Add ticket system with QR scanner and PDF generation
- Add ticket validation and check-in API endpoints - Add PDF ticket generation with QR codes (pdfkit) - Add admin QR scanner page with camera support - Add admin site settings page - Update email templates with PDF ticket download link - Add checked_in_by_admin_id field for audit tracking - Update booking success page with ticket download - Various UI improvements to events and booking pages
This commit is contained in:
@@ -23,7 +23,9 @@
|
|||||||
"jose": "^5.4.0",
|
"jose": "^5.4.0",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
|
"pdfkit": "^0.17.2",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -31,7 +33,9 @@
|
|||||||
"@types/better-sqlite3": "^7.6.10",
|
"@types/better-sqlite3": "^7.6.10",
|
||||||
"@types/node": "^20.14.9",
|
"@types/node": "^20.14.9",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
|
"@types/pdfkit": "^0.17.4",
|
||||||
"@types/pg": "^8.11.6",
|
"@types/pg": "^8.11.6",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"drizzle-kit": "^0.22.8",
|
"drizzle-kit": "^0.22.8",
|
||||||
"tsx": "^4.15.7",
|
"tsx": "^4.15.7",
|
||||||
"typescript": "^5.5.2"
|
"typescript": "^5.5.2"
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ async function migrate() {
|
|||||||
title_es TEXT,
|
title_es TEXT,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
description_es TEXT,
|
description_es TEXT,
|
||||||
|
short_description TEXT,
|
||||||
|
short_description_es TEXT,
|
||||||
start_datetime TEXT NOT NULL,
|
start_datetime TEXT NOT NULL,
|
||||||
end_datetime TEXT,
|
end_datetime TEXT,
|
||||||
location TEXT NOT NULL,
|
location TEXT NOT NULL,
|
||||||
@@ -98,6 +100,14 @@ async function migrate() {
|
|||||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
|
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Add short description columns to events
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS tickets (
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -151,6 +161,11 @@ async function migrate() {
|
|||||||
`);
|
`);
|
||||||
} catch (e) { /* migration may have already run */ }
|
} catch (e) { /* migration may have already run */ }
|
||||||
|
|
||||||
|
// Migration: Add checked_in_by_admin_id column to tickets
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id TEXT REFERENCES users(id)`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
||||||
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||||
|
|
||||||
@@ -347,6 +362,28 @@ async function migrate() {
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Site settings table
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
timezone TEXT NOT NULL DEFAULT 'America/Asuncion',
|
||||||
|
site_name TEXT NOT NULL DEFAULT 'Spanglish',
|
||||||
|
site_description TEXT,
|
||||||
|
site_description_es TEXT,
|
||||||
|
contact_email TEXT,
|
||||||
|
contact_phone TEXT,
|
||||||
|
facebook_url TEXT,
|
||||||
|
instagram_url TEXT,
|
||||||
|
twitter_url TEXT,
|
||||||
|
linkedin_url TEXT,
|
||||||
|
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||||
|
maintenance_message TEXT,
|
||||||
|
maintenance_message_es TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
updated_by TEXT REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
} else {
|
} else {
|
||||||
// PostgreSQL migrations
|
// PostgreSQL migrations
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
@@ -415,6 +452,8 @@ async function migrate() {
|
|||||||
title_es VARCHAR(255),
|
title_es VARCHAR(255),
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
description_es TEXT,
|
description_es TEXT,
|
||||||
|
short_description VARCHAR(300),
|
||||||
|
short_description_es VARCHAR(300),
|
||||||
start_datetime TIMESTAMP NOT NULL,
|
start_datetime TIMESTAMP NOT NULL,
|
||||||
end_datetime TIMESTAMP,
|
end_datetime TIMESTAMP,
|
||||||
location VARCHAR(500) NOT NULL,
|
location VARCHAR(500) NOT NULL,
|
||||||
@@ -439,6 +478,14 @@ async function migrate() {
|
|||||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
|
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Add short description columns to events
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS tickets (
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -462,6 +509,11 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN attendee_ruc VARCHAR(15)`);
|
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN attendee_ruc VARCHAR(15)`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Add checked_in_by_admin_id column to tickets
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id UUID REFERENCES users(id)`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS payments (
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
@@ -642,6 +694,28 @@ async function migrate() {
|
|||||||
updated_at TIMESTAMP NOT NULL
|
updated_at TIMESTAMP NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Site settings table
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
timezone VARCHAR(100) NOT NULL DEFAULT 'America/Asuncion',
|
||||||
|
site_name VARCHAR(255) NOT NULL DEFAULT 'Spanglish',
|
||||||
|
site_description TEXT,
|
||||||
|
site_description_es TEXT,
|
||||||
|
contact_email VARCHAR(255),
|
||||||
|
contact_phone VARCHAR(50),
|
||||||
|
facebook_url VARCHAR(500),
|
||||||
|
instagram_url VARCHAR(500),
|
||||||
|
twitter_url VARCHAR(500),
|
||||||
|
linkedin_url VARCHAR(500),
|
||||||
|
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||||
|
maintenance_message TEXT,
|
||||||
|
maintenance_message_es TEXT,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
updated_by UUID REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Migrations completed successfully!');
|
console.log('Migrations completed successfully!');
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export const sqliteEvents = sqliteTable('events', {
|
|||||||
titleEs: text('title_es'),
|
titleEs: text('title_es'),
|
||||||
description: text('description').notNull(),
|
description: text('description').notNull(),
|
||||||
descriptionEs: text('description_es'),
|
descriptionEs: text('description_es'),
|
||||||
|
shortDescription: text('short_description'),
|
||||||
|
shortDescriptionEs: text('short_description_es'),
|
||||||
startDatetime: text('start_datetime').notNull(),
|
startDatetime: text('start_datetime').notNull(),
|
||||||
endDatetime: text('end_datetime'),
|
endDatetime: text('end_datetime'),
|
||||||
location: text('location').notNull(),
|
location: text('location').notNull(),
|
||||||
@@ -93,6 +95,7 @@ export const sqliteTickets = sqliteTable('tickets', {
|
|||||||
preferredLanguage: text('preferred_language'),
|
preferredLanguage: text('preferred_language'),
|
||||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
|
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
|
||||||
checkinAt: text('checkin_at'),
|
checkinAt: text('checkin_at'),
|
||||||
|
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||||
qrCode: text('qr_code'),
|
qrCode: text('qr_code'),
|
||||||
adminNote: text('admin_note'),
|
adminNote: text('admin_note'),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
@@ -246,6 +249,32 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
|
|||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Site Settings table for global website configuration
|
||||||
|
export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
// Timezone configuration
|
||||||
|
timezone: text('timezone').notNull().default('America/Asuncion'),
|
||||||
|
// Site info
|
||||||
|
siteName: text('site_name').notNull().default('Spanglish'),
|
||||||
|
siteDescription: text('site_description'),
|
||||||
|
siteDescriptionEs: text('site_description_es'),
|
||||||
|
// Contact info
|
||||||
|
contactEmail: text('contact_email'),
|
||||||
|
contactPhone: text('contact_phone'),
|
||||||
|
// Social links (can also be stored here as fallback)
|
||||||
|
facebookUrl: text('facebook_url'),
|
||||||
|
instagramUrl: text('instagram_url'),
|
||||||
|
twitterUrl: text('twitter_url'),
|
||||||
|
linkedinUrl: text('linkedin_url'),
|
||||||
|
// Other settings
|
||||||
|
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
maintenanceMessage: text('maintenance_message'),
|
||||||
|
maintenanceMessageEs: text('maintenance_message_es'),
|
||||||
|
// Metadata
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== PostgreSQL Schema ====================
|
// ==================== PostgreSQL Schema ====================
|
||||||
export const pgUsers = pgTable('users', {
|
export const pgUsers = pgTable('users', {
|
||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
@@ -308,6 +337,8 @@ export const pgEvents = pgTable('events', {
|
|||||||
titleEs: varchar('title_es', { length: 255 }),
|
titleEs: varchar('title_es', { length: 255 }),
|
||||||
description: pgText('description').notNull(),
|
description: pgText('description').notNull(),
|
||||||
descriptionEs: pgText('description_es'),
|
descriptionEs: pgText('description_es'),
|
||||||
|
shortDescription: varchar('short_description', { length: 300 }),
|
||||||
|
shortDescriptionEs: varchar('short_description_es', { length: 300 }),
|
||||||
startDatetime: timestamp('start_datetime').notNull(),
|
startDatetime: timestamp('start_datetime').notNull(),
|
||||||
endDatetime: timestamp('end_datetime'),
|
endDatetime: timestamp('end_datetime'),
|
||||||
location: varchar('location', { length: 500 }).notNull(),
|
location: varchar('location', { length: 500 }).notNull(),
|
||||||
@@ -335,6 +366,7 @@ export const pgTickets = pgTable('tickets', {
|
|||||||
preferredLanguage: varchar('preferred_language', { length: 10 }),
|
preferredLanguage: varchar('preferred_language', { length: 10 }),
|
||||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||||
checkinAt: timestamp('checkin_at'),
|
checkinAt: timestamp('checkin_at'),
|
||||||
|
checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in
|
||||||
qrCode: varchar('qr_code', { length: 255 }),
|
qrCode: varchar('qr_code', { length: 255 }),
|
||||||
adminNote: pgText('admin_note'),
|
adminNote: pgText('admin_note'),
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
@@ -480,6 +512,32 @@ export const pgEmailSettings = pgTable('email_settings', {
|
|||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Site Settings table for global website configuration
|
||||||
|
export const pgSiteSettings = pgTable('site_settings', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
// Timezone configuration
|
||||||
|
timezone: varchar('timezone', { length: 100 }).notNull().default('America/Asuncion'),
|
||||||
|
// Site info
|
||||||
|
siteName: varchar('site_name', { length: 255 }).notNull().default('Spanglish'),
|
||||||
|
siteDescription: pgText('site_description'),
|
||||||
|
siteDescriptionEs: pgText('site_description_es'),
|
||||||
|
// Contact info
|
||||||
|
contactEmail: varchar('contact_email', { length: 255 }),
|
||||||
|
contactPhone: varchar('contact_phone', { length: 50 }),
|
||||||
|
// Social links
|
||||||
|
facebookUrl: varchar('facebook_url', { length: 500 }),
|
||||||
|
instagramUrl: varchar('instagram_url', { length: 500 }),
|
||||||
|
twitterUrl: varchar('twitter_url', { length: 500 }),
|
||||||
|
linkedinUrl: varchar('linkedin_url', { length: 500 }),
|
||||||
|
// Other settings
|
||||||
|
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
|
||||||
|
maintenanceMessage: pgText('maintenance_message'),
|
||||||
|
maintenanceMessageEs: pgText('maintenance_message_es'),
|
||||||
|
// Metadata
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||||
|
});
|
||||||
|
|
||||||
// Export the appropriate schema based on DB_TYPE
|
// Export the appropriate schema based on DB_TYPE
|
||||||
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
|
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
|
||||||
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
|
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
|
||||||
@@ -497,6 +555,7 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
|
|||||||
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
||||||
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
|
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
|
||||||
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
|
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
|
||||||
|
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
||||||
|
|
||||||
// Type exports
|
// Type exports
|
||||||
export type User = typeof sqliteUsers.$inferSelect;
|
export type User = typeof sqliteUsers.$inferSelect;
|
||||||
@@ -523,3 +582,5 @@ export type UserSession = typeof sqliteUserSessions.$inferSelect;
|
|||||||
export type NewUserSession = typeof sqliteUserSessions.$inferInsert;
|
export type NewUserSession = typeof sqliteUserSessions.$inferInsert;
|
||||||
export type Invoice = typeof sqliteInvoices.$inferSelect;
|
export type Invoice = typeof sqliteInvoices.$inferSelect;
|
||||||
export type NewInvoice = typeof sqliteInvoices.$inferInsert;
|
export type NewInvoice = typeof sqliteInvoices.$inferInsert;
|
||||||
|
export type SiteSettings = typeof sqliteSiteSettings.$inferSelect;
|
||||||
|
export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import lnbitsRoutes from './routes/lnbits.js';
|
|||||||
import emailsRoutes from './routes/emails.js';
|
import emailsRoutes from './routes/emails.js';
|
||||||
import paymentOptionsRoutes from './routes/payment-options.js';
|
import paymentOptionsRoutes from './routes/payment-options.js';
|
||||||
import dashboardRoutes from './routes/dashboard.js';
|
import dashboardRoutes from './routes/dashboard.js';
|
||||||
|
import siteSettingsRoutes from './routes/site-settings.js';
|
||||||
import emailService from './lib/email.js';
|
import emailService from './lib/email.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -1712,6 +1713,7 @@ app.route('/api/lnbits', lnbitsRoutes);
|
|||||||
app.route('/api/emails', emailsRoutes);
|
app.route('/api/emails', emailsRoutes);
|
||||||
app.route('/api/payment-options', paymentOptionsRoutes);
|
app.route('/api/payment-options', paymentOptionsRoutes);
|
||||||
app.route('/api/dashboard', dashboardRoutes);
|
app.route('/api/dashboard', dashboardRoutes);
|
||||||
|
app.route('/api/site-settings', siteSettingsRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.notFound((c) => {
|
app.notFound((c) => {
|
||||||
|
|||||||
@@ -548,6 +548,10 @@ export const emailService = {
|
|||||||
const locale = ticket.preferredLanguage || 'en';
|
const locale = ticket.preferredLanguage || 'en';
|
||||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||||
|
|
||||||
|
// Generate ticket PDF URL
|
||||||
|
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||||
|
const ticketPdfUrl = `${apiUrl}/api/tickets/${ticket.id}/pdf`;
|
||||||
|
|
||||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||||
return this.sendTemplateEmail({
|
return this.sendTemplateEmail({
|
||||||
templateSlug: 'booking-confirmation',
|
templateSlug: 'booking-confirmation',
|
||||||
@@ -560,6 +564,7 @@ export const emailService = {
|
|||||||
attendeeEmail: ticket.attendeeEmail,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
ticketId: ticket.id,
|
ticketId: ticket.id,
|
||||||
qrCode: ticket.qrCode || '',
|
qrCode: ticket.qrCode || '',
|
||||||
|
ticketPdfUrl,
|
||||||
eventTitle,
|
eventTitle,
|
||||||
eventDate: this.formatDate(event.startDatetime, locale),
|
eventDate: this.formatDate(event.startDatetime, locale),
|
||||||
eventTime: this.formatTime(event.startDatetime, locale),
|
eventTime: this.formatTime(event.startDatetime, locale),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const bookingVariables: EmailVariable[] = [
|
|||||||
{ name: 'attendeeEmail', description: 'Attendee email', example: 'john@example.com' },
|
{ name: 'attendeeEmail', description: 'Attendee email', example: 'john@example.com' },
|
||||||
{ name: 'ticketId', description: 'Unique ticket ID', example: 'TKT-ABC123' },
|
{ name: 'ticketId', description: 'Unique ticket ID', example: 'TKT-ABC123' },
|
||||||
{ name: 'qrCode', description: 'QR code for check-in', example: 'data:image/png;base64,...' },
|
{ name: 'qrCode', description: 'QR code for check-in', example: 'data:image/png;base64,...' },
|
||||||
|
{ name: 'ticketPdfUrl', description: 'URL to download ticket PDF', example: 'https://api.spanglish.com/api/tickets/abc123/pdf' },
|
||||||
{ name: 'eventTitle', description: 'Event title', example: 'Spanglish Night - January Edition' },
|
{ name: 'eventTitle', description: 'Event title', example: 'Spanglish Night - January Edition' },
|
||||||
{ name: 'eventDate', description: 'Event date formatted', example: 'January 28, 2026' },
|
{ name: 'eventDate', description: 'Event date formatted', example: 'January 28, 2026' },
|
||||||
{ name: 'eventTime', description: 'Event time', example: '7:00 PM' },
|
{ name: 'eventTime', description: 'Event time', example: '7:00 PM' },
|
||||||
@@ -228,18 +229,17 @@ export const defaultTemplates: DefaultTemplate[] = [
|
|||||||
|
|
||||||
<div class="ticket-box">
|
<div class="ticket-box">
|
||||||
<p>Your Ticket ID</p>
|
<p>Your Ticket ID</p>
|
||||||
<p class="ticket-id">{{ticketId}}</p>
|
<p class="ticket-id">{{qrCode}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if qrCode}}
|
{{#if ticketPdfUrl}}
|
||||||
<div class="qr-code">
|
<p style="text-align: center; margin: 24px 0;">
|
||||||
<p><strong>Show this QR code at check-in:</strong></p>
|
<a href="{{ticketPdfUrl}}" class="btn" style="background-color: #1a1a1a; color: #f4d03f;">📄 Download Your Ticket (PDF)</a>
|
||||||
<img src="{{qrCode}}" alt="Check-in QR Code" />
|
</p>
|
||||||
</div>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div class="note">
|
<div class="note">
|
||||||
<strong>💡 Important:</strong> Please arrive 10-15 minutes early for check-in. Bring your ticket ID or show this email.
|
<strong>💡 Important:</strong> Please arrive 10-15 minutes early for check-in. Show the PDF ticket or this email at the entrance.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>See you at Spanglish!</p>
|
<p>See you at Spanglish!</p>
|
||||||
@@ -263,18 +263,17 @@ export const defaultTemplates: DefaultTemplate[] = [
|
|||||||
|
|
||||||
<div class="ticket-box">
|
<div class="ticket-box">
|
||||||
<p>Tu ID de Ticket</p>
|
<p>Tu ID de Ticket</p>
|
||||||
<p class="ticket-id">{{ticketId}}</p>
|
<p class="ticket-id">{{qrCode}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if qrCode}}
|
{{#if ticketPdfUrl}}
|
||||||
<div class="qr-code">
|
<p style="text-align: center; margin: 24px 0;">
|
||||||
<p><strong>Muestra este código QR en el check-in:</strong></p>
|
<a href="{{ticketPdfUrl}}" class="btn" style="background-color: #1a1a1a; color: #f4d03f;">📄 Descargar Tu Ticket (PDF)</a>
|
||||||
<img src="{{qrCode}}" alt="Código QR de Check-in" />
|
</p>
|
||||||
</div>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div class="note">
|
<div class="note">
|
||||||
<strong>💡 Importante:</strong> Por favor llega 10-15 minutos antes para el check-in. Trae tu ID de ticket o muestra este email.
|
<strong>💡 Importante:</strong> Por favor llega 10-15 minutos antes para el check-in. Muestra el PDF del ticket o este email en la entrada.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>¡Nos vemos en Spanglish!</p>
|
<p>¡Nos vemos en Spanglish!</p>
|
||||||
|
|||||||
246
backend/src/lib/pdf.ts
Normal file
246
backend/src/lib/pdf.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
// PDF Ticket Generation Service
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
interface TicketData {
|
||||||
|
id: string;
|
||||||
|
qrCode: string;
|
||||||
|
attendeeName: string;
|
||||||
|
attendeeEmail?: string;
|
||||||
|
event: {
|
||||||
|
title: string;
|
||||||
|
startDatetime: string;
|
||||||
|
endDatetime?: string;
|
||||||
|
location: string;
|
||||||
|
locationUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a QR code as a data URL
|
||||||
|
*/
|
||||||
|
async function generateQRCode(data: string): Promise<Buffer> {
|
||||||
|
return QRCode.toBuffer(data, {
|
||||||
|
type: 'png',
|
||||||
|
width: 200,
|
||||||
|
margin: 2,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for display
|
||||||
|
*/
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time for display
|
||||||
|
*/
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a PDF ticket for a single ticket
|
||||||
|
*/
|
||||||
|
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: 'A4',
|
||||||
|
margin: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
||||||
|
|
||||||
|
// Generate QR code with ticket URL
|
||||||
|
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
|
||||||
|
const qrBuffer = await generateQRCode(qrUrl);
|
||||||
|
|
||||||
|
// ==================== Header ====================
|
||||||
|
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
|
||||||
|
|
||||||
|
// Divider line
|
||||||
|
doc.moveDown(1);
|
||||||
|
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||||
|
doc.moveDown(1);
|
||||||
|
|
||||||
|
// ==================== Event Info ====================
|
||||||
|
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
// Date and time
|
||||||
|
doc.fontSize(14).fillColor('#333');
|
||||||
|
doc.text(formatDate(ticket.event.startDatetime), { align: 'center' });
|
||||||
|
|
||||||
|
const startTime = formatTime(ticket.event.startDatetime);
|
||||||
|
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null;
|
||||||
|
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
|
||||||
|
doc.text(timeRange, { align: 'center' });
|
||||||
|
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
|
||||||
|
|
||||||
|
// ==================== QR Code ====================
|
||||||
|
doc.moveDown(2);
|
||||||
|
|
||||||
|
// Center the QR code
|
||||||
|
const qrSize = 180;
|
||||||
|
const pageWidth = 595; // A4 width in points
|
||||||
|
const qrX = (pageWidth - qrSize) / 2;
|
||||||
|
|
||||||
|
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
|
||||||
|
doc.y += qrSize + 10;
|
||||||
|
|
||||||
|
// ==================== Attendee Info ====================
|
||||||
|
doc.moveDown(1);
|
||||||
|
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
|
||||||
|
|
||||||
|
if (ticket.attendeeEmail) {
|
||||||
|
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Ticket ID ====================
|
||||||
|
doc.moveDown(1);
|
||||||
|
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
|
||||||
|
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
|
||||||
|
|
||||||
|
// ==================== Footer ====================
|
||||||
|
doc.moveDown(2);
|
||||||
|
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a combined PDF with multiple tickets
|
||||||
|
*/
|
||||||
|
export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: 'A4',
|
||||||
|
margin: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
||||||
|
|
||||||
|
for (let i = 0; i < tickets.length; i++) {
|
||||||
|
const ticket = tickets[i];
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
doc.addPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
|
||||||
|
const qrBuffer = await generateQRCode(qrUrl);
|
||||||
|
|
||||||
|
// ==================== Header ====================
|
||||||
|
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
|
||||||
|
|
||||||
|
// Divider line
|
||||||
|
doc.moveDown(1);
|
||||||
|
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||||
|
doc.moveDown(1);
|
||||||
|
|
||||||
|
// ==================== Event Info ====================
|
||||||
|
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
doc.fontSize(14).fillColor('#333');
|
||||||
|
doc.text(formatDate(ticket.event.startDatetime), { align: 'center' });
|
||||||
|
|
||||||
|
const startTime = formatTime(ticket.event.startDatetime);
|
||||||
|
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null;
|
||||||
|
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
|
||||||
|
doc.text(timeRange, { align: 'center' });
|
||||||
|
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
|
||||||
|
|
||||||
|
// ==================== QR Code ====================
|
||||||
|
doc.moveDown(2);
|
||||||
|
|
||||||
|
const qrSize = 180;
|
||||||
|
const pageWidth = 595;
|
||||||
|
const qrX = (pageWidth - qrSize) / 2;
|
||||||
|
|
||||||
|
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
|
||||||
|
doc.y += qrSize + 10;
|
||||||
|
|
||||||
|
// ==================== Attendee Info ====================
|
||||||
|
doc.moveDown(1);
|
||||||
|
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
|
||||||
|
|
||||||
|
if (ticket.attendeeEmail) {
|
||||||
|
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Ticket ID ====================
|
||||||
|
doc.moveDown(1);
|
||||||
|
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
|
||||||
|
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
|
||||||
|
|
||||||
|
// Ticket number for multi-ticket bookings
|
||||||
|
if (tickets.length > 1) {
|
||||||
|
doc.text(`Ticket ${i + 1} of ${tickets.length}`, { align: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Footer ====================
|
||||||
|
doc.moveDown(2);
|
||||||
|
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
generateTicketPDF,
|
||||||
|
generateCombinedTicketsPDF,
|
||||||
|
};
|
||||||
@@ -28,6 +28,8 @@ const baseEventSchema = z.object({
|
|||||||
titleEs: z.string().optional().nullable(),
|
titleEs: z.string().optional().nullable(),
|
||||||
description: z.string().min(1),
|
description: z.string().min(1),
|
||||||
descriptionEs: z.string().optional().nullable(),
|
descriptionEs: z.string().optional().nullable(),
|
||||||
|
shortDescription: z.string().max(300).optional().nullable(),
|
||||||
|
shortDescriptionEs: z.string().max(300).optional().nullable(),
|
||||||
startDatetime: z.string(),
|
startDatetime: z.string(),
|
||||||
endDatetime: z.string().optional().nullable(),
|
endDatetime: z.string().optional().nullable(),
|
||||||
location: z.string().min(1),
|
location: z.string().min(1),
|
||||||
@@ -315,6 +317,8 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
|||||||
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
|
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
|
||||||
description: existing.description,
|
description: existing.description,
|
||||||
descriptionEs: existing.descriptionEs,
|
descriptionEs: existing.descriptionEs,
|
||||||
|
shortDescription: existing.shortDescription,
|
||||||
|
shortDescriptionEs: existing.shortDescriptionEs,
|
||||||
startDatetime: existing.startDatetime,
|
startDatetime: existing.startDatetime,
|
||||||
endDatetime: existing.endDatetime,
|
endDatetime: existing.endDatetime,
|
||||||
location: existing.location,
|
location: existing.location,
|
||||||
|
|||||||
144
backend/src/routes/site-settings.ts
Normal file
144
backend/src/routes/site-settings.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db, siteSettings } from '../db/index.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
|
interface UserContext {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||||
|
|
||||||
|
// Validation schema for updating site settings
|
||||||
|
const updateSiteSettingsSchema = z.object({
|
||||||
|
timezone: z.string().optional(),
|
||||||
|
siteName: z.string().optional(),
|
||||||
|
siteDescription: z.string().optional().nullable(),
|
||||||
|
siteDescriptionEs: z.string().optional().nullable(),
|
||||||
|
contactEmail: z.string().email().optional().nullable().or(z.literal('')),
|
||||||
|
contactPhone: z.string().optional().nullable(),
|
||||||
|
facebookUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||||
|
instagramUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||||
|
twitterUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||||
|
linkedinUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||||
|
maintenanceMode: z.boolean().optional(),
|
||||||
|
maintenanceMessage: z.string().optional().nullable(),
|
||||||
|
maintenanceMessageEs: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get site settings (public - needed for frontend timezone)
|
||||||
|
siteSettingsRouter.get('/', async (c) => {
|
||||||
|
const settings = await (db as any).select().from(siteSettings).limit(1).get();
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
// Return default settings if none exist
|
||||||
|
return c.json({
|
||||||
|
settings: {
|
||||||
|
timezone: 'America/Asuncion',
|
||||||
|
siteName: 'Spanglish',
|
||||||
|
siteDescription: null,
|
||||||
|
siteDescriptionEs: null,
|
||||||
|
contactEmail: null,
|
||||||
|
contactPhone: null,
|
||||||
|
facebookUrl: null,
|
||||||
|
instagramUrl: null,
|
||||||
|
twitterUrl: null,
|
||||||
|
linkedinUrl: null,
|
||||||
|
maintenanceMode: false,
|
||||||
|
maintenanceMessage: null,
|
||||||
|
maintenanceMessageEs: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ settings });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get available timezones
|
||||||
|
siteSettingsRouter.get('/timezones', async (c) => {
|
||||||
|
// Common timezones for Americas (especially relevant for Paraguay)
|
||||||
|
const timezones = [
|
||||||
|
{ value: 'America/Asuncion', label: 'Paraguay (Asunción) - UTC-4/-3' },
|
||||||
|
{ value: 'America/Sao_Paulo', label: 'Brazil (São Paulo) - UTC-3' },
|
||||||
|
{ value: 'America/Buenos_Aires', label: 'Argentina (Buenos Aires) - UTC-3' },
|
||||||
|
{ value: 'America/Santiago', label: 'Chile (Santiago) - UTC-4/-3' },
|
||||||
|
{ value: 'America/Lima', label: 'Peru (Lima) - UTC-5' },
|
||||||
|
{ value: 'America/Bogota', label: 'Colombia (Bogotá) - UTC-5' },
|
||||||
|
{ value: 'America/Caracas', label: 'Venezuela (Caracas) - UTC-4' },
|
||||||
|
{ value: 'America/La_Paz', label: 'Bolivia (La Paz) - UTC-4' },
|
||||||
|
{ value: 'America/Montevideo', label: 'Uruguay (Montevideo) - UTC-3' },
|
||||||
|
{ value: 'America/New_York', label: 'US Eastern - UTC-5/-4' },
|
||||||
|
{ value: 'America/Chicago', label: 'US Central - UTC-6/-5' },
|
||||||
|
{ value: 'America/Denver', label: 'US Mountain - UTC-7/-6' },
|
||||||
|
{ value: 'America/Los_Angeles', label: 'US Pacific - UTC-8/-7' },
|
||||||
|
{ value: 'America/Mexico_City', label: 'Mexico (Mexico City) - UTC-6/-5' },
|
||||||
|
{ value: 'Europe/London', label: 'UK (London) - UTC+0/+1' },
|
||||||
|
{ value: 'Europe/Madrid', label: 'Spain (Madrid) - UTC+1/+2' },
|
||||||
|
{ value: 'Europe/Paris', label: 'France (Paris) - UTC+1/+2' },
|
||||||
|
{ value: 'Europe/Berlin', label: 'Germany (Berlin) - UTC+1/+2' },
|
||||||
|
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return c.json({ timezones });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update site settings (admin only)
|
||||||
|
siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSiteSettingsSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const user = c.get('user');
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Check if settings exist
|
||||||
|
const existing = await (db as any).select().from(siteSettings).limit(1).get();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// Create new settings record
|
||||||
|
const id = generateId();
|
||||||
|
const newSettings = {
|
||||||
|
id,
|
||||||
|
timezone: data.timezone || 'America/Asuncion',
|
||||||
|
siteName: data.siteName || 'Spanglish',
|
||||||
|
siteDescription: data.siteDescription || null,
|
||||||
|
siteDescriptionEs: data.siteDescriptionEs || null,
|
||||||
|
contactEmail: data.contactEmail || null,
|
||||||
|
contactPhone: data.contactPhone || null,
|
||||||
|
facebookUrl: data.facebookUrl || null,
|
||||||
|
instagramUrl: data.instagramUrl || null,
|
||||||
|
twitterUrl: data.twitterUrl || null,
|
||||||
|
linkedinUrl: data.linkedinUrl || null,
|
||||||
|
maintenanceMode: data.maintenanceMode || false,
|
||||||
|
maintenanceMessage: data.maintenanceMessage || null,
|
||||||
|
maintenanceMessageEs: data.maintenanceMessageEs || null,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(siteSettings).values(newSettings);
|
||||||
|
|
||||||
|
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing settings
|
||||||
|
const updateData = {
|
||||||
|
...data,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(siteSettings)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq((siteSettings as any).id, existing.id));
|
||||||
|
|
||||||
|
const updated = await (db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id)).get();
|
||||||
|
|
||||||
|
return c.json({ settings: updated, message: 'Settings updated successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default siteSettingsRouter;
|
||||||
@@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
|
|||||||
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
|
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
|
||||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
|
import { generateTicketPDF } from '../lib/pdf.js';
|
||||||
|
|
||||||
const ticketsRouter = new Hono();
|
const ticketsRouter = new Hono();
|
||||||
|
|
||||||
@@ -247,6 +248,70 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Download ticket as PDF
|
||||||
|
ticketsRouter.get('/:id/pdf', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const user = await getAuthUser(c);
|
||||||
|
|
||||||
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization - must be ticket owner or admin
|
||||||
|
if (user) {
|
||||||
|
const isAdmin = ['admin', 'organizer', 'staff'].includes(user.role);
|
||||||
|
const isOwner = user.id === ticket.userId;
|
||||||
|
|
||||||
|
if (!isAdmin && !isOwner) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Allow unauthenticated access via ticket ID for email links
|
||||||
|
// The ticket ID itself serves as a secure token (UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only generate PDF for confirmed or checked-in tickets
|
||||||
|
if (!['confirmed', 'checked_in'].includes(ticket.status)) {
|
||||||
|
return c.json({ error: 'Ticket is not confirmed' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get event
|
||||||
|
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfBuffer = await generateTicketPDF({
|
||||||
|
id: ticket.id,
|
||||||
|
qrCode: ticket.qrCode,
|
||||||
|
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
|
event: {
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
endDatetime: event.endDatetime,
|
||||||
|
location: event.location,
|
||||||
|
locationUrl: event.locationUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set response headers for PDF download
|
||||||
|
return new Response(new Uint8Array(pdfBuffer), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="spanglish-ticket-${ticket.qrCode}.pdf"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('PDF generation error:', error);
|
||||||
|
return c.json({ error: 'Failed to generate PDF' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get ticket by ID
|
// Get ticket by ID
|
||||||
ticketsRouter.get('/:id', async (c) => {
|
ticketsRouter.get('/:id', async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
@@ -301,9 +366,108 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
|||||||
return c.json({ ticket: updated });
|
return c.json({ ticket: updated });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Validate ticket by QR code (for scanner)
|
||||||
|
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const { code, eventId } = body;
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return c.json({ error: 'Code is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find ticket by QR code or ID
|
||||||
|
let ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).qrCode, code))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// If not found by QR, try by ID
|
||||||
|
if (!ticket) {
|
||||||
|
ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, code))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({
|
||||||
|
valid: false,
|
||||||
|
error: 'Ticket not found',
|
||||||
|
status: 'invalid',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If eventId is provided, verify the ticket is for that event
|
||||||
|
if (eventId && ticket.eventId !== eventId) {
|
||||||
|
return c.json({
|
||||||
|
valid: false,
|
||||||
|
error: 'Ticket is for a different event',
|
||||||
|
status: 'wrong_event',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get event details
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Determine validity status
|
||||||
|
let validityStatus = 'invalid';
|
||||||
|
let canCheckIn = false;
|
||||||
|
|
||||||
|
if (ticket.status === 'cancelled') {
|
||||||
|
validityStatus = 'cancelled';
|
||||||
|
} else if (ticket.status === 'pending') {
|
||||||
|
validityStatus = 'pending_payment';
|
||||||
|
} else if (ticket.status === 'checked_in') {
|
||||||
|
validityStatus = 'already_checked_in';
|
||||||
|
} else if (ticket.status === 'confirmed') {
|
||||||
|
validityStatus = 'valid';
|
||||||
|
canCheckIn = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin who checked in (if applicable)
|
||||||
|
let checkedInBy = null;
|
||||||
|
if (ticket.checkedInByAdminId) {
|
||||||
|
const admin = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq((users as any).id, ticket.checkedInByAdminId))
|
||||||
|
.get();
|
||||||
|
checkedInBy = admin ? admin.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
valid: validityStatus === 'valid',
|
||||||
|
status: validityStatus,
|
||||||
|
canCheckIn,
|
||||||
|
ticket: {
|
||||||
|
id: ticket.id,
|
||||||
|
qrCode: ticket.qrCode,
|
||||||
|
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
|
attendeePhone: ticket.attendeePhone,
|
||||||
|
status: ticket.status,
|
||||||
|
checkinAt: ticket.checkinAt,
|
||||||
|
checkedInBy,
|
||||||
|
},
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
} : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Check-in ticket
|
// Check-in ticket
|
||||||
ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
|
const adminUser = (c as any).get('user');
|
||||||
|
|
||||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
@@ -319,14 +483,33 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
|
|||||||
return c.json({ error: 'Ticket must be confirmed before check-in' }, 400);
|
return c.json({ error: 'Ticket must be confirmed before check-in' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(tickets)
|
.update(tickets)
|
||||||
.set({ status: 'checked_in', checkinAt: getNow() })
|
.set({
|
||||||
|
status: 'checked_in',
|
||||||
|
checkinAt: now,
|
||||||
|
checkedInByAdminId: adminUser?.id || null,
|
||||||
|
})
|
||||||
.where(eq((tickets as any).id, id));
|
.where(eq((tickets as any).id, id));
|
||||||
|
|
||||||
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
return c.json({ ticket: updated, message: 'Check-in successful' });
|
// Get event for response
|
||||||
|
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ticket: {
|
||||||
|
...updated,
|
||||||
|
attendeeName: `${updated.attendeeFirstName} ${updated.attendeeLastName || ''}`.trim(),
|
||||||
|
},
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
} : null,
|
||||||
|
message: 'Check-in successful'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark payment as received (for cash payments - admin only)
|
// Mark payment as received (for cash payments - admin only)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.1.4",
|
"@heroicons/react": "^2.1.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.4",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -913,10 +913,12 @@ export default function BookingPage() {
|
|||||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
<span>{event.location}</span>
|
<span>{event.location}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
{!event.externalBookingEnabled && (
|
||||||
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
<div className="flex items-center gap-3">
|
||||||
<span>{event.availableSeats} {t('events.details.spotsLeft')}</span>
|
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
</div>
|
<span>{event.availableSeats} {t('events.details.spotsLeft')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
|
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
<span className="font-bold text-lg">
|
<span className="font-bold text-lg">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
TicketIcon,
|
TicketIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
|
ArrowDownTrayIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
export default function BookingSuccessPage() {
|
export default function BookingSuccessPage() {
|
||||||
@@ -224,6 +225,20 @@ export default function BookingSuccessPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Download Ticket Button */}
|
||||||
|
{isPaid && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<a
|
||||||
|
href={`/api/tickets/${ticketId}/pdf`}
|
||||||
|
download
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon className="w-5 h-5" />
|
||||||
|
{locale === 'es' ? 'Descargar Ticket' : 'Download Ticket'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
<Link href="/events">
|
<Link href="/events">
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ export default function CommunityPage() {
|
|||||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
|
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
|
||||||
{/* WhatsApp Card */}
|
{/* WhatsApp Card */}
|
||||||
{whatsappUrl && (
|
{whatsappUrl && (
|
||||||
<Card className="p-8 text-center card-hover">
|
<Card className="p-8 text-center card-hover h-full flex flex-col">
|
||||||
<div className="w-20 h-20 mx-auto bg-green-100 rounded-full flex items-center justify-center">
|
<div className="w-20 h-20 mx-auto bg-green-100 rounded-full flex items-center justify-center">
|
||||||
<svg className="w-10 h-10 text-green-600" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-10 h-10 text-green-600" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-6 text-xl font-semibold">{t('community.whatsapp.title')}</h3>
|
<h3 className="mt-6 text-xl font-semibold">{t('community.whatsapp.title')}</h3>
|
||||||
<p className="mt-3 text-gray-600">{t('community.whatsapp.description')}</p>
|
<p className="mt-3 text-gray-600 flex-grow">{t('community.whatsapp.description')}</p>
|
||||||
<a
|
<a
|
||||||
href={whatsappUrl}
|
href={whatsappUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -64,14 +64,14 @@ export default function CommunityPage() {
|
|||||||
|
|
||||||
{/* Telegram Card */}
|
{/* Telegram Card */}
|
||||||
{telegramUrl && (
|
{telegramUrl && (
|
||||||
<Card className="p-8 text-center card-hover">
|
<Card className="p-8 text-center card-hover h-full flex flex-col">
|
||||||
<div className="w-20 h-20 mx-auto bg-blue-100 rounded-full flex items-center justify-center">
|
<div className="w-20 h-20 mx-auto bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
<svg className="w-10 h-10 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-10 h-10 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-6 text-xl font-semibold">{t('community.telegram.title')}</h3>
|
<h3 className="mt-6 text-xl font-semibold">{t('community.telegram.title')}</h3>
|
||||||
<p className="mt-3 text-gray-600">{t('community.telegram.description')}</p>
|
<p className="mt-3 text-gray-600 flex-grow">{t('community.telegram.description')}</p>
|
||||||
<a
|
<a
|
||||||
href={telegramUrl}
|
href={telegramUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -87,19 +87,19 @@ export default function CommunityPage() {
|
|||||||
|
|
||||||
{/* Instagram Card */}
|
{/* Instagram Card */}
|
||||||
{instagramUrl && (
|
{instagramUrl && (
|
||||||
<Card className="p-8 text-center card-hover">
|
<Card className="p-8 text-center card-hover h-full flex flex-col">
|
||||||
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
|
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
|
||||||
<CameraIcon className="w-10 h-10 text-white" />
|
<CameraIcon className="w-10 h-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-6 text-xl font-semibold">{t('community.instagram.title')}</h3>
|
<h3 className="mt-6 text-xl font-semibold">{t('community.instagram.title')}</h3>
|
||||||
<p className="mt-3 text-gray-600">{t('community.instagram.description')}</p>
|
<p className="mt-3 text-gray-600 flex-grow">{t('community.instagram.description')}</p>
|
||||||
<a
|
<a
|
||||||
href={instagramUrl}
|
href={instagramUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-block mt-6"
|
className="inline-block mt-6"
|
||||||
>
|
>
|
||||||
<Button variant="secondary">
|
<Button>
|
||||||
{t('community.instagram.button')}
|
{t('community.instagram.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
@@ -108,14 +108,14 @@ export default function CommunityPage() {
|
|||||||
|
|
||||||
{/* TikTok Card */}
|
{/* TikTok Card */}
|
||||||
{tiktokUrl && (
|
{tiktokUrl && (
|
||||||
<Card className="p-8 text-center card-hover">
|
<Card className="p-8 text-center card-hover h-full flex flex-col">
|
||||||
<div className="w-20 h-20 mx-auto bg-black rounded-full flex items-center justify-center">
|
<div className="w-20 h-20 mx-auto bg-black rounded-full flex items-center justify-center">
|
||||||
<svg className="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
|
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-6 text-xl font-semibold">{t('community.tiktok.title')}</h3>
|
<h3 className="mt-6 text-xl font-semibold">{t('community.tiktok.title')}</h3>
|
||||||
<p className="mt-3 text-gray-600">{t('community.tiktok.description')}</p>
|
<p className="mt-3 text-gray-600 flex-grow">{t('community.tiktok.description')}</p>
|
||||||
<a
|
<a
|
||||||
href={tiktokUrl}
|
href={tiktokUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export default function HeroSection() {
|
|||||||
<div className="container-page py-16 md:py-24">
|
<div className="container-page py-16 md:py-24">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-primary-dark leading-tight text-balance">
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold leading-tight text-balance" style={{ color: '#002F44' }}>
|
||||||
{t('home.hero.title')}
|
{t('home.hero.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-xl text-gray-600">
|
<p className="mt-6 text-xl" style={{ color: '#002F44' }}>
|
||||||
{t('home.hero.subtitle')}
|
{t('home.hero.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex flex-wrap gap-4">
|
<div className="mt-8 flex flex-wrap gap-4">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function NewsletterSection() {
|
|||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section-padding bg-primary-dark text-white">
|
<section className="section-padding text-white" style={{ backgroundColor: '#002F44' }}>
|
||||||
<div className="container-page">
|
<div className="container-page">
|
||||||
<div className="max-w-2xl mx-auto text-center">
|
<div className="max-w-2xl mx-auto text-center">
|
||||||
<SparklesIcon className="w-12 h-12 mx-auto text-primary-yellow" />
|
<SparklesIcon className="w-12 h-12 mx-auto text-primary-yellow" />
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ export default function NextEventSection() {
|
|||||||
<h3 className="text-2xl font-bold text-primary-dark">
|
<h3 className="text-2xl font-bold text-primary-dark">
|
||||||
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-3 text-gray-600">
|
<p className="mt-3 text-gray-600 whitespace-pre-line">
|
||||||
{locale === 'es' && nextEvent.descriptionEs
|
{locale === 'es'
|
||||||
? nextEvent.descriptionEs
|
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
|
||||||
: nextEvent.description}
|
: (nextEvent.shortDescription || nextEvent.description)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
@@ -93,9 +93,11 @@ export default function NextEventSection() {
|
|||||||
? t('events.details.free')
|
? t('events.details.free')
|
||||||
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
{!nextEvent.externalBookingEnabled && (
|
||||||
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
</p>
|
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button size="lg" className="mt-6">
|
<Button size="lg" className="mt-6">
|
||||||
{t('common.moreInfo')}
|
{t('common.moreInfo')}
|
||||||
|
|||||||
@@ -120,7 +120,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl">⏰</span>
|
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl">⏰</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{t('events.details.time')}</p>
|
<p className="font-medium">{t('events.details.time')}</p>
|
||||||
<p className="text-gray-600" suppressHydrationWarning>{formatTime(event.startDatetime)}</p>
|
<p className="text-gray-600" suppressHydrationWarning>
|
||||||
|
{formatTime(event.startDatetime)}
|
||||||
|
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -142,15 +145,17 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
{!event.externalBookingEnabled && (
|
||||||
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
<div className="flex items-start gap-3">
|
||||||
<div>
|
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||||
<p className="font-medium">{t('events.details.capacity')}</p>
|
<div>
|
||||||
<p className="text-gray-600">
|
<p className="font-medium">{t('events.details.capacity')}</p>
|
||||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
<p className="text-gray-600">
|
||||||
</p>
|
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||||
@@ -213,9 +218,11 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-gray-500">
|
{!event.externalBookingEnabled && (
|
||||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
<p className="mt-4 text-center text-sm text-gray-500">
|
||||||
</p>
|
{event.availableSeats} {t('events.details.spotsLeft')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface Event {
|
|||||||
titleEs?: string;
|
titleEs?: string;
|
||||||
description: string;
|
description: string;
|
||||||
descriptionEs?: string;
|
descriptionEs?: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
shortDescriptionEs?: string;
|
||||||
startDatetime: string;
|
startDatetime: string;
|
||||||
endDatetime?: string;
|
endDatetime?: string;
|
||||||
location: string;
|
location: string;
|
||||||
@@ -47,10 +49,12 @@ export async function generateMetadata({ params }: { params: { id: string } }):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const title = event.title;
|
const title = event.title;
|
||||||
// Use the beginning of the event description, truncated to ~155 chars for SEO
|
// Use short description if available, otherwise fall back to truncated full description
|
||||||
const description = event.description.length > 155
|
const description = event.shortDescription
|
||||||
? event.description.slice(0, 152).trim() + '...'
|
? event.shortDescription
|
||||||
: event.description;
|
: (event.description.length > 155
|
||||||
|
? event.description.slice(0, 152).trim() + '...'
|
||||||
|
: event.description);
|
||||||
|
|
||||||
// Convert relative banner URL to absolute URL for SEO
|
// Convert relative banner URL to absolute URL for SEO
|
||||||
const imageUrl = event.bannerUrl
|
const imageUrl = event.bannerUrl
|
||||||
|
|||||||
@@ -135,12 +135,14 @@ export default function EventsPage() {
|
|||||||
<MapPinIcon className="w-4 h-4" />
|
<MapPinIcon className="w-4 h-4" />
|
||||||
<span className="truncate">{event.location}</span>
|
<span className="truncate">{event.location}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
{!event.externalBookingEnabled && (
|
||||||
<UserGroupIcon className="w-4 h-4" />
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<UserGroupIcon className="w-4 h-4" />
|
||||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
<span>
|
||||||
</span>
|
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex items-center justify-between">
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export default function AdminEventsPage() {
|
|||||||
titleEs: string;
|
titleEs: string;
|
||||||
description: string;
|
description: string;
|
||||||
descriptionEs: string;
|
descriptionEs: string;
|
||||||
|
shortDescription: string;
|
||||||
|
shortDescriptionEs: string;
|
||||||
startDatetime: string;
|
startDatetime: string;
|
||||||
endDatetime: string;
|
endDatetime: string;
|
||||||
location: string;
|
location: string;
|
||||||
@@ -41,6 +43,8 @@ export default function AdminEventsPage() {
|
|||||||
titleEs: '',
|
titleEs: '',
|
||||||
description: '',
|
description: '',
|
||||||
descriptionEs: '',
|
descriptionEs: '',
|
||||||
|
shortDescription: '',
|
||||||
|
shortDescriptionEs: '',
|
||||||
startDatetime: '',
|
startDatetime: '',
|
||||||
endDatetime: '',
|
endDatetime: '',
|
||||||
location: '',
|
location: '',
|
||||||
@@ -75,6 +79,8 @@ export default function AdminEventsPage() {
|
|||||||
titleEs: '',
|
titleEs: '',
|
||||||
description: '',
|
description: '',
|
||||||
descriptionEs: '',
|
descriptionEs: '',
|
||||||
|
shortDescription: '',
|
||||||
|
shortDescriptionEs: '',
|
||||||
startDatetime: '',
|
startDatetime: '',
|
||||||
endDatetime: '',
|
endDatetime: '',
|
||||||
location: '',
|
location: '',
|
||||||
@@ -90,14 +96,27 @@ export default function AdminEventsPage() {
|
|||||||
setEditingEvent(null);
|
setEditingEvent(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Convert ISO UTC string to local datetime-local format (YYYY-MM-DDTHH:MM)
|
||||||
|
const isoToLocalDatetime = (isoString: string): string => {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (event: Event) => {
|
const handleEdit = (event: Event) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
title: event.title,
|
title: event.title,
|
||||||
titleEs: event.titleEs || '',
|
titleEs: event.titleEs || '',
|
||||||
description: event.description,
|
description: event.description,
|
||||||
descriptionEs: event.descriptionEs || '',
|
descriptionEs: event.descriptionEs || '',
|
||||||
startDatetime: event.startDatetime.slice(0, 16),
|
shortDescription: event.shortDescription || '',
|
||||||
endDatetime: event.endDatetime?.slice(0, 16) || '',
|
shortDescriptionEs: event.shortDescriptionEs || '',
|
||||||
|
startDatetime: isoToLocalDatetime(event.startDatetime),
|
||||||
|
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||||
location: event.location,
|
location: event.location,
|
||||||
locationUrl: event.locationUrl || '',
|
locationUrl: event.locationUrl || '',
|
||||||
price: event.price,
|
price: event.price,
|
||||||
@@ -134,6 +153,8 @@ export default function AdminEventsPage() {
|
|||||||
titleEs: formData.titleEs || undefined,
|
titleEs: formData.titleEs || undefined,
|
||||||
description: formData.description,
|
description: formData.description,
|
||||||
descriptionEs: formData.descriptionEs || undefined,
|
descriptionEs: formData.descriptionEs || undefined,
|
||||||
|
shortDescription: formData.shortDescription || undefined,
|
||||||
|
shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||||
startDatetime: new Date(formData.startDatetime).toISOString(),
|
startDatetime: new Date(formData.startDatetime).toISOString(),
|
||||||
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
||||||
location: formData.location,
|
location: formData.location,
|
||||||
@@ -288,6 +309,33 @@ export default function AdminEventsPage() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Short Description (English)</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.shortDescription}
|
||||||
|
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
rows={2}
|
||||||
|
maxLength={300}
|
||||||
|
placeholder="Brief summary for SEO and cards (max 300 chars)"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300 characters</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.shortDescriptionEs}
|
||||||
|
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
rows={2}
|
||||||
|
maxLength={300}
|
||||||
|
placeholder="Resumen breve para SEO y tarjetas (máx 300 caracteres)"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300 characters</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
|
QrCodeIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -59,12 +60,14 @@ export default function AdminLayout({
|
|||||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
||||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
|
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
|
||||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
|
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
|
||||||
|
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon },
|
||||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
|
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
|
||||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
|
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
|
||||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
|
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
|
||||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
|
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
|
||||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
|
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
|
||||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
|
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
|
||||||
|
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
|||||||
562
frontend/src/app/admin/scanner/page.tsx
Normal file
562
frontend/src/app/admin/scanner/page.tsx
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { ticketsApi, eventsApi, Event, TicketValidationResult } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import {
|
||||||
|
QrCodeIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
VideoCameraIcon,
|
||||||
|
VideoCameraSlashIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
ClockIcon,
|
||||||
|
UserIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
type ScanState = 'idle' | 'scanning' | 'success' | 'error' | 'already_checked_in' | 'pending';
|
||||||
|
|
||||||
|
interface ScanResult {
|
||||||
|
state: ScanState;
|
||||||
|
validation?: TicketValidationResult;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scanner component that manages its own DOM
|
||||||
|
function QRScanner({
|
||||||
|
onScan,
|
||||||
|
isActive,
|
||||||
|
onActiveChange
|
||||||
|
}: {
|
||||||
|
onScan: (code: string) => void;
|
||||||
|
isActive: boolean;
|
||||||
|
onActiveChange: (active: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scannerRef = useRef<any>(null);
|
||||||
|
const scannerElementId = useRef(`qr-scanner-${Math.random().toString(36).substr(2, 9)}`);
|
||||||
|
|
||||||
|
// Create scanner element on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current && !document.getElementById(scannerElementId.current)) {
|
||||||
|
const scannerDiv = document.createElement('div');
|
||||||
|
scannerDiv.id = scannerElementId.current;
|
||||||
|
scannerDiv.style.width = '100%';
|
||||||
|
containerRef.current.appendChild(scannerDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup scanner on unmount
|
||||||
|
if (scannerRef.current) {
|
||||||
|
try {
|
||||||
|
scannerRef.current.stop().catch(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
scannerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle scanner start/stop
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const startScanner = async () => {
|
||||||
|
const elementId = scannerElementId.current;
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { Html5Qrcode } = await import('html5-qrcode');
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Stop existing scanner
|
||||||
|
if (scannerRef.current) {
|
||||||
|
try {
|
||||||
|
await scannerRef.current.stop();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
scannerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const scanner = new Html5Qrcode(elementId);
|
||||||
|
scannerRef.current = scanner;
|
||||||
|
|
||||||
|
await scanner.start(
|
||||||
|
{ facingMode: 'environment' },
|
||||||
|
{
|
||||||
|
fps: 10,
|
||||||
|
qrbox: { width: 250, height: 250 },
|
||||||
|
aspectRatio: 1,
|
||||||
|
},
|
||||||
|
(decodedText: string) => {
|
||||||
|
onScan(decodedText);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// QR parsing error - ignore
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Scanner error:', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
toast.error('Failed to start camera. Please check camera permissions.');
|
||||||
|
onActiveChange(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopScanner = async () => {
|
||||||
|
if (scannerRef.current) {
|
||||||
|
try {
|
||||||
|
await scannerRef.current.stop();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
scannerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
startScanner();
|
||||||
|
} else {
|
||||||
|
stopScanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isActive, onScan, onActiveChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-gray-900 rounded-lg overflow-hidden min-h-[300px]">
|
||||||
|
<div ref={containerRef} className="w-full" />
|
||||||
|
{!isActive && (
|
||||||
|
<div className="h-48 flex items-center justify-center text-gray-400 text-center p-8">
|
||||||
|
<div>
|
||||||
|
<VideoCameraIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>Click "Start Camera" to begin scanning</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan Result Modal
|
||||||
|
function ScanResultModal({
|
||||||
|
scanResult,
|
||||||
|
onCheckin,
|
||||||
|
onClose,
|
||||||
|
checkingIn,
|
||||||
|
formatDateTime,
|
||||||
|
}: {
|
||||||
|
scanResult: ScanResult;
|
||||||
|
onCheckin: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
checkingIn: boolean;
|
||||||
|
formatDateTime: (dateStr: string) => string;
|
||||||
|
}) {
|
||||||
|
if (scanResult.state === 'idle') return null;
|
||||||
|
|
||||||
|
const isSuccess = scanResult.state === 'success';
|
||||||
|
const isAlreadyCheckedIn = scanResult.state === 'already_checked_in';
|
||||||
|
const isPending = scanResult.state === 'pending';
|
||||||
|
const isError = scanResult.state === 'error';
|
||||||
|
|
||||||
|
// Determine colors based on state
|
||||||
|
const bgColor = isSuccess ? 'bg-green-500' : isAlreadyCheckedIn ? 'bg-yellow-500' : isPending ? 'bg-orange-500' : 'bg-red-500';
|
||||||
|
const bgColorLight = isSuccess ? 'bg-green-50' : isAlreadyCheckedIn ? 'bg-yellow-50' : isPending ? 'bg-orange-50' : 'bg-red-50';
|
||||||
|
const borderColor = isSuccess ? 'border-green-500' : isAlreadyCheckedIn ? 'border-yellow-500' : isPending ? 'border-orange-500' : 'border-red-500';
|
||||||
|
const textColor = isSuccess ? 'text-green-800' : isAlreadyCheckedIn ? 'text-yellow-800' : isPending ? 'text-orange-800' : 'text-red-800';
|
||||||
|
const textColorLight = isSuccess ? 'text-green-600' : isAlreadyCheckedIn ? 'text-yellow-600' : isPending ? 'text-orange-600' : 'text-red-600';
|
||||||
|
const iconBg = isSuccess ? 'bg-green-100' : isAlreadyCheckedIn ? 'bg-yellow-100' : isPending ? 'bg-orange-100' : 'bg-red-100';
|
||||||
|
const iconColor = isSuccess ? 'text-green-600' : isAlreadyCheckedIn ? 'text-yellow-600' : isPending ? 'text-orange-600' : 'text-red-600';
|
||||||
|
|
||||||
|
const StatusIcon = isSuccess ? CheckCircleIcon : isAlreadyCheckedIn ? ExclamationTriangleIcon : isPending ? ClockIcon : XCircleIcon;
|
||||||
|
const statusTitle = isSuccess ? 'Valid Ticket' : isAlreadyCheckedIn ? 'Already Checked In' : isPending ? 'Payment Pending' : 'Invalid Ticket';
|
||||||
|
const statusSubtitle = isSuccess ? 'Ready for check-in' :
|
||||||
|
isAlreadyCheckedIn ? (
|
||||||
|
scanResult.validation?.ticket?.checkinAt
|
||||||
|
? `Checked in at ${new Date(scanResult.validation.ticket.checkinAt).toLocaleTimeString()}${scanResult.validation?.ticket?.checkedInBy ? ` by ${scanResult.validation.ticket.checkedInBy}` : ''}`
|
||||||
|
: 'This ticket was already used'
|
||||||
|
) :
|
||||||
|
isPending ? 'Ticket not yet confirmed' :
|
||||||
|
(scanResult.validation?.error || scanResult.error || 'Ticket not found or cancelled');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className={clsx(
|
||||||
|
'relative w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200',
|
||||||
|
bgColorLight,
|
||||||
|
'border-4',
|
||||||
|
borderColor
|
||||||
|
)}>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-3 right-3 p-1 rounded-full bg-white/80 hover:bg-white transition-colors z-10"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header with color band */}
|
||||||
|
<div className={clsx('h-2', bgColor)} />
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Status Icon & Title */}
|
||||||
|
<div className="flex items-center gap-4 mb-5">
|
||||||
|
<div className={clsx('w-16 h-16 rounded-full flex items-center justify-center', iconBg)}>
|
||||||
|
<StatusIcon className={clsx('w-10 h-10', iconColor)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className={clsx('font-bold text-xl', textColor)}>{statusTitle}</h3>
|
||||||
|
<p className={clsx('text-sm', textColorLight)}>{statusSubtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket Details */}
|
||||||
|
{scanResult.validation?.ticket && (
|
||||||
|
<div className="bg-white rounded-xl p-4 mb-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
|
<UserIcon className="w-7 h-7 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold text-lg text-gray-900 truncate">
|
||||||
|
{scanResult.validation.ticket.attendeeName}
|
||||||
|
</p>
|
||||||
|
{scanResult.validation.ticket.attendeeEmail && (
|
||||||
|
<p className="text-sm text-gray-500 truncate">
|
||||||
|
{scanResult.validation.ticket.attendeeEmail}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scanResult.validation.event && (
|
||||||
|
<div className="text-sm text-gray-600 border-t pt-3 mt-3 space-y-1">
|
||||||
|
<p className="font-semibold text-gray-800">{scanResult.validation.event.title}</p>
|
||||||
|
<p>{formatDateTime(scanResult.validation.event.startDatetime)}</p>
|
||||||
|
<p className="text-gray-500">{scanResult.validation.event.location}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400 mt-3 font-mono">
|
||||||
|
Ticket ID: {scanResult.validation.ticket.id.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{isSuccess && scanResult.validation?.canCheckIn && (
|
||||||
|
<Button
|
||||||
|
className="flex-1 py-4 text-lg"
|
||||||
|
onClick={onCheckin}
|
||||||
|
isLoading={checkingIn}
|
||||||
|
>
|
||||||
|
<CheckCircleIcon className="w-6 h-6 mr-2" />
|
||||||
|
Check In
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className={clsx(
|
||||||
|
'py-4 text-lg',
|
||||||
|
isSuccess && scanResult.validation?.canCheckIn ? '' : 'flex-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-6 h-6 mr-2" />
|
||||||
|
Scan Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminScannerPage() {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [selectedEventId, setSelectedEventId] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Scanner state
|
||||||
|
const [cameraActive, setCameraActive] = useState(false);
|
||||||
|
const [scanResult, setScanResult] = useState<ScanResult>({ state: 'idle' });
|
||||||
|
const [lastScannedCode, setLastScannedCode] = useState<string>('');
|
||||||
|
const [checkingIn, setCheckingIn] = useState(false);
|
||||||
|
|
||||||
|
// Manual search
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const [checkinCount, setCheckinCount] = useState(0);
|
||||||
|
const [recentCheckins, setRecentCheckins] = useState<Array<{ name: string; time: string }>>([]);
|
||||||
|
|
||||||
|
// Refs for callbacks
|
||||||
|
const selectedEventIdRef = useRef<string>('');
|
||||||
|
const lastScannedCodeRef = useRef<string>('');
|
||||||
|
|
||||||
|
// Keep refs in sync
|
||||||
|
useEffect(() => {
|
||||||
|
selectedEventIdRef.current = selectedEventId;
|
||||||
|
}, [selectedEventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
lastScannedCodeRef.current = lastScannedCode;
|
||||||
|
}, [lastScannedCode]);
|
||||||
|
|
||||||
|
// Load events
|
||||||
|
useEffect(() => {
|
||||||
|
eventsApi.getAll({ status: 'published' })
|
||||||
|
.then(res => {
|
||||||
|
setEvents(res.events);
|
||||||
|
const upcoming = res.events.filter(e => new Date(e.startDatetime) >= new Date());
|
||||||
|
if (upcoming.length === 1) {
|
||||||
|
setSelectedEventId(upcoming[0].id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Validate ticket
|
||||||
|
const validateTicket = useCallback(async (code: string) => {
|
||||||
|
try {
|
||||||
|
const result = await ticketsApi.validate(code, selectedEventIdRef.current || undefined);
|
||||||
|
|
||||||
|
let state: ScanState = 'idle';
|
||||||
|
if (result.status === 'valid') {
|
||||||
|
state = 'success';
|
||||||
|
} else if (result.status === 'already_checked_in') {
|
||||||
|
state = 'already_checked_in';
|
||||||
|
} else if (result.status === 'pending_payment') {
|
||||||
|
state = 'pending';
|
||||||
|
} else {
|
||||||
|
state = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
setScanResult({ state, validation: result });
|
||||||
|
} catch (error: any) {
|
||||||
|
setScanResult({
|
||||||
|
state: 'error',
|
||||||
|
error: error.message || 'Failed to validate ticket'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle QR scan
|
||||||
|
const handleScan = useCallback((decodedText: string) => {
|
||||||
|
// Avoid duplicate scans
|
||||||
|
if (decodedText === lastScannedCodeRef.current) return;
|
||||||
|
lastScannedCodeRef.current = decodedText;
|
||||||
|
setLastScannedCode(decodedText);
|
||||||
|
|
||||||
|
// Extract ticket ID from URL if present
|
||||||
|
let code = decodedText;
|
||||||
|
const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/);
|
||||||
|
if (urlMatch) {
|
||||||
|
code = urlMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
validateTicket(code);
|
||||||
|
}, [validateTicket]);
|
||||||
|
|
||||||
|
// Check in ticket
|
||||||
|
const handleCheckin = async () => {
|
||||||
|
if (!scanResult.validation?.ticket?.id) return;
|
||||||
|
|
||||||
|
setCheckingIn(true);
|
||||||
|
try {
|
||||||
|
const result = await ticketsApi.checkin(scanResult.validation.ticket.id);
|
||||||
|
|
||||||
|
toast.success(`${result.ticket.attendeeName || 'Guest'} checked in!`);
|
||||||
|
|
||||||
|
setCheckinCount(prev => prev + 1);
|
||||||
|
setRecentCheckins(prev => [
|
||||||
|
{
|
||||||
|
name: result.ticket.attendeeName || 'Guest',
|
||||||
|
time: new Date().toLocaleTimeString()
|
||||||
|
},
|
||||||
|
...prev.slice(0, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
handleCloseModal();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Check-in failed');
|
||||||
|
} finally {
|
||||||
|
setCheckingIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal and reset
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setScanResult({ state: 'idle' });
|
||||||
|
setLastScannedCode('');
|
||||||
|
lastScannedCodeRef.current = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manual search
|
||||||
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
setScanResult({ state: 'idle' });
|
||||||
|
|
||||||
|
await validateTicket(searchQuery.trim());
|
||||||
|
setSearching(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format datetime
|
||||||
|
const formatDateTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark flex items-center gap-2">
|
||||||
|
<QrCodeIcon className="w-7 h-7" />
|
||||||
|
Ticket Scanner
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Scan QR codes to check in attendees</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Selector */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<label className="block text-sm font-medium mb-2">Select Event (optional)</label>
|
||||||
|
<select
|
||||||
|
value={selectedEventId}
|
||||||
|
onChange={(e) => setSelectedEventId(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">All Events</option>
|
||||||
|
{events.map((event) => (
|
||||||
|
<option key={event.id} value={event.id}>
|
||||||
|
{event.title} - {formatDateTime(event.startDatetime)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Scanner Area */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-semibold">Camera Scanner</h2>
|
||||||
|
<Button
|
||||||
|
variant={cameraActive ? 'outline' : 'primary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCameraActive(!cameraActive)}
|
||||||
|
>
|
||||||
|
{cameraActive ? (
|
||||||
|
<>
|
||||||
|
<VideoCameraSlashIcon className="w-4 h-4 mr-2" />
|
||||||
|
Stop Camera
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<VideoCameraIcon className="w-4 h-4 mr-2" />
|
||||||
|
Start Camera
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<QRScanner
|
||||||
|
isActive={cameraActive}
|
||||||
|
onScan={handleScan}
|
||||||
|
onActiveChange={setCameraActive}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Manual Search */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<h2 className="font-semibold mb-3">Manual Search</h2>
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter ticket ID or QR code..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit" isLoading={searching}>
|
||||||
|
<MagnifyingGlassIcon className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Card className="p-4 text-center">
|
||||||
|
<p className="text-3xl font-bold text-primary-dark">{checkinCount}</p>
|
||||||
|
<p className="text-sm text-gray-600">Checked in this session</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600 mb-2">Recent Check-ins</p>
|
||||||
|
{recentCheckins.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400">No check-ins yet</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{recentCheckins.map((checkin, i) => (
|
||||||
|
<li key={i} className="text-xs flex justify-between">
|
||||||
|
<span className="truncate">{checkin.name}</span>
|
||||||
|
<span className="text-gray-400">{checkin.time}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scan Result Modal */}
|
||||||
|
<ScanResultModal
|
||||||
|
scanResult={scanResult}
|
||||||
|
onCheckin={handleCheckin}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
checkingIn={checkingIn}
|
||||||
|
formatDateTime={formatDateTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
376
frontend/src/app/admin/settings/page.tsx
Normal file
376
frontend/src/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { siteSettingsApi, SiteSettings, TimezoneOption } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import {
|
||||||
|
Cog6ToothIcon,
|
||||||
|
GlobeAltIcon,
|
||||||
|
ClockIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AdminSettingsPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [timezones, setTimezones] = useState<TimezoneOption[]>([]);
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<SiteSettings>({
|
||||||
|
timezone: 'America/Asuncion',
|
||||||
|
siteName: 'Spanglish',
|
||||||
|
siteDescription: null,
|
||||||
|
siteDescriptionEs: null,
|
||||||
|
contactEmail: null,
|
||||||
|
contactPhone: null,
|
||||||
|
facebookUrl: null,
|
||||||
|
instagramUrl: null,
|
||||||
|
twitterUrl: null,
|
||||||
|
linkedinUrl: null,
|
||||||
|
maintenanceMode: false,
|
||||||
|
maintenanceMessage: null,
|
||||||
|
maintenanceMessageEs: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [settingsRes, timezonesRes] = await Promise.all([
|
||||||
|
siteSettingsApi.get(),
|
||||||
|
siteSettingsApi.getTimezones(),
|
||||||
|
]);
|
||||||
|
setSettings(settingsRes.settings);
|
||||||
|
setTimezones(timezonesRes.timezones);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load settings');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await siteSettingsApi.update(settings);
|
||||||
|
setSettings(response.settings);
|
||||||
|
toast.success(locale === 'es' ? 'Configuración guardada' : 'Settings saved');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to save settings');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetting = <K extends keyof SiteSettings>(key: K, value: SiteSettings[K]) => {
|
||||||
|
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark flex items-center gap-3">
|
||||||
|
<Cog6ToothIcon className="w-7 h-7" />
|
||||||
|
{locale === 'es' ? 'Configuración del Sitio' : 'Site Settings'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Configura las opciones generales del sitio web'
|
||||||
|
: 'Configure general website settings'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave} isLoading={saving}>
|
||||||
|
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Timezone Settings */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<ClockIcon className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'Zona Horaria' : 'Timezone'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Zona horaria para mostrar las fechas de eventos'
|
||||||
|
: 'Timezone used for displaying event dates'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-md">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{locale === 'es' ? 'Zona Horaria del Sitio' : 'Site Timezone'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={settings.timezone}
|
||||||
|
onChange={(e) => updateSetting('timezone', e.target.value)}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
>
|
||||||
|
{timezones.map((tz) => (
|
||||||
|
<option key={tz.value} value={tz.value}>
|
||||||
|
{tz.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Esta zona horaria se usará como referencia para las fechas de eventos.'
|
||||||
|
: 'This timezone will be used as reference for event dates.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Site Information */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<GlobeAltIcon className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'Información del Sitio' : 'Site Information'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Información básica del sitio web'
|
||||||
|
: 'Basic website information'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="max-w-md">
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Nombre del Sitio' : 'Site Name'}
|
||||||
|
value={settings.siteName}
|
||||||
|
onChange={(e) => updateSetting('siteName', e.target.value)}
|
||||||
|
placeholder="Spanglish"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{locale === 'es' ? 'Descripción (Inglés)' : 'Description (English)'}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={settings.siteDescription || ''}
|
||||||
|
onChange={(e) => updateSetting('siteDescription', e.target.value || null)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Brief site description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{locale === 'es' ? 'Descripción (Español)' : 'Description (Spanish)'}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={settings.siteDescriptionEs || ''}
|
||||||
|
onChange={(e) => updateSetting('siteDescriptionEs', e.target.value || null)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Descripción breve del sitio..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contact Information */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<EnvelopeIcon className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'Información de Contacto' : 'Contact Information'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Datos de contacto del sitio'
|
||||||
|
: 'Site contact details'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Email de Contacto' : 'Contact Email'}
|
||||||
|
type="email"
|
||||||
|
value={settings.contactEmail || ''}
|
||||||
|
onChange={(e) => updateSetting('contactEmail', e.target.value || null)}
|
||||||
|
placeholder="contact@example.com"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Teléfono de Contacto' : 'Contact Phone'}
|
||||||
|
type="tel"
|
||||||
|
value={settings.contactPhone || ''}
|
||||||
|
onChange={(e) => updateSetting('contactPhone', e.target.value || null)}
|
||||||
|
placeholder="+595 981 123456"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-secondary-light-gray">
|
||||||
|
<h4 className="font-medium mb-4">
|
||||||
|
{locale === 'es' ? 'Redes Sociales' : 'Social Media Links'}
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Facebook URL"
|
||||||
|
type="url"
|
||||||
|
value={settings.facebookUrl || ''}
|
||||||
|
onChange={(e) => updateSetting('facebookUrl', e.target.value || null)}
|
||||||
|
placeholder="https://facebook.com/..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Instagram URL"
|
||||||
|
type="url"
|
||||||
|
value={settings.instagramUrl || ''}
|
||||||
|
onChange={(e) => updateSetting('instagramUrl', e.target.value || null)}
|
||||||
|
placeholder="https://instagram.com/..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Twitter URL"
|
||||||
|
type="url"
|
||||||
|
value={settings.twitterUrl || ''}
|
||||||
|
onChange={(e) => updateSetting('twitterUrl', e.target.value || null)}
|
||||||
|
placeholder="https://twitter.com/..."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="LinkedIn URL"
|
||||||
|
type="url"
|
||||||
|
value={settings.linkedinUrl || ''}
|
||||||
|
onChange={(e) => updateSetting('linkedinUrl', e.target.value || null)}
|
||||||
|
placeholder="https://linkedin.com/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Maintenance Mode */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
|
||||||
|
<WrenchScrewdriverIcon className="w-5 h-5 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'Modo de Mantenimiento' : 'Maintenance Mode'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Habilita el modo de mantenimiento cuando necesites hacer cambios'
|
||||||
|
: 'Enable maintenance mode when you need to make changes'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{locale === 'es' ? 'Modo de Mantenimiento' : 'Maintenance Mode'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{settings.maintenanceMode
|
||||||
|
? (locale === 'es' ? 'El sitio está en mantenimiento' : 'The site is under maintenance')
|
||||||
|
: (locale === 'es' ? 'El sitio está activo' : 'The site is live')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updateSetting('maintenanceMode', !settings.maintenanceMode)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
settings.maintenanceMode ? 'bg-orange-500' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
settings.maintenanceMode ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.maintenanceMode && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-4">
|
||||||
|
<p className="text-orange-800 text-sm font-medium flex items-center gap-2">
|
||||||
|
<WrenchScrewdriverIcon className="w-4 h-4" />
|
||||||
|
{locale === 'es'
|
||||||
|
? '¡Advertencia! El modo de mantenimiento está activo.'
|
||||||
|
: 'Warning! Maintenance mode is active.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{locale === 'es' ? 'Mensaje de Mantenimiento (Inglés)' : 'Maintenance Message (English)'}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={settings.maintenanceMessage || ''}
|
||||||
|
onChange={(e) => updateSetting('maintenanceMessage', e.target.value || null)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="We are currently performing maintenance. Please check back soon."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{locale === 'es' ? 'Mensaje de Mantenimiento (Español)' : 'Maintenance Message (Spanish)'}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={settings.maintenanceMessageEs || ''}
|
||||||
|
onChange={(e) => updateSetting('maintenanceMessageEs', e.target.value || null)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Estamos realizando mantenimiento. Por favor vuelve pronto."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Save Button at Bottom */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} isLoading={saving} size="lg">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Guardar Todos los Cambios' : 'Save All Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ export default function LinktreePage() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-primary-dark via-gray-900 to-primary-dark">
|
<div className="min-h-screen" style={{ background: 'linear-gradient(to bottom, #002F44, #001a28, #002F44)' }}>
|
||||||
<div className="max-w-md mx-auto px-4 py-8 pb-16">
|
<div className="max-w-md mx-auto px-4 py-8 pb-16">
|
||||||
{/* Profile Header */}
|
{/* Profile Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
@@ -102,9 +102,11 @@ export default function LinktreePage() {
|
|||||||
? t('events.details.free')
|
? t('events.details.free')
|
||||||
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-400">
|
{!nextEvent.externalBookingEnabled && (
|
||||||
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
<span className="text-sm text-gray-400">
|
||||||
</span>
|
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 bg-primary-yellow text-primary-dark font-semibold py-3 px-4 rounded-xl text-center transition-all duration-200 group-hover:bg-yellow-400">
|
<div className="mt-4 bg-primary-yellow text-primary-dark font-semibold py-3 px-4 rounded-xl text-center transition-all duration-200 group-hover:bg-yellow-400">
|
||||||
|
|||||||
@@ -31,13 +31,9 @@ export default function Footer() {
|
|||||||
className="h-10 w-auto"
|
className="h-10 w-auto"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-3 text-gray-600 max-w-md">
|
<p className="mt-3 max-w-md" style={{ color: '#002F44' }}>
|
||||||
{t('footer.tagline')}
|
{t('footer.tagline')}
|
||||||
</p>
|
</p>
|
||||||
{/* Local SEO text */}
|
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
|
||||||
Language Exchange Events in Asunción, Paraguay
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Links */}
|
{/* Quick Links */}
|
||||||
@@ -84,7 +80,7 @@ export default function Footer() {
|
|||||||
{/* Social */}
|
{/* Social */}
|
||||||
{socialLinks.length > 0 && (
|
{socialLinks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-primary-dark mb-4">
|
<h3 className="font-semibold mb-4" style={{ color: '#002F44' }}>
|
||||||
{t('footer.social')}
|
{t('footer.social')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
@@ -112,13 +108,14 @@ export default function Footer() {
|
|||||||
<Link
|
<Link
|
||||||
key={link.slug}
|
key={link.slug}
|
||||||
href={`/legal/${link.slug}`}
|
href={`/legal/${link.slug}`}
|
||||||
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
|
className="hover:opacity-70 transition-colors text-sm"
|
||||||
|
style={{ color: '#002F44' }}
|
||||||
>
|
>
|
||||||
{locale === 'es' ? link.es : link.en}
|
{locale === 'es' ? link.es : link.en}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center text-gray-500 text-sm">
|
<div className="text-center text-sm" style={{ color: '#002F44' }}>
|
||||||
{t('footer.copyright', { year: currentYear })}
|
{t('footer.copyright', { year: currentYear })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import LanguageToggle from '@/components/LanguageToggle';
|
import LanguageToggle from '@/components/LanguageToggle';
|
||||||
@@ -10,6 +11,37 @@ import Button from '@/components/ui/Button';
|
|||||||
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isActive = pathname === href || (href !== '/' && pathname.startsWith(href));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="font-medium transition-colors"
|
||||||
|
style={{ color: isActive ? '#FBB82B' : '#002F44' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileNavLink({ href, children, onClick }: { href: string; children: React.ReactNode; onClick: () => void }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isActive = pathname === href || (href !== '/' && pathname.startsWith(href));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="px-4 py-2 hover:bg-gray-50 rounded-lg font-medium"
|
||||||
|
style={{ color: isActive ? '#FBB82B' : '#002F44' }}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, isAdmin, logout } = useAuth();
|
||||||
@@ -41,13 +73,9 @@ export default function Header() {
|
|||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<div className="hidden md:flex items-center gap-6">
|
<div className="hidden md:flex items-center gap-6">
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<Link
|
<NavLink key={link.href} href={link.href}>
|
||||||
key={link.href}
|
|
||||||
href={link.href}
|
|
||||||
className="text-gray-700 hover:text-primary-dark font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,14 +143,13 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2 pt-4">
|
<div className="flex flex-col gap-2 pt-4">
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<Link
|
<MobileNavLink
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className="px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg font-medium"
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</MobileNavLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="border-t border-gray-100 mt-2 pt-4 px-4">
|
<div className="border-t border-gray-100 mt-2 pt-4 px-4">
|
||||||
|
|||||||
@@ -86,8 +86,15 @@ export const ticketsApi = {
|
|||||||
return fetchApi<{ tickets: Ticket[] }>(`/api/tickets?${query}`);
|
return fetchApi<{ tickets: Ticket[] }>(`/api/tickets?${query}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Validate ticket by QR code (for scanner)
|
||||||
|
validate: (code: string, eventId?: string) =>
|
||||||
|
fetchApi<TicketValidationResult>('/api/tickets/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code, eventId }),
|
||||||
|
}),
|
||||||
|
|
||||||
checkin: (id: string) =>
|
checkin: (id: string) =>
|
||||||
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/checkin`, {
|
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -141,6 +148,9 @@ export const ticketsApi = {
|
|||||||
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
||||||
`/api/lnbits/status/${ticketId}`
|
`/api/lnbits/status/${ticketId}`
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Get PDF download URL (returns the URL, not the PDF itself)
|
||||||
|
getPdfUrl: (id: string) => `${API_BASE}/api/tickets/${id}/pdf`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Contacts API
|
// Contacts API
|
||||||
@@ -413,6 +423,8 @@ export interface Event {
|
|||||||
titleEs?: string;
|
titleEs?: string;
|
||||||
description: string;
|
description: string;
|
||||||
descriptionEs?: string;
|
descriptionEs?: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
shortDescriptionEs?: string;
|
||||||
startDatetime: string;
|
startDatetime: string;
|
||||||
endDatetime?: string;
|
endDatetime?: string;
|
||||||
location: string;
|
location: string;
|
||||||
@@ -441,6 +453,7 @@ export interface Ticket {
|
|||||||
preferredLanguage?: string;
|
preferredLanguage?: string;
|
||||||
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
|
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
|
||||||
checkinAt?: string;
|
checkinAt?: string;
|
||||||
|
checkedInByAdminId?: string;
|
||||||
qrCode: string;
|
qrCode: string;
|
||||||
adminNote?: string;
|
adminNote?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -449,6 +462,29 @@ export interface Ticket {
|
|||||||
user?: User;
|
user?: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TicketValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
status: 'valid' | 'already_checked_in' | 'pending_payment' | 'cancelled' | 'invalid' | 'wrong_event';
|
||||||
|
canCheckIn: boolean;
|
||||||
|
ticket?: {
|
||||||
|
id: string;
|
||||||
|
qrCode: string;
|
||||||
|
attendeeName: string;
|
||||||
|
attendeeEmail?: string;
|
||||||
|
attendeePhone?: string;
|
||||||
|
status: string;
|
||||||
|
checkinAt?: string;
|
||||||
|
checkedInBy?: string;
|
||||||
|
};
|
||||||
|
event?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startDatetime: string;
|
||||||
|
location: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
id: string;
|
id: string;
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
@@ -892,3 +928,42 @@ export const dashboardApi = {
|
|||||||
unlinkGoogle: () =>
|
unlinkGoogle: () =>
|
||||||
fetchApi<{ message: string }>('/api/dashboard/unlink-google', { method: 'POST' }),
|
fetchApi<{ message: string }>('/api/dashboard/unlink-google', { method: 'POST' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== Site Settings API ====================
|
||||||
|
|
||||||
|
export interface SiteSettings {
|
||||||
|
id?: string;
|
||||||
|
timezone: string;
|
||||||
|
siteName: string;
|
||||||
|
siteDescription?: string | null;
|
||||||
|
siteDescriptionEs?: string | null;
|
||||||
|
contactEmail?: string | null;
|
||||||
|
contactPhone?: string | null;
|
||||||
|
facebookUrl?: string | null;
|
||||||
|
instagramUrl?: string | null;
|
||||||
|
twitterUrl?: string | null;
|
||||||
|
linkedinUrl?: string | null;
|
||||||
|
maintenanceMode: boolean;
|
||||||
|
maintenanceMessage?: string | null;
|
||||||
|
maintenanceMessageEs?: string | null;
|
||||||
|
updatedAt?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimezoneOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const siteSettingsApi = {
|
||||||
|
get: () => fetchApi<{ settings: SiteSettings }>('/api/site-settings'),
|
||||||
|
|
||||||
|
update: (data: Partial<SiteSettings>) =>
|
||||||
|
fetchApi<{ settings: SiteSettings; message: string }>('/api/site-settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getTimezones: () =>
|
||||||
|
fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'),
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user