5 Commits

Author SHA1 Message Date
a5e939221d Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-02-12 07:56:37 +00:00
833e3e5a9c Merge pull request 'Fix llms.txt event times: format in America/Asuncion timezone' (#4) from dev into main
Reviewed-on: #4
2026-02-12 06:28:51 +00:00
ba1975dd6d Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-02-12 04:55:39 +00:00
3025ef3d21 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-02-12 03:19:06 +00:00
8564f8af83 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-02-12 02:18:08 +00:00
51 changed files with 3364 additions and 6689 deletions

View File

@@ -64,8 +64,6 @@ npm run start
npm run db:generate npm run db:generate
npm run db:migrate npm run db:migrate
npm run db:studio npm run db:studio
npm run db:export # Backup database
npm run db:import # Restore from backup
``` ```
You can also run per workspace: You can also run per workspace:
@@ -119,25 +117,6 @@ Then run:
npm run db:migrate npm run db:migrate
``` ```
### Backups (export / import)
Create backups and restore if needed:
```bash
# Export (creates timestamped file in backend/data/backups/)
npm run db:export
# Export to custom path
npm run db:export -- -o ./my-backup.db # SQLite
npm run db:export -- -o ./my-backup.sql # PostgreSQL
# Import (stop the backend server first)
npm run db:import -- ./data/backups/spanglish-2025-03-07-143022.db
npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip confirmation
```
**Note:** Stop the backend before importing so the database file is not locked.
## Production deployment (nginx + systemd) ## Production deployment (nginx + systemd)
This repo includes example configs in `deploy/`: This repo includes example configs in `deploy/`:

View File

@@ -19,7 +19,7 @@ GOOGLE_CLIENT_ID=
# Server Configuration # Server Configuration
PORT=3001 PORT=3001
API_URL=http://localhost:3001 API_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3019 FRONTEND_URL=http://localhost:3002
# Revalidation secret (shared with frontend for on-demand cache revalidation) # Revalidation secret (shared with frontend for on-demand cache revalidation)
# Must match the REVALIDATE_SECRET in frontend/.env # Must match the REVALIDATE_SECRET in frontend/.env
@@ -67,9 +67,3 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
# SendGrid: SMTP_HOST=smtp.sendgrid.net, SMTP_PORT=587, SMTP_USER=apikey, SMTP_PASS=your_api_key # SendGrid: SMTP_HOST=smtp.sendgrid.net, SMTP_PORT=587, SMTP_USER=apikey, SMTP_PASS=your_api_key
# Mailgun: SMTP_HOST=smtp.mailgun.org, SMTP_PORT=587 # Mailgun: SMTP_HOST=smtp.mailgun.org, SMTP_PORT=587
# Amazon SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com, SMTP_PORT=587 # Amazon SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com, SMTP_PORT=587
# Email Queue Rate Limiting
# Maximum number of emails that can be sent per hour (default: 30)
# If the limit is reached, queued emails will pause and resume automatically
MAX_EMAILS_PER_HOUR=30

View File

@@ -8,9 +8,7 @@
"start": "NODE_ENV=production node dist/index.js", "start": "NODE_ENV=production node dist/index.js",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts", "db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio"
"db:export": "tsx src/db/export.ts",
"db:import": "tsx src/db/import.ts"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.11.4", "@hono/node-server": "^1.11.4",

View File

@@ -1,100 +0,0 @@
import 'dotenv/config';
import { closeSync, existsSync, mkdirSync, openSync } from 'fs';
import { dirname, resolve } from 'path';
import { spawnSync } from 'child_process';
import Database from 'better-sqlite3';
const dbType = process.env.DB_TYPE || 'sqlite';
const dbPath = process.env.DATABASE_URL || './data/spanglish.db';
const BACKUP_DIR = resolve(process.cwd(), 'data', 'backups');
function parseArgs(): { output?: string } {
const args = process.argv.slice(2);
const result: { output?: string } = {};
for (let i = 0; i < args.length; i++) {
if (args[i] === '-o' || args[i] === '--output') {
result.output = args[i + 1];
i++;
}
}
return result;
}
function getTimestamp(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const h = String(now.getHours()).padStart(2, '0');
const min = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d}-${h}${min}${s}`;
}
function exportSqlite(outputPath: string): void {
const db = new Database(resolve(process.cwd(), dbPath), { readonly: true });
try {
db.backup(outputPath);
console.log(`Exported to ${outputPath}`);
} finally {
db.close();
}
}
function exportPostgres(outputPath: string): void {
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
const outFd = openSync(outputPath, 'w');
try {
const result = spawnSync(
'pg_dump',
['--clean', '--if-exists', connString],
{
stdio: ['ignore', outFd, 'pipe'],
encoding: 'utf-8',
}
);
if (result.error) {
console.error('pg_dump failed. Ensure pg_dump is installed and in PATH.');
console.error(result.error.message);
process.exit(1);
}
if (result.status !== 0) {
console.error('pg_dump failed:', result.stderr);
process.exit(1);
}
console.log(`Exported to ${outputPath}`);
} finally {
closeSync(outFd);
}
}
async function main() {
const { output } = parseArgs();
const ext = dbType === 'postgres' ? '.sql' : '.db';
const defaultName = `spanglish-${getTimestamp()}${ext}`;
const outputPath = output
? resolve(process.cwd(), output)
: resolve(BACKUP_DIR, defaultName);
const dir = dirname(outputPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
console.log(`Database type: ${dbType}`);
if (dbType === 'sqlite') {
exportSqlite(outputPath);
} else {
exportPostgres(outputPath);
}
process.exit(0);
}
main().catch((err) => {
console.error('Export failed:', err);
process.exit(1);
});

View File

@@ -1,91 +0,0 @@
import 'dotenv/config';
import { copyFileSync, existsSync, readFileSync } from 'fs';
import { resolve } from 'path';
import { spawnSync } from 'child_process';
const dbType = process.env.DB_TYPE || 'sqlite';
const dbPath = process.env.DATABASE_URL || './data/spanglish.db';
function parseArgs(): { file?: string; yes?: boolean } {
const args = process.argv.slice(2);
const result: { file?: string; yes?: boolean } = {};
for (let i = 0; i < args.length; i++) {
if (args[i] === '-y' || args[i] === '--yes') {
result.yes = true;
} else if (!args[i].startsWith('-')) {
result.file = args[i];
}
}
return result;
}
function importSqlite(backupPath: string): void {
const targetPath = resolve(process.cwd(), dbPath);
copyFileSync(backupPath, targetPath);
console.log(`Restored from ${backupPath} to ${targetPath}`);
}
function importPostgres(backupPath: string): void {
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
const sql = readFileSync(backupPath, 'utf-8');
const result = spawnSync(
'psql',
[connString],
{
stdio: ['pipe', 'inherit', 'inherit'],
input: sql,
}
);
if (result.error) {
console.error('psql failed. Ensure psql is installed and in PATH.');
console.error(result.error.message);
process.exit(1);
}
if (result.status !== 0) {
process.exit(1);
}
console.log(`Restored from ${backupPath}`);
}
async function main() {
const { file, yes } = parseArgs();
if (!file) {
console.error('Usage: npm run db:import -- <backup-file> [--yes]');
console.error('Example: npm run db:import -- ./data/backups/spanglish-2025-03-07.db');
process.exit(1);
}
const backupPath = resolve(process.cwd(), file);
if (!existsSync(backupPath)) {
console.error(`Backup file not found: ${backupPath}`);
process.exit(1);
}
if (!yes) {
console.log('WARNING: Import will overwrite the current database.');
console.log('Stop the backend server before importing.');
console.log('Press Ctrl+C to cancel, or run with --yes to skip this warning.');
await new Promise((r) => setTimeout(r, 3000));
}
console.log(`Database type: ${dbType}`);
if (dbType === 'sqlite') {
importSqlite(backupPath);
} else if (dbType === 'postgres') {
importPostgres(backupPath);
} else {
console.error('Unknown DB_TYPE. Use sqlite or postgres.');
process.exit(1);
}
process.exit(0);
}
main().catch((err) => {
console.error('Import failed:', err);
process.exit(1);
});

View File

@@ -368,13 +368,6 @@ async function migrate() {
) )
`); `);
try {
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TEXT`);
} catch (e) { /* column may already exist */ }
await (db as any).run(sql` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS email_settings ( CREATE TABLE IF NOT EXISTS email_settings (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -444,25 +437,6 @@ async function migrate() {
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
) )
`); `);
// Legal settings table for legal page placeholder values
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS legal_settings (
id TEXT PRIMARY KEY,
company_name TEXT,
legal_entity_name TEXT,
ruc_number TEXT,
company_address TEXT,
company_city TEXT,
company_country TEXT,
support_email TEXT,
legal_email TEXT,
governing_law TEXT,
jurisdiction_city 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`
@@ -779,13 +753,6 @@ async function migrate() {
) )
`); `);
try {
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TIMESTAMP`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS email_settings ( CREATE TABLE IF NOT EXISTS email_settings (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -855,25 +822,6 @@ async function migrate() {
updated_at TIMESTAMP NOT NULL updated_at TIMESTAMP NOT NULL
) )
`); `);
// Legal settings table for legal page placeholder values
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS legal_settings (
id UUID PRIMARY KEY,
company_name VARCHAR(255),
legal_entity_name VARCHAR(255),
ruc_number VARCHAR(50),
company_address TEXT,
company_city VARCHAR(100),
company_country VARCHAR(100),
support_email VARCHAR(255),
legal_email VARCHAR(255),
governing_law VARCHAR(255),
jurisdiction_city VARCHAR(100),
updated_at TIMESTAMP NOT NULL,
updated_by UUID REFERENCES users(id)
)
`);
} }
console.log('Migrations completed successfully!'); console.log('Migrations completed successfully!');

View File

@@ -75,7 +75,7 @@ export const sqliteEvents = sqliteTable('events', {
price: real('price').notNull().default(0), price: real('price').notNull().default(0),
currency: text('currency').notNull().default('PYG'), currency: text('currency').notNull().default('PYG'),
capacity: integer('capacity').notNull().default(50), capacity: integer('capacity').notNull().default(50),
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'), status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
bannerUrl: text('banner_url'), bannerUrl: text('banner_url'),
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false), externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
externalBookingUrl: text('external_booking_url'), externalBookingUrl: text('external_booking_url'),
@@ -243,8 +243,6 @@ export const sqliteEmailLogs = sqliteTable('email_logs', {
sentAt: text('sent_at'), sentAt: text('sent_at'),
sentBy: text('sent_by').references(() => sqliteUsers.id), sentBy: text('sent_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
resendAttempts: integer('resend_attempts').notNull().default(0),
lastResentAt: text('last_resent_at'),
}); });
export const sqliteEmailSettings = sqliteTable('email_settings', { export const sqliteEmailSettings = sqliteTable('email_settings', {
@@ -283,23 +281,6 @@ export const sqliteFaqQuestions = sqliteTable('faq_questions', {
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
// Legal Settings table for legal page placeholder values
export const sqliteLegalSettings = sqliteTable('legal_settings', {
id: text('id').primaryKey(),
companyName: text('company_name'),
legalEntityName: text('legal_entity_name'),
rucNumber: text('ruc_number'),
companyAddress: text('company_address'),
companyCity: text('company_city'),
companyCountry: text('company_country'),
supportEmail: text('support_email'),
legalEmail: text('legal_email'),
governingLaw: text('governing_law'),
jurisdictionCity: text('jurisdiction_city'),
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
});
// Site Settings table for global website configuration // Site Settings table for global website configuration
export const sqliteSiteSettings = sqliteTable('site_settings', { export const sqliteSiteSettings = sqliteTable('site_settings', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -559,8 +540,6 @@ export const pgEmailLogs = pgTable('email_logs', {
sentAt: timestamp('sent_at'), sentAt: timestamp('sent_at'),
sentBy: uuid('sent_by').references(() => pgUsers.id), sentBy: uuid('sent_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
resendAttempts: pgInteger('resend_attempts').notNull().default(0),
lastResentAt: timestamp('last_resent_at'),
}); });
export const pgEmailSettings = pgTable('email_settings', { export const pgEmailSettings = pgTable('email_settings', {
@@ -599,23 +578,6 @@ export const pgFaqQuestions = pgTable('faq_questions', {
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
// Legal Settings table for legal page placeholder values
export const pgLegalSettings = pgTable('legal_settings', {
id: uuid('id').primaryKey(),
companyName: varchar('company_name', { length: 255 }),
legalEntityName: varchar('legal_entity_name', { length: 255 }),
rucNumber: varchar('ruc_number', { length: 50 }),
companyAddress: pgText('company_address'),
companyCity: varchar('company_city', { length: 100 }),
companyCountry: varchar('company_country', { length: 100 }),
supportEmail: varchar('support_email', { length: 255 }),
legalEmail: varchar('legal_email', { length: 255 }),
governingLaw: varchar('governing_law', { length: 255 }),
jurisdictionCity: varchar('jurisdiction_city', { length: 100 }),
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
});
// Site Settings table for global website configuration // Site Settings table for global website configuration
export const pgSiteSettings = pgTable('site_settings', { export const pgSiteSettings = pgTable('site_settings', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
@@ -661,7 +623,6 @@ 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 legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings;
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings; export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages; export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions; export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
@@ -697,5 +658,3 @@ export type LegalPage = typeof sqliteLegalPages.$inferSelect;
export type NewLegalPage = typeof sqliteLegalPages.$inferInsert; export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect; export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert; export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;

View File

@@ -21,10 +21,8 @@ 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 siteSettingsRoutes from './routes/site-settings.js';
import legalPagesRoutes from './routes/legal-pages.js'; import legalPagesRoutes from './routes/legal-pages.js';
import legalSettingsRoutes from './routes/legal-settings.js';
import faqRoutes from './routes/faq.js'; import faqRoutes from './routes/faq.js';
import emailService from './lib/email.js'; import emailService from './lib/email.js';
import { initEmailQueue } from './lib/emailQueue.js';
const app = new Hono(); const app = new Hono();
@@ -1858,7 +1856,6 @@ app.route('/api/payment-options', paymentOptionsRoutes);
app.route('/api/dashboard', dashboardRoutes); app.route('/api/dashboard', dashboardRoutes);
app.route('/api/site-settings', siteSettingsRoutes); app.route('/api/site-settings', siteSettingsRoutes);
app.route('/api/legal-pages', legalPagesRoutes); app.route('/api/legal-pages', legalPagesRoutes);
app.route('/api/legal-settings', legalSettingsRoutes);
app.route('/api/faq', faqRoutes); app.route('/api/faq', faqRoutes);
// 404 handler // 404 handler
@@ -1874,9 +1871,6 @@ app.onError((err, c) => {
const port = parseInt(process.env.PORT || '3001'); const port = parseInt(process.env.PORT || '3001');
// Initialize email queue with the email service reference
initEmailQueue(emailService);
// Initialize email templates on startup // Initialize email templates on startup
emailService.seedDefaultTemplates().catch(err => { emailService.seedDefaultTemplates().catch(err => {
console.error('[Email] Failed to seed templates:', err); console.error('[Email] Failed to seed templates:', err);

View File

@@ -10,7 +10,6 @@ import {
defaultTemplates, defaultTemplates,
type DefaultTemplate type DefaultTemplate
} from './emailTemplates.js'; } from './emailTemplates.js';
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer'; import type { Transporter } from 'nodemailer';
@@ -1175,100 +1174,6 @@ export const emailService = {
}; };
}, },
/**
* Queue emails for event attendees (non-blocking).
* Adds all matching recipients to the background email queue and returns immediately.
* Rate limiting and actual sending is handled by the email queue.
*/
async queueEventEmails(params: {
eventId: string;
templateSlug: string;
customVariables?: Record<string, any>;
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
sentBy: string;
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
// Validate event exists
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, eventId))
);
if (!event) {
return { success: false, queuedCount: 0, error: 'Event not found' };
}
// Validate template exists
const template = await this.getTemplate(templateSlug);
if (!template) {
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
}
// Get tickets based on filter
let ticketQuery = (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).eventId, eventId));
if (recipientFilter !== 'all') {
ticketQuery = ticketQuery.where(
and(
eq((tickets as any).eventId, eventId),
eq((tickets as any).status, recipientFilter)
)
);
}
const eventTickets = await dbAll<any>(ticketQuery);
if (eventTickets.length === 0) {
return { success: true, queuedCount: 0, error: 'No recipients found' };
}
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
// Build individual email jobs for the queue
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
return {
templateSlug,
to: ticket.attendeeEmail,
toName: fullName,
locale,
eventId: event.id,
sentBy,
variables: {
attendeeName: fullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
...customVariables,
},
};
});
// Enqueue all emails for background processing
enqueueBulkEmails(jobs);
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
return {
success: true,
queuedCount: jobs.length,
};
},
/** /**
* Send a custom email (not from template) * Send a custom email (not from template)
*/ */
@@ -1278,11 +1183,10 @@ export const emailService = {
subject: string; subject: string;
bodyHtml: string; bodyHtml: string;
bodyText?: string; bodyText?: string;
replyTo?: string;
eventId?: string; eventId?: string;
sentBy?: string | null; sentBy: string;
}): Promise<{ success: boolean; logId?: string; error?: string }> { }): Promise<{ success: boolean; logId?: string; error?: string }> {
const { to, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params; const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params;
const allVariables = { const allVariables = {
...this.getCommonVariables(), ...this.getCommonVariables(),
@@ -1304,7 +1208,7 @@ export const emailService = {
subject, subject,
bodyHtml: finalBodyHtml, bodyHtml: finalBodyHtml,
status: 'pending', status: 'pending',
sentBy: sentBy || null, sentBy,
createdAt: now, createdAt: now,
}); });
@@ -1314,7 +1218,6 @@ export const emailService = {
subject, subject,
html: finalBodyHtml, html: finalBodyHtml,
text: bodyText, text: bodyText,
replyTo,
}); });
// Update log // Update log
@@ -1342,61 +1245,6 @@ export const emailService = {
error: result.error error: result.error
}; };
}, },
/**
* Resend an email from an existing log entry
*/
async resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
const log = await dbGet<any>(
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
);
if (!log) {
return { success: false, error: 'Email log not found' };
}
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
return { success: false, error: 'Email log missing required data to resend' };
}
const result = await sendEmail({
to: log.recipientEmail,
subject: log.subject,
html: log.bodyHtml,
text: undefined,
});
const now = getNow();
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: now,
errorMessage: null,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
error: result.error,
};
},
}; };
// Export the main sendEmail function for direct use // Export the main sendEmail function for direct use

View File

@@ -1,194 +0,0 @@
// In-memory email queue with rate limiting
// Processes emails asynchronously in the background without blocking the request thread
import { generateId } from './utils.js';
// ==================== Types ====================
export interface EmailJob {
id: string;
type: 'template';
params: TemplateEmailJobParams;
addedAt: number;
}
export interface TemplateEmailJobParams {
templateSlug: string;
to: string;
toName?: string;
variables: Record<string, any>;
locale?: string;
eventId?: string;
sentBy?: string;
}
export interface QueueStatus {
queued: number;
processing: boolean;
sentInLastHour: number;
maxPerHour: number;
}
// ==================== Queue State ====================
const queue: EmailJob[] = [];
const sentTimestamps: number[] = [];
let processing = false;
let processTimer: ReturnType<typeof setTimeout> | null = null;
// Lazy reference to emailService to avoid circular imports
let _emailService: any = null;
function getEmailService() {
if (!_emailService) {
// Dynamic import to avoid circular dependency
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
}
return _emailService;
}
/**
* Initialize the email queue with a reference to the email service.
* Must be called once at startup.
*/
export function initEmailQueue(emailService: any): void {
_emailService = emailService;
console.log('[EmailQueue] Initialized');
}
// ==================== Rate Limiting ====================
function getMaxPerHour(): number {
return parseInt(process.env.MAX_EMAILS_PER_HOUR || '30', 10);
}
/**
* Clean up timestamps older than 1 hour
*/
function cleanOldTimestamps(): void {
const oneHourAgo = Date.now() - 3_600_000;
while (sentTimestamps.length > 0 && sentTimestamps[0] <= oneHourAgo) {
sentTimestamps.shift();
}
}
// ==================== Queue Operations ====================
/**
* Add a single email job to the queue.
* Returns the job ID.
*/
export function enqueueEmail(params: TemplateEmailJobParams): string {
const id = generateId();
queue.push({
id,
type: 'template',
params,
addedAt: Date.now(),
});
scheduleProcessing();
return id;
}
/**
* Add multiple email jobs to the queue at once.
* Returns array of job IDs.
*/
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
const ids: string[] = [];
for (const params of paramsList) {
const id = generateId();
queue.push({
id,
type: 'template',
params,
addedAt: Date.now(),
});
ids.push(id);
}
if (ids.length > 0) {
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
scheduleProcessing();
}
return ids;
}
/**
* Get current queue status
*/
export function getQueueStatus(): QueueStatus {
cleanOldTimestamps();
return {
queued: queue.length,
processing,
sentInLastHour: sentTimestamps.length,
maxPerHour: getMaxPerHour(),
};
}
// ==================== Processing ====================
function scheduleProcessing(): void {
if (processing) return;
processing = true;
// Start processing on next tick to not block the caller
setImmediate(() => processNext());
}
async function processNext(): Promise<void> {
if (queue.length === 0) {
processing = false;
console.log('[EmailQueue] Queue empty. Processing stopped.');
return;
}
// Rate limit check
cleanOldTimestamps();
const maxPerHour = getMaxPerHour();
if (sentTimestamps.length >= maxPerHour) {
// Calculate when the oldest timestamp in the window expires
const waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
console.log(
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
);
processTimer = setTimeout(() => processNext(), waitMs);
return;
}
// Dequeue and process
const job = queue.shift()!;
try {
const emailService = getEmailService();
await emailService.sendTemplateEmail(job.params);
sentTimestamps.push(Date.now());
console.log(
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
);
} catch (error: any) {
console.error(
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
error?.message || error
);
// The sendTemplateEmail method already logs the failure in the email_logs table,
// so we don't need to retry here. The error is logged and we move on.
}
// Small delay between sends to be gentle on the email server
processTimer = setTimeout(() => processNext(), 200);
}
/**
* Stop processing (for graceful shutdown)
*/
export function stopQueue(): void {
if (processTimer) {
clearTimeout(processTimer);
processTimer = null;
}
processing = false;
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
}

View File

@@ -1,80 +0,0 @@
import { getLegalSettingsValues } from '../routes/legal-settings.js';
/**
* Strict whitelist of supported placeholders.
* Only these placeholders will be replaced in legal page content.
* Unknown placeholders remain unchanged.
*/
const SUPPORTED_PLACEHOLDERS = new Set([
'COMPANY_NAME',
'LEGAL_ENTITY_NAME',
'RUC_NUMBER',
'COMPANY_ADDRESS',
'COMPANY_CITY',
'COMPANY_COUNTRY',
'SUPPORT_EMAIL',
'LEGAL_EMAIL',
'GOVERNING_LAW',
'JURISDICTION_CITY',
'CURRENT_YEAR',
'LAST_UPDATED_DATE',
]);
/**
* Replace legal placeholders in content using strict whitelist mapping.
*
* Rules:
* - Only supported placeholders are replaced
* - Unknown placeholders remain unchanged
* - Missing values are replaced with empty string
* - No code execution or dynamic evaluation
* - Replacement is pure string substitution
*
* @param content - The markdown/text content containing {{PLACEHOLDER}} tokens
* @param updatedAt - The page's updated_at timestamp (for LAST_UPDATED_DATE)
* @returns Content with placeholders replaced
*/
export async function replaceLegalPlaceholders(
content: string,
updatedAt?: string
): Promise<string> {
if (!content) return content;
// Fetch legal settings values from DB
const settingsValues = await getLegalSettingsValues();
// Build the full replacement map
const replacements: Record<string, string> = { ...settingsValues };
// Dynamic values
replacements['CURRENT_YEAR'] = new Date().getFullYear().toString();
if (updatedAt) {
try {
const date = new Date(updatedAt);
if (!isNaN(date.getTime())) {
replacements['LAST_UPDATED_DATE'] = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
} else {
replacements['LAST_UPDATED_DATE'] = updatedAt;
}
} catch {
replacements['LAST_UPDATED_DATE'] = updatedAt;
}
}
// Replace only whitelisted placeholders using a single regex pass
// Matches {{PLACEHOLDER_NAME}} where PLACEHOLDER_NAME is uppercase letters and underscores
return content.replace(/\{\{([A-Z_]+)\}\}/g, (match, placeholderName) => {
// Only replace if the placeholder is in the whitelist
if (!SUPPORTED_PLACEHOLDERS.has(placeholderName)) {
return match; // Unknown placeholder - leave unchanged
}
// Return the value or empty string if missing
return replacements[placeholderName] ?? '';
});
}

View File

@@ -1,22 +0,0 @@
// Trigger frontend cache revalidation (fire-and-forget)
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
export function revalidateFrontendCache() {
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const secret = process.env.REVALIDATE_SECRET;
if (!secret) {
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
return;
}
fetch(`${frontendUrl}/api/revalidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
})
.then((res) => {
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
else console.log('Frontend revalidation triggered (sitemap + next-event)');
})
.catch((err) => {
console.error('Frontend revalidation error:', err.message);
});
}

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js'; import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
import { eq, and, gte, sql, desc, inArray } from 'drizzle-orm'; import { eq, and, gte, sql, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
@@ -222,211 +222,6 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
return c.json({ tickets: enrichedTickets }); return c.json({ tickets: enrichedTickets });
}); });
// Export attendees for a specific event (admin) — CSV download
adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
const eventId = c.req.param('eventId');
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
const q = c.req.query('q') || '';
// Verify event exists
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Build query for tickets belonging to this event
let conditions: any[] = [eq((tickets as any).eventId, eventId)];
if (status === 'confirmed') {
conditions.push(eq((tickets as any).status, 'confirmed'));
} else if (status === 'checked_in') {
conditions.push(eq((tickets as any).status, 'checked_in'));
} else if (status === 'confirmed_pending') {
conditions.push(inArray((tickets as any).status, ['confirmed', 'pending']));
} else {
// "all" — include everything
}
let ticketList = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(conditions.length === 1 ? conditions[0] : and(...conditions))
.orderBy(desc((tickets as any).createdAt))
);
// Apply text search filter in-memory
if (q) {
const query = q.toLowerCase();
ticketList = ticketList.filter((t: any) => {
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
return (
fullName.includes(query) ||
(t.attendeeEmail || '').toLowerCase().includes(query) ||
(t.attendeePhone || '').toLowerCase().includes(query) ||
t.id.toLowerCase().includes(query)
);
});
}
// Enrich each ticket with payment data
const rows = await Promise.all(
ticketList.map(async (ticket: any) => {
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
);
const fullName = [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' ');
const isCheckedIn = ticket.status === 'checked_in';
return {
'Ticket ID': ticket.id,
'Full Name': fullName,
'Email': ticket.attendeeEmail || '',
'Phone': ticket.attendeePhone || '',
'Status': ticket.status,
'Checked In': isCheckedIn ? 'true' : 'false',
'Check-in Time': ticket.checkinAt || '',
'Payment Status': payment?.status || '',
'Booked At': ticket.createdAt || '',
'Notes': ticket.adminNote || '',
};
})
);
// Generate CSV
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
const columns = [
'Ticket ID', 'Full Name', 'Email', 'Phone',
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
'Booked At', 'Notes',
];
const headerLine = columns.map(csvEscape).join(',');
const dataLines = rows.map((row: any) =>
columns.map((col) => csvEscape(row[col])).join(',')
);
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n'); // BOM for UTF-8
// Build filename: event-slug-attendees-YYYY-MM-DD.csv
const slug = (event.title || 'event')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const dateStr = new Date().toISOString().split('T')[0];
const filename = `${slug}-attendees-${dateStr}.csv`;
c.header('Content-Type', 'text/csv; charset=utf-8');
c.header('Content-Disposition', `attachment; filename="${filename}"`);
return c.body(csvContent);
});
// Legacy alias — keep old path working
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => {
const newUrl = new URL(c.req.url);
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
return c.redirect(newUrl.toString(), 301);
});
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => {
const eventId = c.req.param('eventId');
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
const q = c.req.query('q') || '';
// Verify event exists
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Only confirmed/checked_in for tickets export
let conditions: any[] = [
eq((tickets as any).eventId, eventId),
inArray((tickets as any).status, ['confirmed', 'checked_in']),
];
if (status === 'confirmed') {
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'confirmed')];
} else if (status === 'checked_in') {
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'checked_in')];
}
let ticketList = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(and(...conditions))
.orderBy(desc((tickets as any).createdAt))
);
// Apply text search filter
if (q) {
const query = q.toLowerCase();
ticketList = ticketList.filter((t: any) => {
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
return (
fullName.includes(query) ||
t.id.toLowerCase().includes(query)
);
});
}
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
const rows = ticketList.map((ticket: any) => ({
'Ticket ID': ticket.id,
'Booking ID': ticket.bookingId || '',
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
'Status': ticket.status,
'Check-in Time': ticket.checkinAt || '',
'Booked At': ticket.createdAt || '',
}));
const headerLine = columns.map(csvEscape).join(',');
const dataLines = rows.map((row: any) =>
columns.map((col: string) => csvEscape(row[col])).join(',')
);
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n');
const slug = (event.title || 'event')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const dateStr = new Date().toISOString().split('T')[0];
const filename = `${slug}-tickets-${dateStr}.csv`;
c.header('Content-Type', 'text/csv; charset=utf-8');
c.header('Content-Disposition', `attachment; filename="${filename}"`);
return c.body(csvContent);
});
// Export financial data (admin) // Export financial data (admin)
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => { adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
const startDate = c.req.query('startDate'); const startDate = c.req.query('startDate');

View File

@@ -1,37 +1,13 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js'; import { db, dbGet, dbAll, contacts, emailSubscribers } from '../db/index.js';
import { eq, desc } from 'drizzle-orm'; import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow } from '../lib/utils.js';
import { emailService } from '../lib/email.js';
const contactsRouter = new Hono(); const contactsRouter = new Hono();
// ==================== Sanitization Helpers ====================
/**
* Sanitize a string to prevent HTML injection
* Escapes HTML special characters
*/
function sanitizeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
/**
* Sanitize email header values to prevent email header injection
* Strips newlines and carriage returns that could be used to inject headers
*/
function sanitizeHeaderValue(str: string): string {
return str.replace(/[\r\n]/g, '').trim();
}
const createContactSchema = z.object({ const createContactSchema = z.object({
name: z.string().min(2), name: z.string().min(2),
email: z.string().email(), email: z.string().email(),
@@ -53,74 +29,17 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
const now = getNow(); const now = getNow();
const id = generateId(); const id = generateId();
// Sanitize header-sensitive values to prevent email header injection
const sanitizedEmail = sanitizeHeaderValue(data.email);
const sanitizedName = sanitizeHeaderValue(data.name);
const newContact = { const newContact = {
id, id,
name: sanitizedName, name: data.name,
email: sanitizedEmail, email: data.email,
message: data.message, message: data.message,
status: 'new' as const, status: 'new' as const,
createdAt: now, createdAt: now,
}; };
// Always store the message in admin, regardless of email outcome
await (db as any).insert(contacts).values(newContact); await (db as any).insert(contacts).values(newContact);
// Send email notification to support email (non-blocking)
try {
// Retrieve support_email from legal_settings
const settings = await dbGet<any>(
(db as any).select().from(legalSettings).limit(1)
);
const supportEmail = settings?.supportEmail;
if (supportEmail) {
const websiteUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
// Sanitize all values for HTML display
const safeName = sanitizeHtml(sanitizedName);
const safeEmail = sanitizeHtml(sanitizedEmail);
const safeMessage = sanitizeHtml(data.message);
const subject = `New Contact Form Message ${websiteUrl}`;
const bodyHtml = `
<p><strong>${safeName}</strong> (${safeEmail}) sent a message:</p>
<div style="padding: 16px 20px; background-color: #f8fafc; border-left: 4px solid #3b82f6; margin: 16px 0; white-space: pre-wrap; font-size: 15px; line-height: 1.6;">${safeMessage}</div>
<p style="color: #64748b; font-size: 13px;">Reply directly to this email to respond to ${safeName}.</p>
`;
const bodyText = [
`${sanitizedName} (${sanitizedEmail}) sent a message:`,
'',
data.message,
'',
`Reply directly to this email to respond to ${sanitizedName}.`,
].join('\n');
const emailResult = await emailService.sendCustomEmail({
to: supportEmail,
subject,
bodyHtml,
bodyText,
replyTo: sanitizedEmail,
});
if (!emailResult.success) {
console.error('[Contact Form] Failed to send email notification:', emailResult.error);
}
} else {
console.warn('[Contact Form] No support email configured in legal settings skipping email notification');
}
} catch (emailError: any) {
// Log the error but do NOT break the contact form UX
console.error('[Contact Form] Error sending email notification:', emailError?.message || emailError);
}
return c.json({ message: 'Message sent successfully' }, 201); return c.json({ message: 'Message sent successfully' }, 201);
}); });

View File

@@ -5,7 +5,6 @@ import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js'; import { getNow, generateId } from '../lib/utils.js';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js'; import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
import { getQueueStatus } from '../lib/emailQueue.js';
const emailsRouter = new Hono(); const emailsRouter = new Hono();
@@ -196,7 +195,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
// ==================== Email Sending Routes ==================== // ==================== Email Sending Routes ====================
// Send email using template to event attendees (non-blocking, queued) // Send email using template to event attendees
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
const { eventId } = c.req.param(); const { eventId } = c.req.param();
const user = (c as any).get('user'); const user = (c as any).get('user');
@@ -207,8 +206,7 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
return c.json({ error: 'Template slug is required' }, 400); return c.json({ error: 'Template slug is required' }, 400);
} }
// Queue emails for background processing instead of sending synchronously const result = await emailService.sendToEventAttendees({
const result = await emailService.queueEventEmails({
eventId, eventId,
templateSlug, templateSlug,
customVariables, customVariables,
@@ -349,23 +347,6 @@ emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) =>
return c.json({ log }); return c.json({ log });
}); });
// Resend email from log
emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param();
const result = await emailService.resendFromLog(id);
if (!result.success && result.error === 'Email log not found') {
return c.json({ error: 'Email log not found' }, 404);
}
if (!result.success && result.error === 'Email log missing required data to resend') {
return c.json({ error: result.error }, 400);
}
return c.json({ success: result.success, error: result.error });
});
// Get email stats // Get email stats
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');
@@ -430,10 +411,4 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
return c.json(result); return c.json(result);
}); });
// Get email queue status
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
const status = getQueueStatus();
return c.json({ status });
});
export default emailsRouter; export default emailsRouter;

View File

@@ -5,7 +5,6 @@ import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, ema
import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -16,6 +15,29 @@ interface UserContext {
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Trigger frontend cache revalidation (fire-and-forget)
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
function revalidateFrontendCache() {
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const secret = process.env.REVALIDATE_SECRET;
if (!secret) {
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
return;
}
fetch(`${frontendUrl}/api/revalidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
})
.then((res) => {
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
else console.log('Frontend revalidation triggered (sitemap + next-event)');
})
.catch((err) => {
console.error('Frontend revalidation error:', err.message);
});
}
// Helper to normalize event data for API response // Helper to normalize event data for API response
// PostgreSQL decimal returns strings, booleans are stored as integers // PostgreSQL decimal returns strings, booleans are stored as integers
function normalizeEvent(event: any) { function normalizeEvent(event: any) {
@@ -75,7 +97,7 @@ const baseEventSchema = z.object({
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0), price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
currency: z.string().default('PYG'), currency: z.string().default('PYG'),
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50), capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'), status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
// Accept relative paths (/uploads/...) or full URLs // Accept relative paths (/uploads/...) or full URLs
bannerUrl: z.string().optional().nullable().or(z.literal('')), bannerUrl: z.string().optional().nullable().or(z.literal('')),
// External booking support - accept boolean or number (0/1 from DB) // External booking support - accept boolean or number (0/1 from DB)
@@ -220,7 +242,6 @@ async function getEventTicketCount(eventId: string): Promise<number> {
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming // Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
eventsRouter.get('/next/upcoming', async (c) => { eventsRouter.get('/next/upcoming', async (c) => {
const now = getNow(); const now = getNow();
const nowMs = Date.now();
// First, check if there's a featured event in site settings // First, check if there's a featured event in site settings
const settings = await dbGet<any>( const settings = await dbGet<any>(
@@ -231,6 +252,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
let shouldUnsetFeatured = false; let shouldUnsetFeatured = false;
if (settings?.featuredEventId) { if (settings?.featuredEventId) {
// Get the featured event
featuredEvent = await dbGet<any>( featuredEvent = await dbGet<any>(
(db as any) (db as any)
.select() .select()
@@ -239,30 +261,37 @@ eventsRouter.get('/next/upcoming', async (c) => {
); );
if (featuredEvent) { if (featuredEvent) {
// Check if featured event is still valid:
// 1. Must be published
// 2. Must not have ended (endDatetime >= now, or startDatetime >= now if no endDatetime)
const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime; const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime;
const isPublished = featuredEvent.status === 'published'; const isPublished = featuredEvent.status === 'published';
const hasNotEnded = new Date(eventEndTime).getTime() > nowMs; const hasNotEnded = eventEndTime >= now;
if (!isPublished || !hasNotEnded) { if (!isPublished || !hasNotEnded) {
// Featured event is no longer valid - mark for unsetting
shouldUnsetFeatured = true; shouldUnsetFeatured = true;
featuredEvent = null; featuredEvent = null;
} }
} else { } else {
// Featured event no longer exists
shouldUnsetFeatured = true; shouldUnsetFeatured = true;
} }
} }
// If we need to unset the featured event, do it asynchronously
if (shouldUnsetFeatured && settings) { if (shouldUnsetFeatured && settings) {
try { // Unset featured event in background (don't await to avoid blocking response)
await (db as any) (db as any)
.update(siteSettings) .update(siteSettings)
.set({ featuredEventId: null, updatedAt: now }) .set({ featuredEventId: null, updatedAt: now })
.where(eq((siteSettings as any).id, settings.id)); .where(eq((siteSettings as any).id, settings.id))
console.log('Featured event auto-cleared (event ended or unpublished)'); .then(() => {
revalidateFrontendCache(); console.log('Featured event auto-cleared (event ended or unpublished)');
} catch (err: any) { })
console.error('Failed to clear featured event:', err); .catch((err: any) => {
} console.error('Failed to clear featured event:', err);
});
} }
// If we have a valid featured event, return it // If we have a valid featured event, return it

View File

@@ -3,7 +3,6 @@ import { db, dbGet, dbAll, legalPages } from '../db/index.js';
import { eq, desc } from 'drizzle-orm'; import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js'; import { getNow, generateId } from '../lib/utils.js';
import { replaceLegalPlaceholders } from '../lib/legal-placeholders.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -172,15 +171,12 @@ legalPagesRouter.get('/:slug', async (c) => {
// Get localized content with fallback // Get localized content with fallback
const { title, contentMarkdown } = getLocalizedContent(page, locale); const { title, contentMarkdown } = getLocalizedContent(page, locale);
// Replace legal placeholders before returning
const processedContent = await replaceLegalPlaceholders(contentMarkdown, page.updatedAt);
return c.json({ return c.json({
page: { page: {
id: page.id, id: page.id,
slug: page.slug, slug: page.slug,
title, title,
contentMarkdown: processedContent, contentMarkdown,
updatedAt: page.updatedAt, updatedAt: page.updatedAt,
source: 'database', source: 'database',
} }
@@ -199,14 +195,11 @@ legalPagesRouter.get('/:slug', async (c) => {
? (titles?.es || titles?.en || slug) ? (titles?.es || titles?.en || slug)
: (titles?.en || titles?.es || slug); : (titles?.en || titles?.es || slug);
// Replace legal placeholders in filesystem content too
const processedContent = await replaceLegalPlaceholders(content);
return c.json({ return c.json({
page: { page: {
slug, slug,
title, title,
contentMarkdown: processedContent, contentMarkdown: content,
source: 'filesystem', source: 'filesystem',
} }
}); });

View File

@@ -1,146 +0,0 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, legalSettings } 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 legalSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Validation schema for updating legal settings
const updateLegalSettingsSchema = z.object({
companyName: z.string().optional().nullable(),
legalEntityName: z.string().optional().nullable(),
rucNumber: z.string().optional().nullable(),
companyAddress: z.string().optional().nullable(),
companyCity: z.string().optional().nullable(),
companyCountry: z.string().optional().nullable(),
supportEmail: z.string().email().optional().nullable().or(z.literal('')),
legalEmail: z.string().email().optional().nullable().or(z.literal('')),
governingLaw: z.string().optional().nullable(),
jurisdictionCity: z.string().optional().nullable(),
});
// Get legal settings (admin only)
legalSettingsRouter.get('/', requireAuth(['admin']), async (c) => {
const settings = await dbGet<any>(
(db as any).select().from(legalSettings).limit(1)
);
if (!settings) {
// Return empty defaults
return c.json({
settings: {
companyName: null,
legalEntityName: null,
rucNumber: null,
companyAddress: null,
companyCity: null,
companyCountry: null,
supportEmail: null,
legalEmail: null,
governingLaw: null,
jurisdictionCity: null,
},
});
}
return c.json({ settings });
});
// Internal helper: get legal settings for placeholder replacement (no auth required)
// This is called server-side from legal-pages route, not exposed as HTTP endpoint
export async function getLegalSettingsValues(): Promise<Record<string, string>> {
const settings = await dbGet<any>(
(db as any).select().from(legalSettings).limit(1)
);
if (!settings) {
return {};
}
const values: Record<string, string> = {};
if (settings.companyName) values['COMPANY_NAME'] = settings.companyName;
if (settings.legalEntityName) values['LEGAL_ENTITY_NAME'] = settings.legalEntityName;
if (settings.rucNumber) values['RUC_NUMBER'] = settings.rucNumber;
if (settings.companyAddress) values['COMPANY_ADDRESS'] = settings.companyAddress;
if (settings.companyCity) values['COMPANY_CITY'] = settings.companyCity;
if (settings.companyCountry) values['COMPANY_COUNTRY'] = settings.companyCountry;
if (settings.supportEmail) values['SUPPORT_EMAIL'] = settings.supportEmail;
if (settings.legalEmail) values['LEGAL_EMAIL'] = settings.legalEmail;
if (settings.governingLaw) values['GOVERNING_LAW'] = settings.governingLaw;
if (settings.jurisdictionCity) values['JURISDICTION_CITY'] = settings.jurisdictionCity;
return values;
}
// Update legal settings (admin only)
legalSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateLegalSettingsSchema), async (c) => {
const data = c.req.valid('json');
const user = c.get('user');
const now = getNow();
// Check if settings exist
const existing = await dbGet<any>(
(db as any).select().from(legalSettings).limit(1)
);
if (!existing) {
// Create new settings record
const id = generateId();
const newSettings = {
id,
companyName: data.companyName || null,
legalEntityName: data.legalEntityName || null,
rucNumber: data.rucNumber || null,
companyAddress: data.companyAddress || null,
companyCity: data.companyCity || null,
companyCountry: data.companyCountry || null,
supportEmail: data.supportEmail || null,
legalEmail: data.legalEmail || null,
governingLaw: data.governingLaw || null,
jurisdictionCity: data.jurisdictionCity || null,
updatedAt: now,
updatedBy: user.id,
};
await (db as any).insert(legalSettings).values(newSettings);
return c.json({ settings: newSettings, message: 'Legal settings created successfully' }, 201);
}
// Update existing settings
const updateData: Record<string, any> = {
...data,
updatedAt: now,
updatedBy: user.id,
};
// Normalize empty strings to null
for (const key of Object.keys(updateData)) {
if (updateData[key] === '') {
updateData[key] = null;
}
}
await (db as any)
.update(legalSettings)
.set(updateData)
.where(eq((legalSettings as any).id, existing.id));
const updated = await dbGet(
(db as any).select().from(legalSettings).where(eq((legalSettings as any).id, existing.id))
);
return c.json({ settings: updated, message: 'Legal settings updated successfully' });
});
export default legalSettingsRouter;

View File

@@ -30,8 +30,6 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const status = c.req.query('status'); const status = c.req.query('status');
const provider = c.req.query('provider'); const provider = c.req.query('provider');
const pendingApproval = c.req.query('pendingApproval'); const pendingApproval = c.req.query('pendingApproval');
const eventId = c.req.query('eventId');
const eventIds = c.req.query('eventIds');
// Get all payments with their associated tickets // Get all payments with their associated tickets
let allPayments = await dbAll<any>( let allPayments = await dbAll<any>(
@@ -57,7 +55,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
} }
// Enrich with ticket and event data // Enrich with ticket and event data
let enrichedPayments = await Promise.all( const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => { allPayments.map(async (payment: any) => {
const ticket = await dbGet<any>( const ticket = await dbGet<any>(
(db as any) (db as any)
@@ -96,16 +94,6 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
}) })
); );
// Filter by event(s)
if (eventId) {
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
} else if (eventIds) {
const ids = eventIds.split(',').map((s: string) => s.trim()).filter(Boolean);
if (ids.length > 0) {
enrichedPayments = enrichedPayments.filter((p: any) => p.event && ids.includes(p.event.id));
}
}
return c.json({ payments: enrichedPayments }); return c.json({ payments: enrichedPayments });
}); });

View File

@@ -5,7 +5,6 @@ import { db, dbGet, siteSettings, events } from '../db/index.js';
import { eq, and, gte } from 'drizzle-orm'; import { eq, and, gte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -173,11 +172,6 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id)) (db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
); );
// Revalidate frontend cache if featured event changed
if (data.featuredEventId !== undefined) {
revalidateFrontendCache();
}
return c.json({ settings: updated, message: 'Settings updated successfully' }); return c.json({ settings: updated, message: 'Settings updated successfully' });
}); });
@@ -200,13 +194,6 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
if (event.status !== 'published') { if (event.status !== 'published') {
return c.json({ error: 'Event must be published to be featured' }, 400); return c.json({ error: 'Event must be published to be featured' }, 400);
} }
const eventEndTime = event.endDatetime || event.startDatetime;
if (new Date(eventEndTime).getTime() <= Date.now()) {
return c.json(
{ error: 'Cannot feature an event that has already ended' },
400
);
}
} }
// Get or create settings // Get or create settings
@@ -229,9 +216,6 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
await (db as any).insert(siteSettings).values(newSettings); await (db as any).insert(siteSettings).values(newSettings);
// Revalidate frontend cache so homepage shows the updated featured event
revalidateFrontendCache();
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' }); return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
} }
@@ -245,9 +229,6 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
}) })
.where(eq((siteSettings as any).id, existing.id)); .where(eq((siteSettings as any).id, existing.id));
// Revalidate frontend cache so homepage shows the updated featured event
revalidateFrontendCache();
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' }); return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
}); });

View File

@@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
import { eq, and, or, sql, inArray } from 'drizzle-orm'; import { eq, and, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
@@ -69,7 +69,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
if (!['published', 'unlisted'].includes(event.status)) { if (event.status !== 'published') {
return c.json({ error: 'Event is not available for booking' }, 400); return c.json({ error: 'Event is not available for booking' }, 400);
} }
@@ -490,125 +490,6 @@ ticketsRouter.get('/:id/pdf', async (c) => {
} }
}); });
// Get event check-in stats for scanner (lightweight endpoint for staff)
ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const eventId = c.req.query('eventId');
if (!eventId) {
return c.json({ error: 'eventId is required' }, 400);
}
// Get event info
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Count checked-in tickets
const checkedInCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, eventId),
eq((tickets as any).status, 'checked_in')
)
)
);
// Count confirmed + checked_in (total active)
const totalActiveCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
return c.json({
eventId,
capacity: event.capacity,
checkedIn: checkedInCount?.count || 0,
totalActive: totalActiveCount?.count || 0,
});
});
// Live search tickets (GET - for scanner live search)
ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const q = c.req.query('q')?.trim() || '';
const eventId = c.req.query('eventId');
if (q.length < 2) {
return c.json({ tickets: [] });
}
const searchTerm = `%${q.toLowerCase()}%`;
// Search by name (ILIKE), email (ILIKE), ticket ID (exact or partial)
const nameEmailConditions = [
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
// Ticket ID exact or partial match (cast UUID to text for LOWER)
sql`LOWER(CAST(${(tickets as any).id} AS TEXT)) LIKE ${searchTerm}`,
sql`LOWER(CAST(${(tickets as any).qrCode} AS TEXT)) LIKE ${searchTerm}`,
];
let whereClause: any = and(
or(...nameEmailConditions),
// Exclude cancelled tickets by default
sql`${(tickets as any).status} != 'cancelled'`
);
if (eventId) {
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
}
const matchingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(whereClause)
.limit(20)
);
// Enrich with event details
const results = await Promise.all(
matchingTickets.map(async (ticket: any) => {
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
);
return {
ticket_id: ticket.id,
name: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
email: ticket.attendeeEmail,
status: ticket.status,
checked_in: ticket.status === 'checked_in',
checkinAt: ticket.checkinAt,
event_id: ticket.eventId,
qrCode: ticket.qrCode,
event: event ? {
id: event.id,
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
} : null,
};
})
);
return c.json({ tickets: results });
});
// 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');
@@ -673,65 +554,6 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
return c.json({ ticket: updated }); return c.json({ ticket: updated });
}); });
// Search tickets by name/email (for scanner manual search)
ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const body = await c.req.json().catch(() => ({}));
const { query, eventId } = body;
if (!query || typeof query !== 'string' || query.trim().length < 2) {
return c.json({ error: 'Search query must be at least 2 characters' }, 400);
}
const searchTerm = `%${query.trim().toLowerCase()}%`;
const conditions = [
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
];
let whereClause = or(...conditions);
if (eventId) {
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
}
const matchingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(whereClause)
.limit(20)
);
// Enrich with event details
const results = await Promise.all(
matchingTickets.map(async (ticket: any) => {
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
);
return {
id: ticket.id,
qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone,
status: ticket.status,
checkinAt: ticket.checkinAt,
event: event ? {
id: event.id,
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
} : null,
};
})
);
return c.json({ tickets: results });
});
// Validate ticket by QR code (for scanner) // Validate ticket by QR code (for scanner)
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => { ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const body = await c.req.json().catch(() => ({})); const body = await c.req.json().catch(() => ({}));
@@ -1394,7 +1216,7 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
}, 201); }, 201);
}); });
// Get all tickets (admin) - includes payment for each ticket // Get all tickets (admin)
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');
const status = c.req.query('status'); const status = c.req.query('status');
@@ -1413,25 +1235,9 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(and(...conditions)); query = query.where(and(...conditions));
} }
const ticketsList = await dbAll(query); const result = await dbAll(query);
const ticketIds = ticketsList.map((t: any) => t.id);
let paymentByTicketId: Record<string, any> = {}; return c.json({ tickets: result });
if (ticketIds.length > 0) {
const paymentsList = await dbAll(
(db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))
);
for (const p of paymentsList as any[]) {
paymentByTicketId[p.ticketId] = p;
}
}
const ticketsWithPayment = ticketsList.map((t: any) => ({
...t,
payment: paymentByTicketId[t.id] || null,
}));
return c.json({ tickets: ticketsWithPayment });
}); });
export default ticketsRouter; export default ticketsRouter;

View File

@@ -8,9 +8,9 @@ Type=simple
User=spanglish User=spanglish
Group=spanglish Group=spanglish
WorkingDirectory=/home/spanglish/Spanglish/backend WorkingDirectory=/home/spanglish/Spanglish/backend
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
Environment=NODE_ENV=production Environment=NODE_ENV=production
Environment=PORT=3018 Environment=PORT=3018
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
ExecStart=/usr/bin/node dist/index.js ExecStart=/usr/bin/node dist/index.js
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10

View File

@@ -25,9 +25,6 @@ NEXT_PUBLIC_TIKTOK=spanglishsocialpy
# Must match the REVALIDATE_SECRET in backend/.env # Must match the REVALIDATE_SECRET in backend/.env
REVALIDATE_SECRET=change-me-to-a-random-secret REVALIDATE_SECRET=change-me-to-a-random-secret
# Next event cache revalidation (seconds) - homepage metadata/social preview refresh interval. Default: 3600
NEXT_EVENT_REVALIDATE_SECONDS=3600
# Plausible Analytics (optional - leave empty to disable tracking) # Plausible Analytics (optional - leave empty to disable tracking)
NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -145,7 +145,7 @@ export default function BookingPage() {
paymentOptionsApi.getForEvent(params.eventId as string), paymentOptionsApi.getForEvent(params.eventId as string),
]) ])
.then(([eventRes, paymentRes]) => { .then(([eventRes, paymentRes]) => {
if (!eventRes.event || !['published', 'unlisted'].includes(eventRes.event.status)) { if (!eventRes.event || eventRes.event.status !== 'published') {
toast.error('Event is not available for booking'); toast.error('Event is not available for booking');
router.push('/events'); router.push('/events');
return; return;

View File

@@ -5,7 +5,9 @@ import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils'; import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline'; import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
interface NextEventSectionProps { interface NextEventSectionProps {
initialEvent?: Event | null; initialEvent?: Event | null;
@@ -14,24 +16,11 @@ interface NextEventSectionProps {
export default function NextEventSection({ initialEvent }: NextEventSectionProps) { export default function NextEventSection({ initialEvent }: NextEventSectionProps) {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null); const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
const [loading, setLoading] = useState(initialEvent === undefined); const [loading, setLoading] = useState(!initialEvent);
useEffect(() => { useEffect(() => {
if (initialEvent !== undefined) { // Skip fetch if we already have server-provided data
if (initialEvent) { if (initialEvent !== undefined) return;
const endTime = initialEvent.endDatetime || initialEvent.startDatetime;
if (new Date(endTime).getTime() <= Date.now()) {
setNextEvent(null);
setLoading(true);
eventsApi.getNextUpcoming()
.then(({ event }) => setNextEvent(event))
.catch(console.error)
.finally(() => setLoading(false));
return;
}
}
return;
}
eventsApi.getNextUpcoming() eventsApi.getNextUpcoming()
.then(({ event }) => setNextEvent(event)) .then(({ event }) => setNextEvent(event))
.catch(console.error) .catch(console.error)
@@ -41,15 +30,6 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es'); const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const title = nextEvent
? (locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title)
: '';
const description = nextEvent
? (locale === 'es'
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
: (nextEvent.shortDescription || nextEvent.description))
: '';
if (loading) { if (loading) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
@@ -69,72 +49,56 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
} }
return ( return (
<Link href={`/events/${nextEvent.id}`} className="block group"> <Link href={`/events/${nextEvent.id}`} className="block">
<div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg transition-all duration-300 hover:shadow-2xl hover:scale-[1.01]"> <Card variant="elevated" className="p-8 cursor-pointer hover:shadow-lg transition-shadow">
<div className="flex flex-col md:flex-row"> <div className="flex flex-col md:flex-row gap-8">
{/* Banner */} <div className="flex-1">
{nextEvent.bannerUrl ? ( <h3 className="text-2xl font-bold text-primary-dark">
<div className="relative w-full md:w-2/5 flex-shrink-0"> {locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
<img </h3>
src={nextEvent.bannerUrl} <p className="mt-3 text-gray-600 whitespace-pre-line">
alt={title} {locale === 'es'
className="w-full h-48 md:h-full object-cover" ? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
/> : (nextEvent.shortDescription || nextEvent.description)}
</div> </p>
) : (
<div className="w-full md:w-2/5 flex-shrink-0 h-48 md:h-auto bg-gradient-to-br from-primary-yellow/20 to-secondary-gray flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-gray-300" />
</div>
)}
{/* Info */} <div className="mt-6 space-y-3">
<div className="flex-1 p-5 md:p-8 flex flex-col justify-between"> <div className="flex items-center gap-3 text-gray-700">
<div> <CalendarIcon className="w-5 h-5 text-primary-yellow" />
<h3 className="text-xl md:text-2xl font-bold text-primary-dark group-hover:text-brand-navy transition-colors"> <span>{formatDate(nextEvent.startDatetime)}</span>
{title}
</h3>
{description && (
<p className="mt-2 text-sm md:text-base text-gray-600 line-clamp-2">
{description}
</p>
)}
<div className="mt-4 md:mt-5 space-y-2">
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span>{formatDate(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
<ClockIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span>{fmtTime(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span>{nextEvent.location}</span>
</div>
</div> </div>
</div> <div className="flex items-center gap-3 text-gray-700">
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
<div className="mt-5 md:mt-6 flex items-center justify-between gap-4">
<div>
<span className="text-2xl md:text-3xl font-bold text-primary-dark">
{nextEvent.price === 0
? t('events.details.free')
: formatPrice(nextEvent.price, nextEvent.currency)}
</span> </span>
{!nextEvent.externalBookingEnabled && nextEvent.availableSeats != null && ( <span>{fmtTime(nextEvent.startDatetime)}</span>
<p className="text-xs text-gray-500 mt-0.5"> </div>
{nextEvent.availableSeats} {t('events.details.spotsLeft')} <div className="flex items-center gap-3 text-gray-700">
</p> <MapPinIcon className="w-5 h-5 text-primary-yellow" />
)} <span>{nextEvent.location}</span>
</div> </div>
<span className="inline-flex items-center bg-primary-yellow text-primary-dark font-semibold py-2.5 px-5 rounded-xl text-sm transition-all duration-200 group-hover:bg-yellow-400 flex-shrink-0">
{t('common.moreInfo')}
</span>
</div> </div>
</div> </div>
<div className="flex flex-col justify-between items-start md:items-end">
<div className="text-right">
<span className="text-3xl font-bold text-primary-dark">
{nextEvent.price === 0
? t('events.details.free')
: formatPrice(nextEvent.price, nextEvent.currency)}
</span>
{!nextEvent.externalBookingEnabled && (
<p className="text-sm text-gray-500 mt-1">
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
</p>
)}
</div>
<Button size="lg" className="mt-6">
{t('common.moreInfo')}
</Button>
</div>
</div> </div>
</div> </Card>
</Link> </Link>
); );
} }

View File

@@ -17,7 +17,7 @@ export default function NextEventSectionWrapper({ initialEvent }: NextEventSecti
<h2 className="section-title text-center"> <h2 className="section-title text-center">
{t('home.nextEvent.title')} {t('home.nextEvent.title')}
</h2> </h2>
<div className="mt-12 max-w-4xl mx-auto"> <div className="mt-12 max-w-3xl mx-auto">
<NextEventSection initialEvent={initialEvent} /> <NextEventSection initialEvent={initialEvent} />
</div> </div>
</div> </div>

View File

@@ -60,7 +60,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const isCancelled = event.status === 'cancelled'; const isCancelled = event.status === 'cancelled';
// Only calculate isPastEvent after mount to avoid hydration mismatch // Only calculate isPastEvent after mount to avoid hydration mismatch
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false; const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted'); const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
// Booking card content - reused for mobile and desktop positions // Booking card content - reused for mobile and desktop positions
const BookingCardContent = () => ( const BookingCardContent = () => (

View File

@@ -20,7 +20,7 @@ interface Event {
price: number; price: number;
currency: string; currency: string;
capacity: number; capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string; bannerUrl?: string;
availableSeats?: number; availableSeats?: number;
bookedCount?: number; bookedCount?: number;

View File

@@ -38,10 +38,8 @@ interface NextEvent {
async function getNextUpcomingEvent(): Promise<NextEvent | null> { async function getNextUpcomingEvent(): Promise<NextEvent | null> {
try { try {
const revalidateSeconds =
parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600;
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, { const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
next: { tags: ['next-event'], revalidate: revalidateSeconds }, next: { tags: ['next-event'] },
}); });
if (!response.ok) return null; if (!response.ok) return null;
const data = await response.json(); const data = await response.json();

View File

@@ -5,7 +5,6 @@ import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api'; import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { import {
TicketIcon, TicketIcon,
CheckCircleIcon, CheckCircleIcon,
@@ -15,10 +14,8 @@ import {
EnvelopeIcon, EnvelopeIcon,
PhoneIcon, PhoneIcon,
FunnelIcon, FunnelIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx';
interface TicketWithDetails extends Omit<Ticket, 'payment'> { interface TicketWithDetails extends Omit<Ticket, 'payment'> {
bookingId?: string; bookingId?: string;
@@ -43,11 +40,10 @@ export default function AdminBookingsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState<string | null>(null); const [processing, setProcessing] = useState<string | null>(null);
// Filters
const [selectedEvent, setSelectedEvent] = useState<string>(''); const [selectedEvent, setSelectedEvent] = useState<string>('');
const [selectedStatus, setSelectedStatus] = useState<string>(''); const [selectedStatus, setSelectedStatus] = useState<string>('');
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>(''); const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -60,12 +56,19 @@ export default function AdminBookingsPage() {
eventsApi.getAll(), eventsApi.getAll(),
]); ]);
const ticketsWithEvent = ticketsRes.tickets.map((ticket) => ({ // Fetch full ticket details with payment info
...ticket, const ticketsWithDetails = await Promise.all(
event: eventsRes.events.find((e) => e.id === ticket.eventId), ticketsRes.tickets.map(async (ticket) => {
})); try {
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id);
return fullTicket;
} catch {
return ticket;
}
})
);
setTickets(ticketsWithEvent); setTickets(ticketsWithDetails);
setEvents(eventsRes.events); setEvents(eventsRes.events);
} catch (error) { } catch (error) {
toast.error('Failed to load bookings'); toast.error('Failed to load bookings');
@@ -128,62 +131,62 @@ export default function AdminBookingsPage() {
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'confirmed': return 'bg-green-100 text-green-800'; case 'confirmed':
case 'pending': return 'bg-yellow-100 text-yellow-800'; return 'bg-green-100 text-green-800';
case 'cancelled': return 'bg-red-100 text-red-800'; case 'pending':
case 'checked_in': return 'bg-blue-100 text-blue-800'; return 'bg-yellow-100 text-yellow-800';
default: return 'bg-gray-100 text-gray-800'; case 'cancelled':
return 'bg-red-100 text-red-800';
case 'checked_in':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
} }
}; };
const getPaymentStatusColor = (status: string) => { const getPaymentStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'paid': return 'bg-green-100 text-green-800'; case 'paid':
case 'pending': return 'bg-yellow-100 text-yellow-800'; return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed': case 'failed':
case 'cancelled': return 'bg-red-100 text-red-800'; case 'cancelled':
case 'refunded': return 'bg-purple-100 text-purple-800'; return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800'; case 'refunded':
return 'bg-purple-100 text-purple-800';
default:
return 'bg-gray-100 text-gray-800';
} }
}; };
const getPaymentMethodLabel = (provider: string | null) => { const getPaymentMethodLabel = (provider: string) => {
if (provider == null) return '—'; switch (provider) {
const labels: Record<string, string> = { case 'bancard':
cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event', return 'TPago / Card';
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer', case 'lightning':
lightning: 'Lightning', return 'Bitcoin Lightning';
tpago: 'TPago', case 'cash':
bancard: 'Bancard', return 'Cash at Event';
}; default:
return labels[provider] || provider; return provider;
};
const getDisplayProvider = (ticket: TicketWithDetails): string | null => {
if (ticket.payment?.provider) return ticket.payment.provider;
if (ticket.bookingId) {
const sibling = tickets.find((t) => t.bookingId === ticket.bookingId && t.payment?.provider);
return sibling?.payment?.provider ?? null;
} }
return null;
}; };
// Filter tickets
const filteredTickets = tickets.filter((ticket) => { const filteredTickets = tickets.filter((ticket) => {
if (selectedEvent && ticket.eventId !== selectedEvent) return false; if (selectedEvent && ticket.eventId !== selectedEvent) return false;
if (selectedStatus && ticket.status !== selectedStatus) return false; if (selectedStatus && ticket.status !== selectedStatus) return false;
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false; if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
const name = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.toLowerCase();
return name.includes(q) || (ticket.attendeeEmail?.toLowerCase().includes(q) || false);
}
return true; return true;
}); });
// Sort by created date (newest first)
const sortedTickets = [...filteredTickets].sort( const sortedTickets = [...filteredTickets].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
); );
// Stats
const stats = { const stats = {
total: tickets.length, total: tickets.length,
pending: tickets.filter(t => t.status === 'pending').length, pending: tickets.filter(t => t.status === 'pending').length,
@@ -193,36 +196,23 @@ export default function AdminBookingsPage() {
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length, pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
}; };
// Helper to get booking info for a ticket (ticket count and total)
const getBookingInfo = (ticket: TicketWithDetails) => { const getBookingInfo = (ticket: TicketWithDetails) => {
if (!ticket.bookingId) { if (!ticket.bookingId) {
return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) }; return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) };
} }
const bookingTickets = tickets.filter(t => t.bookingId === ticket.bookingId);
// Count all tickets with the same bookingId
const bookingTickets = tickets.filter(
t => t.bookingId === ticket.bookingId
);
return { return {
ticketCount: bookingTickets.length, ticketCount: bookingTickets.length,
bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 0), bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 0),
}; };
}; };
const hasActiveFilters = selectedEvent || selectedStatus || selectedPaymentStatus || searchQuery;
const clearFilters = () => {
setSelectedEvent('');
setSelectedStatus('');
setSelectedPaymentStatus('');
setSearchQuery('');
};
const getPrimaryAction = (ticket: TicketWithDetails) => {
if (ticket.status === 'pending' && ticket.payment?.status === 'pending') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
}
if (ticket.status === 'confirmed') {
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' };
}
return null;
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -234,61 +224,51 @@ export default function AdminBookingsPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">Manage Bookings</h1> <h1 className="text-2xl font-bold text-primary-dark">Manage Bookings</h1>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-6 gap-2 md:gap-4 mb-6"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<Card className="p-3 md:p-4 text-center"> <Card className="p-4 text-center">
<p className="text-xl md:text-2xl font-bold text-primary-dark">{stats.total}</p> <p className="text-2xl font-bold text-primary-dark">{stats.total}</p>
<p className="text-xs md:text-sm text-gray-500">Total</p> <p className="text-sm text-gray-500">Total</p>
</Card> </Card>
<Card className="p-3 md:p-4 text-center border-l-4 border-yellow-400"> <Card className="p-4 text-center border-l-4 border-yellow-400">
<p className="text-xl md:text-2xl font-bold text-yellow-600">{stats.pending}</p> <p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
<p className="text-xs md:text-sm text-gray-500">Pending</p> <p className="text-sm text-gray-500">Pending</p>
</Card> </Card>
<Card className="p-3 md:p-4 text-center border-l-4 border-green-400"> <Card className="p-4 text-center border-l-4 border-green-400">
<p className="text-xl md:text-2xl font-bold text-green-600">{stats.confirmed}</p> <p className="text-2xl font-bold text-green-600">{stats.confirmed}</p>
<p className="text-xs md:text-sm text-gray-500">Confirmed</p> <p className="text-sm text-gray-500">Confirmed</p>
</Card> </Card>
<Card className="p-3 md:p-4 text-center border-l-4 border-blue-400"> <Card className="p-4 text-center border-l-4 border-blue-400">
<p className="text-xl md:text-2xl font-bold text-blue-600">{stats.checkedIn}</p> <p className="text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
<p className="text-xs md:text-sm text-gray-500">Checked In</p> <p className="text-sm text-gray-500">Checked In</p>
</Card> </Card>
<Card className="p-3 md:p-4 text-center border-l-4 border-red-400"> <Card className="p-4 text-center border-l-4 border-red-400">
<p className="text-xl md:text-2xl font-bold text-red-600">{stats.cancelled}</p> <p className="text-2xl font-bold text-red-600">{stats.cancelled}</p>
<p className="text-xs md:text-sm text-gray-500">Cancelled</p> <p className="text-sm text-gray-500">Cancelled</p>
</Card> </Card>
<Card className="p-3 md:p-4 text-center border-l-4 border-orange-400"> <Card className="p-4 text-center border-l-4 border-orange-400">
<p className="text-xl md:text-2xl font-bold text-orange-600">{stats.pendingPayment}</p> <p className="text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
<p className="text-xs md:text-sm text-gray-500">Pending Pay</p> <p className="text-sm text-gray-500">Pending Payment</p>
</Card> </Card>
</div> </div>
{/* Desktop Filters */} {/* Filters */}
<Card className="p-4 mb-6 hidden md:block"> <Card className="p-4 mb-6">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<FunnelIcon className="w-5 h-5 text-gray-500" /> <FunnelIcon className="w-5 h-5 text-gray-500" />
<span className="font-medium">Filters</span> <span className="font-medium">Filters</span>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label> <label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)} <select
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm"> value={selectedEvent}
onChange={(e) => setSelectedEvent(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Events</option> <option value="">All Events</option>
{events.map((event) => ( {events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option> <option key={event.id} value={event.id}>{event.title}</option>
@@ -297,8 +277,11 @@ export default function AdminBookingsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label> <label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
<select value={selectedStatus} onChange={(e) => setSelectedStatus(e.target.value)} <select
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm"> value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="confirmed">Confirmed</option> <option value="confirmed">Confirmed</option>
@@ -308,9 +291,12 @@ export default function AdminBookingsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label> <label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
<select value={selectedPaymentStatus} onChange={(e) => setSelectedPaymentStatus(e.target.value)} <select
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm"> value={selectedPaymentStatus}
<option value="">All Payments</option> onChange={(e) => setSelectedPaymentStatus(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Payment Statuses</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="paid">Paid</option> <option value="paid">Paid</option>
<option value="refunded">Refunded</option> <option value="refunded">Refunded</option>
@@ -318,66 +304,26 @@ export default function AdminBookingsPage() {
</select> </select>
</div> </div>
</div> </div>
{hasActiveFilters && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span>Showing {sortedTickets.length} of {tickets.length}</span>
<button onClick={clearFilters} className="text-primary-yellow hover:underline">Clear</button>
</div>
)}
</Card> </Card>
{/* Mobile Toolbar */} {/* Bookings List */}
<div className="md:hidden space-y-2 mb-4"> <Card className="overflow-hidden">
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
hasActiveFilters
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
: 'border-secondary-light-gray text-gray-600'
)}
>
<FunnelIcon className="w-4 h-4" />
Filters
{hasActiveFilters && <span className="text-xs">({sortedTickets.length})</span>}
</button>
{hasActiveFilters && (
<button onClick={clearFilters} className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center">
Clear
</button>
)}
</div>
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Payment</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Payment</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{sortedTickets.length === 0 ? ( {sortedTickets.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm"> <td colSpan={6} className="px-6 py-12 text-center text-gray-500">
No bookings found. No bookings found.
</td> </td>
</tr> </tr>
@@ -385,69 +331,123 @@ export default function AdminBookingsPage() {
sortedTickets.map((ticket) => { sortedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket); const bookingInfo = getBookingInfo(ticket);
return ( return (
<tr key={ticket.id} className="hover:bg-gray-50"> <tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-4 py-3"> <td className="px-6 py-4">
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p> <div className="space-y-1">
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p> <div className="flex items-center gap-2">
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>} <UserIcon className="w-4 h-4 text-gray-400" />
</td> <span className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</span>
<td className="px-4 py-3"> </div>
<span className="text-sm truncate max-w-[150px] block"> <div className="flex items-center gap-2 text-sm text-gray-500">
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'} <EnvelopeIcon className="w-4 h-4" />
</span> <span>{ticket.attendeeEmail || 'N/A'}</span>
</td> </div>
<td className="px-4 py-3"> <div className="flex items-center gap-2 text-sm text-gray-500">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}> <PhoneIcon className="w-4 h-4" />
<span>{ticket.attendeePhone || 'N/A'}</span>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className="text-sm">
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
</span>
</td>
<td className="px-6 py-4">
<div className="space-y-1">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
{ticket.payment?.status || 'pending'} {ticket.payment?.status || 'pending'}
</span> </span>
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(getDisplayProvider(ticket))}</p> <p className="text-sm text-gray-500">
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
</p>
{ticket.payment && ( {ticket.payment && (
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p> <div>
<p className="text-sm font-medium">
{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}
</p>
{bookingInfo.ticketCount > 1 && (
<p className="text-xs text-purple-600 mt-1">
📦 {bookingInfo.ticketCount} × {Number(ticket.payment.amount).toLocaleString()} {ticket.payment.currency}
</p>
)}
</div>
)} )}
</td> </div>
<td className="px-4 py-3"> </td>
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}> <td className="px-6 py-4">
{ticket.status.replace('_', ' ')} <span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
</span> {ticket.status}
{ticket.bookingId && ( </span>
<p className="text-[10px] text-purple-600 mt-0.5">Group Booking</p> {ticket.qrCode && (
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
)}
{ticket.bookingId && (
<p className="text-xs text-purple-600 mt-1" title="Part of multi-ticket booking">
📦 Group Booking
</p>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(ticket.createdAt)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{/* Mark as Paid (for pending payments) */}
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleMarkPaid(ticket.id)}
isLoading={processing === ticket.id}
className="text-green-600 hover:bg-green-50"
>
<CurrencyDollarIcon className="w-4 h-4 mr-1" />
Mark Paid
</Button>
)} )}
</td>
<td className="px-4 py-3 text-xs text-gray-500"> {/* Check-in (for confirmed tickets) */}
{formatDate(ticket.createdAt)} {ticket.status === 'confirmed' && (
</td> <Button
<td className="px-4 py-3"> size="sm"
<div className="flex items-center justify-end gap-1"> variant="ghost"
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && ( onClick={() => handleCheckin(ticket.id)}
<Button size="sm" variant="outline" onClick={() => handleMarkPaid(ticket.id)} isLoading={processing === ticket.id}
isLoading={processing === ticket.id} className="text-xs px-2 py-1"> className="text-blue-600 hover:bg-blue-50"
Mark Paid >
</Button> <CheckCircleIcon className="w-4 h-4 mr-1" />
)} Check In
{ticket.status === 'confirmed' && ( </Button>
<Button size="sm" onClick={() => handleCheckin(ticket.id)} )}
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
Check In {/* Cancel (for pending/confirmed) */}
</Button> {(ticket.status === 'pending' || ticket.status === 'confirmed') && (
)} <Button
{(ticket.status === 'pending' || ticket.status === 'confirmed') && ( size="sm"
<MoreMenu> variant="ghost"
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600"> onClick={() => handleCancel(ticket.id)}
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel isLoading={processing === ticket.id}
</DropdownItem> className="text-red-600 hover:bg-red-50"
</MoreMenu> >
)} <XCircleIcon className="w-4 h-4 mr-1" />
{ticket.status === 'checked_in' && ( Cancel
<span className="text-xs text-green-600 flex items-center gap-1"> </Button>
<CheckCircleIcon className="w-4 h-4" /> Attended )}
</span>
)} {ticket.status === 'checked_in' && (
{ticket.status === 'cancelled' && ( <span className="text-sm text-green-600 flex items-center gap-1">
<span className="text-xs text-gray-400">Cancelled</span> <CheckCircleIcon className="w-4 h-4" />
)} Attended
</div> </span>
</td> )}
</tr>
{ticket.status === 'cancelled' && (
<span className="text-sm text-gray-400">Cancelled</span>
)}
</div>
</td>
</tr>
); );
}) })
)} )}
@@ -455,158 +455,6 @@ export default function AdminBookingsPage() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{sortedTickets.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">
No bookings found.
</div>
) : (
sortedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket);
const primary = getPrimaryAction(ticket);
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
return (
<Card key={ticket.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail || 'N/A'}</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={clsx('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', getStatusColor(ticket.status))}>
{ticket.status.replace('_', ' ')}
</span>
</div>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span className="truncate">{eventTitle}</span>
<span className="text-gray-300">|</span>
<span className={clsx('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', getPaymentStatusColor(ticket.payment?.status || 'pending'))}>
{ticket.payment?.status || 'pending'}
</span>
{ticket.payment && (
<>
<span className="text-gray-300">|</span>
<span className="font-medium text-gray-700">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</span>
</>
)}
</div>
{ticket.bookingId && (
<p className="text-[10px] text-purple-600 mt-1">{bookingInfo.ticketCount} tickets - Group Booking</p>
)}
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">{formatDate(ticket.createdAt)}</p>
<div className="flex items-center gap-1">
{primary && (
<Button size="sm" variant={ticket.status === 'confirmed' ? 'primary' : 'outline'}
onClick={primary.onClick} isLoading={processing === ticket.id}
className="text-xs px-2.5 py-1.5 min-h-[36px]">
{primary.label}
</Button>
)}
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
<MoreMenu>
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && !primary && (
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
<CurrencyDollarIcon className="w-4 h-4 mr-2" /> Mark Paid
</DropdownItem>
)}
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel Booking
</DropdownItem>
</MoreMenu>
)}
{ticket.status === 'checked_in' && (
<span className="text-[10px] text-green-600 flex items-center gap-1">
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
</span>
)}
{ticket.status === 'cancelled' && (
<span className="text-[10px] text-gray-400">Cancelled</span>
)}
</div>
</div>
</Card>
);
})
)}
</div>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
<div className="space-y-1">
{[
{ value: '', label: 'All Statuses' },
{ value: 'pending', label: `Pending (${stats.pending})` },
{ value: 'confirmed', label: `Confirmed (${stats.confirmed})` },
{ value: 'checked_in', label: `Checked In (${stats.checkedIn})` },
{ value: 'cancelled', label: `Cancelled (${stats.cancelled})` },
].map((opt) => (
<button
key={opt.value}
onClick={() => setSelectedStatus(opt.value)}
className={clsx(
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
selectedStatus === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}
>
{opt.label}
{selectedStatus === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
<div className="space-y-1">
{[
{ value: '', label: 'All Payments' },
{ value: 'pending', label: 'Pending' },
{ value: 'paid', label: 'Paid' },
{ value: 'refunded', label: 'Refunded' },
{ value: 'failed', label: 'Failed' },
].map((opt) => (
<button
key={opt.value}
onClick={() => setSelectedPaymentStatus(opt.value)}
className={clsx(
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
selectedPaymentStatus === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}
>
{opt.label}
{selectedPaymentStatus === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => { clearFilters(); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">
Clear All
</Button>
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">
Apply
</Button>
</div>
</div>
</BottomSheet>
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -6,7 +6,6 @@ import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { import {
EnvelopeIcon, EnvelopeIcon,
PencilIcon, PencilIcon,
@@ -19,8 +18,6 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
XMarkIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -53,8 +50,6 @@ export default function AdminEmailsPage() {
const [logs, setLogs] = useState<EmailLog[]>([]); const [logs, setLogs] = useState<EmailLog[]>([]);
const [logsOffset, setLogsOffset] = useState(0); const [logsOffset, setLogsOffset] = useState(0);
const [logsTotal, setLogsTotal] = useState(0); const [logsTotal, setLogsTotal] = useState(0);
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
const [resendingLogId, setResendingLogId] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null); const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
// Stats state // Stats state
@@ -194,6 +189,7 @@ export default function AdminEmailsPage() {
return; return;
} }
setSending(true);
try { try {
const res = await emailsApi.sendToEvent(composeForm.eventId, { const res = await emailsApi.sendToEvent(composeForm.eventId, {
templateSlug: composeForm.templateSlug, templateSlug: composeForm.templateSlug,
@@ -201,15 +197,20 @@ export default function AdminEmailsPage() {
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined, customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
}); });
if (res.success) { if (res.success || res.sentCount > 0) {
toast.success(`${res.queuedCount} email(s) are being sent in the background.`); toast.success(`Sent ${res.sentCount} emails successfully`);
if (res.failedCount > 0) {
toast.error(`${res.failedCount} emails failed`);
}
clearDraft(); clearDraft();
setShowRecipientPreview(false); setShowRecipientPreview(false);
} else { } else {
toast.error(res.error || 'Failed to queue emails'); toast.error('Failed to send emails');
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Failed to send emails'); toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
} }
}; };
@@ -217,7 +218,7 @@ export default function AdminEmailsPage() {
if (activeTab === 'logs') { if (activeTab === 'logs') {
loadLogs(); loadLogs();
} }
}, [activeTab, logsOffset, logsSubTab]); }, [activeTab, logsOffset]);
const loadData = async () => { const loadData = async () => {
try { try {
@@ -236,11 +237,7 @@ export default function AdminEmailsPage() {
const loadLogs = async () => { const loadLogs = async () => {
try { try {
const res = await emailsApi.getLogs({ const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset });
limit: 20,
offset: logsOffset,
...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
});
setLogs(res.logs); setLogs(res.logs);
setLogsTotal(res.pagination.total); setLogsTotal(res.pagination.total);
} catch (error) { } catch (error) {
@@ -248,27 +245,6 @@ export default function AdminEmailsPage() {
} }
}; };
const handleResend = async (log: EmailLog) => {
setResendingLogId(log.id);
try {
const res = await emailsApi.resendLog(log.id);
if (res.success) {
toast.success('Email re-sent successfully');
} else {
toast.error(res.error || 'Failed to re-send email');
}
await loadLogs();
if (selectedLog?.id === log.id) {
const { log: updatedLog } = await emailsApi.getLog(log.id);
setSelectedLog(updatedLog);
}
} catch (error: any) {
toast.error(error.message || 'Failed to re-send email');
} finally {
setResendingLogId(null);
}
};
const resetTemplateForm = () => { const resetTemplateForm = () => {
setTemplateForm({ setTemplateForm({
name: '', name: '',
@@ -412,7 +388,7 @@ export default function AdminEmailsPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">Email Center</h1> <h1 className="text-2xl font-bold text-primary-dark">Email Center</h1>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
@@ -466,15 +442,18 @@ export default function AdminEmailsPage() {
)} )}
{/* Tabs */} {/* Tabs */}
<div className="border-b border-secondary-light-gray mb-6 overflow-x-auto scrollbar-hide"> <div className="border-b border-secondary-light-gray mb-6">
<nav className="flex gap-4 md:gap-6 min-w-max"> <nav className="flex gap-6">
{(['templates', 'compose', 'logs'] as TabType[]).map((tab) => ( {(['templates', 'compose', 'logs'] as TabType[]).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className={clsx( className={clsx(
'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative whitespace-nowrap min-h-[44px]', 'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative',
activeTab === tab ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700' {
'border-primary-yellow text-primary-dark': activeTab === tab,
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab,
}
)} )}
> >
{tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'} {tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'}
@@ -526,35 +505,30 @@ export default function AdminEmailsPage() {
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-2">
<button onClick={() => handlePreviewTemplate(template)} <button
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" title="Preview"> onClick={() => handlePreviewTemplate(template)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="Preview"
>
<EyeIcon className="w-5 h-5" /> <EyeIcon className="w-5 h-5" />
</button> </button>
<button onClick={() => handleEditTemplate(template)} <button
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center hidden md:flex" title="Edit"> onClick={() => handleEditTemplate(template)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="Edit"
>
<PencilIcon className="w-5 h-5" /> <PencilIcon className="w-5 h-5" />
</button> </button>
<div className="hidden md:block"> {!template.isSystem && (
{!template.isSystem && ( <button
<button onClick={() => handleDeleteTemplate(template.id)} onClick={() => handleDeleteTemplate(template.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" title="Delete"> className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
<XCircleIcon className="w-5 h-5" /> title="Delete"
</button> >
)} <XCircleIcon className="w-5 h-5" />
</div> </button>
<div className="md:hidden"> )}
<MoreMenu>
<DropdownItem onClick={() => handleEditTemplate(template)}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit
</DropdownItem>
{!template.isSystem && (
<DropdownItem onClick={() => handleDeleteTemplate(template.id)} className="text-red-600">
<XCircleIcon className="w-4 h-4 mr-2" /> Delete
</DropdownItem>
)}
</MoreMenu>
</div>
</div> </div>
</div> </div>
</Card> </Card>
@@ -596,7 +570,7 @@ export default function AdminEmailsPage() {
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
> >
<option value="">Choose an event</option> <option value="">Choose an event</option>
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => ( {events.filter(e => e.status === 'published').map((event) => (
<option key={event.id} value={event.id}> <option key={event.id} value={event.id}>
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })} {event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
</option> </option>
@@ -667,17 +641,13 @@ export default function AdminEmailsPage() {
{/* Recipient Preview Modal */} {/* Recipient Preview Modal */}
{showRecipientPreview && ( {showRecipientPreview && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full md:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card"> <Card className="w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray"> <div className="p-4 border-b border-secondary-light-gray">
<div> <h2 className="text-lg font-bold">Recipient Preview</h2>
<h2 className="text-base font-bold">Recipient Preview</h2> <p className="text-sm text-gray-500">
<p className="text-xs text-gray-500">{previewRecipients.length} recipient(s)</p> {previewRecipients.length} recipient(s) will receive this email
</div> </p>
<button onClick={() => setShowRecipientPreview(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
@@ -711,10 +681,14 @@ export default function AdminEmailsPage() {
</div> </div>
<div className="p-4 border-t border-secondary-light-gray flex gap-3"> <div className="p-4 border-t border-secondary-light-gray flex gap-3">
<Button onClick={handleSendEmail} isLoading={sending} disabled={previewRecipients.length === 0} className="flex-1 min-h-[44px]"> <Button
Send to {previewRecipients.length} onClick={handleSendEmail}
isLoading={sending}
disabled={previewRecipients.length === 0}
>
Send to {previewRecipients.length} Recipients
</Button> </Button>
<Button variant="outline" onClick={() => setShowRecipientPreview(false)} className="flex-1 min-h-[44px]"> <Button variant="outline" onClick={() => setShowRecipientPreview(false)}>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -727,79 +701,51 @@ export default function AdminEmailsPage() {
{/* Logs Tab */} {/* Logs Tab */}
{activeTab === 'logs' && ( {activeTab === 'logs' && (
<div> <div>
{/* Sub-tabs: All | Failed */} <Card className="overflow-hidden">
<div className="border-b border-secondary-light-gray mb-4">
<nav className="flex gap-4">
<button
onClick={() => { setLogsSubTab('all'); setLogsOffset(0); }}
className={clsx(
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
logsSubTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
All
</button>
<button
onClick={() => { setLogsSubTab('failed'); setLogsOffset(0); }}
className={clsx(
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
logsSubTab === 'failed' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
Failed
{stats && stats.failed > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700">
{stats.failed}
</span>
)}
</button>
</nav>
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Recipient</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Recipient</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Subject</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Subject</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Sent</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Sent</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{logs.length === 0 ? ( {logs.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr> <tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No emails sent yet
</td>
</tr>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50"> <tr key={log.id} className="hover:bg-gray-50">
<td className="px-4 py-3"> <td className="px-6 py-4">
<div className="flex flex-col gap-1"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div> {getStatusIcon(log.status)}
{(log.resendAttempts ?? 0) > 0 && ( <span className="capitalize text-sm">{log.status}</span>
<span className="text-xs text-gray-500">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</span>
)}
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-6 py-4">
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p> <p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
<p className="text-xs text-gray-500">{log.recipientEmail}</p> <p className="text-sm text-gray-500">{log.recipientEmail}</p>
</td> </td>
<td className="px-4 py-3 max-w-xs"><p className="text-sm truncate">{log.subject}</p></td> <td className="px-6 py-4 max-w-xs">
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td> <p className="text-sm truncate">{log.subject}</p>
<td className="px-4 py-3"> </td>
<div className="flex items-center justify-end gap-1"> <td className="px-6 py-4 text-sm text-gray-600">
{formatDate(log.sentAt || log.createdAt)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
<button <button
onClick={() => handleResend(log)} onClick={() => setSelectedLog(log)}
disabled={resendingLogId === log.id} className="p-2 hover:bg-gray-100 rounded-btn"
className="p-2 hover:bg-gray-100 rounded-btn disabled:opacity-50" title="View Email"
title="Re-send"
> >
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === log.id && 'animate-spin')} />
</button>
<button onClick={() => setSelectedLog(log)} className="p-2 hover:bg-gray-100 rounded-btn" title="View">
<EyeIcon className="w-4 h-4" /> <EyeIcon className="w-4 h-4" />
</button> </button>
</div> </div>
@@ -810,80 +756,46 @@ export default function AdminEmailsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination */}
{logsTotal > 20 && ( {logsTotal > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-light-gray"> <div className="flex items-center justify-between px-6 py-4 border-t border-secondary-light-gray">
<p className="text-sm text-gray-600">Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}</p> <p className="text-sm text-gray-600">
Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" disabled={logsOffset === 0} onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}> <Button
variant="outline"
size="sm"
disabled={logsOffset === 0}
onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}
>
<ChevronLeftIcon className="w-4 h-4" /> <ChevronLeftIcon className="w-4 h-4" />
</Button> </Button>
<Button variant="outline" size="sm" disabled={logsOffset + 20 >= logsTotal} onClick={() => setLogsOffset(logsOffset + 20)}> <Button
variant="outline"
size="sm"
disabled={logsOffset + 20 >= logsTotal}
onClick={() => setLogsOffset(logsOffset + 20)}
>
<ChevronRightIcon className="w-4 h-4" /> <ChevronRightIcon className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div> </div>
)} )}
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{logs.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
) : (
logs.map((log) => (
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(log.status)}</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{log.subject}</p>
<p className="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} &lt;{log.recipientEmail}&gt;</p>
<p className="text-[10px] text-gray-400 mt-1">{formatDate(log.sentAt || log.createdAt)}</p>
{(log.resendAttempts ?? 0) > 0 && (
<p className="text-[10px] text-gray-500 mt-0.5">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</p>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); handleResend(log); }}
disabled={resendingLogId === log.id}
className="p-2 hover:bg-gray-100 rounded-btn flex-shrink-0 disabled:opacity-50"
title="Re-send"
>
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === log.id && 'animate-spin')} />
</button>
</div>
</Card>
))
)}
{logsTotal > 20 && (
<div className="flex items-center justify-between py-3">
<p className="text-xs text-gray-500">{logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={logsOffset === 0} onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))} className="min-h-[44px]">
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" disabled={logsOffset + 20 >= logsTotal} onClick={() => setLogsOffset(logsOffset + 20)} className="min-h-[44px]">
<ChevronRightIcon className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
</div> </div>
)} )}
{/* Template Form Modal */} {/* Template Form Modal */}
{showTemplateForm && ( {showTemplateForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full md:max-w-4xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"> <Card className="w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0"> <h2 className="text-xl font-bold mb-6">
<h2 className="text-base font-bold">{editingTemplate ? 'Edit Template' : 'Create Template'}</h2> {editingTemplate ? 'Edit Template' : 'Create Template'}
<button onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }} </h2>
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSaveTemplate} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0"> <form onSubmit={handleSaveTemplate} className="space-y-4">
<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
label="Template Name" label="Template Name"
@@ -967,10 +879,14 @@ export default function AdminEmailsPage() {
</div> </div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]"> <Button type="submit" isLoading={saving}>
{editingTemplate ? 'Update' : 'Create'} {editingTemplate ? 'Update Template' : 'Create Template'}
</Button> </Button>
<Button type="button" variant="outline" onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }} className="flex-1 min-h-[44px]"> <Button
type="button"
variant="outline"
onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -981,17 +897,16 @@ export default function AdminEmailsPage() {
{/* Preview Modal */} {/* Preview Modal */}
{previewHtml && ( {previewHtml && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full md:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card"> <Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray"> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<div className="min-w-0"> <div>
<h2 className="text-base font-bold">Email Preview</h2> <h2 className="text-lg font-bold">Email Preview</h2>
<p className="text-xs text-gray-500 truncate">Subject: {previewSubject}</p> <p className="text-sm text-gray-500">Subject: {previewSubject}</p>
</div> </div>
<button onClick={() => setPreviewHtml(null)} <Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)}>
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center flex-shrink-0"> Close
<XMarkIcon className="w-5 h-5" /> </Button>
</button>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<iframe <iframe
@@ -1005,40 +920,23 @@ export default function AdminEmailsPage() {
)} )}
{/* Log Detail Modal */} {/* Log Detail Modal */}
<AdminMobileStyles />
{selectedLog && ( {selectedLog && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full md:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card"> <Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray"> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<div className="min-w-0"> <div>
<h2 className="text-base font-bold">Email Details</h2> <h2 className="text-lg font-bold">Email Details</h2>
<div className="flex items-center gap-2 mt-1 flex-wrap"> <div className="flex items-center gap-2 mt-1">
{getStatusIcon(selectedLog.status)} {getStatusIcon(selectedLog.status)}
<span className="capitalize text-sm">{selectedLog.status}</span> <span className="capitalize text-sm">{selectedLog.status}</span>
{selectedLog.errorMessage && ( {selectedLog.errorMessage && (
<span className="text-xs text-red-500">- {selectedLog.errorMessage}</span> <span className="text-sm text-red-500">- {selectedLog.errorMessage}</span>
)}
{(selectedLog.resendAttempts ?? 0) > 0 && (
<span className="text-xs text-gray-500">Re-sent {selectedLog.resendAttempts} time{(selectedLog.resendAttempts ?? 0) !== 1 ? 's' : ''}{selectedLog.lastResentAt ? ` (${formatDate(selectedLog.lastResentAt)})` : ''}</span>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <Button variant="outline" size="sm" onClick={() => setSelectedLog(null)}>
<button Close
onClick={() => handleResend(selectedLog)} </Button>
disabled={resendingLogId === selectedLog.id}
className="p-2 hover:bg-gray-100 rounded-btn disabled:opacity-50 flex items-center gap-1.5 text-sm"
title="Re-send"
>
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === selectedLog.id && 'animate-spin')} />
Re-send
</button>
<button onClick={() => setSelectedLog(null)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
</div> </div>
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50"> <div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
<p><strong>To:</strong> {selectedLog.recipientName} &lt;{selectedLog.recipientEmail}&gt;</p> <p><strong>To:</strong> {selectedLog.recipientName} &lt;{selectedLog.recipientEmail}&gt;</p>

File diff suppressed because it is too large Load Diff

View File

@@ -2,23 +2,19 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, siteSettingsApi, Event } from '@/lib/api'; import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import MediaPicker from '@/components/MediaPicker'; import MediaPicker from '@/components/MediaPicker';
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon } from '@heroicons/react/24/outline';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, XMarkIcon, LinkIcon } from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
export default function AdminEventsPage() { export default function AdminEventsPage() {
const router = useRouter();
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const searchParams = useSearchParams();
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -41,7 +37,7 @@ export default function AdminEventsPage() {
price: number; price: number;
currency: string; currency: string;
capacity: number; capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl: string; bannerUrl: string;
externalBookingEnabled: boolean; externalBookingEnabled: boolean;
externalBookingUrl: string; externalBookingUrl: string;
@@ -70,14 +66,6 @@ export default function AdminEventsPage() {
loadFeaturedEvent(); loadFeaturedEvent();
}, []); }, []);
useEffect(() => {
const editId = searchParams.get('edit');
if (editId && events.length > 0) {
const event = events.find(e => e.id === editId);
if (event) handleEdit(event);
}
}, [searchParams, events]);
const loadEvents = async () => { const loadEvents = async () => {
try { try {
const { events } = await eventsApi.getAll(); const { events } = await eventsApi.getAll();
@@ -94,7 +82,7 @@ export default function AdminEventsPage() {
const { settings } = await siteSettingsApi.get(); const { settings } = await siteSettingsApi.get();
setFeaturedEventId(settings.featuredEventId || null); setFeaturedEventId(settings.featuredEventId || null);
} catch (error) { } catch (error) {
// Ignore - settings may not exist yet // Ignore error - settings may not exist yet
} }
}; };
@@ -113,15 +101,28 @@ export default function AdminEventsPage() {
const resetForm = () => { const resetForm = () => {
setFormData({ setFormData({
title: '', titleEs: '', description: '', descriptionEs: '', title: '',
shortDescription: '', shortDescriptionEs: '', titleEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '', description: '',
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const, descriptionEs: '',
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '', shortDescription: '',
shortDescriptionEs: '',
startDatetime: '',
endDatetime: '',
location: '',
locationUrl: '',
price: 0,
currency: 'PYG',
capacity: 50,
status: 'draft' as const,
bannerUrl: '',
externalBookingEnabled: false,
externalBookingUrl: '',
}); });
setEditingEvent(null); setEditingEvent(null);
}; };
// Convert ISO UTC string to local datetime-local format (YYYY-MM-DDTHH:MM)
const isoToLocalDatetime = (isoString: string): string => { const isoToLocalDatetime = (isoString: string): string => {
const date = new Date(isoString); const date = new Date(isoString);
const year = date.getFullYear(); const year = date.getFullYear();
@@ -134,14 +135,21 @@ export default function AdminEventsPage() {
const handleEdit = (event: Event) => { const handleEdit = (event: Event) => {
setFormData({ setFormData({
title: event.title, titleEs: event.titleEs || '', title: event.title,
description: event.description, descriptionEs: event.descriptionEs || '', titleEs: event.titleEs || '',
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '', description: event.description,
descriptionEs: event.descriptionEs || '',
shortDescription: event.shortDescription || '',
shortDescriptionEs: event.shortDescriptionEs || '',
startDatetime: isoToLocalDatetime(event.startDatetime), startDatetime: isoToLocalDatetime(event.startDatetime),
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '', endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
location: event.location, locationUrl: event.locationUrl || '', location: event.location,
price: event.price, currency: event.currency, capacity: event.capacity, locationUrl: event.locationUrl || '',
status: event.status, bannerUrl: event.bannerUrl || '', price: event.price,
currency: event.currency,
capacity: event.capacity,
status: event.status,
bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false, externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '', externalBookingUrl: event.externalBookingUrl || '',
}); });
@@ -152,7 +160,9 @@ export default function AdminEventsPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
try { try {
// Validate external booking URL if enabled
if (formData.externalBookingEnabled && !formData.externalBookingUrl) { if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
toast.error('External booking URL is required when external booking is enabled'); toast.error('External booking URL is required when external booking is enabled');
setSaving(false); setSaving(false);
@@ -163,18 +173,27 @@ export default function AdminEventsPage() {
setSaving(false); setSaving(false);
return; return;
} }
const eventData = { const eventData = {
title: formData.title, titleEs: formData.titleEs || undefined, title: formData.title,
description: formData.description, descriptionEs: formData.descriptionEs || undefined, titleEs: formData.titleEs || undefined,
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined, description: formData.description,
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, locationUrl: formData.locationUrl || undefined, location: formData.location,
price: formData.price, currency: formData.currency, capacity: formData.capacity, locationUrl: formData.locationUrl || undefined,
status: formData.status, bannerUrl: formData.bannerUrl || undefined, price: formData.price,
currency: formData.currency,
capacity: formData.capacity,
status: formData.status,
bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled, externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
}; };
if (editingEvent) { if (editingEvent) {
await eventsApi.update(editingEvent.id, eventData); await eventsApi.update(editingEvent.id, eventData);
toast.success('Event updated'); toast.success('Event updated');
@@ -182,6 +201,7 @@ export default function AdminEventsPage() {
await eventsApi.create(eventData); await eventsApi.create(eventData);
toast.success('Event created'); toast.success('Event created');
} }
setShowForm(false); setShowForm(false);
resetForm(); resetForm();
loadEvents(); loadEvents();
@@ -194,6 +214,7 @@ export default function AdminEventsPage() {
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this event?')) return; if (!confirm('Are you sure you want to delete this event?')) return;
try { try {
await eventsApi.delete(id); await eventsApi.delete(id);
toast.success('Event deleted'); toast.success('Event deleted');
@@ -213,21 +234,23 @@ export default function AdminEventsPage() {
} }
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion', month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'America/Asuncion',
}); });
}; };
const isEventOver = (event: Event) => {
const refDate = event.endDatetime || event.startDatetime;
return new Date(refDate) < new Date();
};
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
const styles: Record<string, string> = { const styles: Record<string, string> = {
draft: 'badge-gray', published: 'badge-success', unlisted: 'badge-warning', draft: 'badge-gray',
cancelled: 'badge-danger', completed: 'badge-info', archived: 'badge-gray', published: 'badge-success',
cancelled: 'badge-danger',
completed: 'badge-info',
archived: 'badge-gray',
}; };
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>; return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
}; };
@@ -263,8 +286,8 @@ export default function AdminEventsPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1> <h1 className="text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
<Button onClick={() => { resetForm(); setShowForm(true); }} className="hidden md:flex"> <Button onClick={() => { resetForm(); setShowForm(true); }}>
<PlusIcon className="w-5 h-5 mr-2" /> <PlusIcon className="w-5 h-5 mr-2" />
{t('admin.events.create')} {t('admin.events.create')}
</Button> </Button>
@@ -272,148 +295,221 @@ export default function AdminEventsPage() {
{/* Event Form Modal */} {/* Event Form Modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"> <Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
<div className="flex items-center justify-between p-4 md:p-6 border-b border-secondary-light-gray flex-shrink-0"> <h2 className="text-xl font-bold mb-6">
<h2 className="text-lg md:text-xl font-bold"> {editingEvent ? t('admin.events.edit') : t('admin.events.create')}
{editingEvent ? t('admin.events.edit') : t('admin.events.create')} </h2>
</h2>
<button onClick={() => { setShowForm(false); resetForm(); }}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 md:p-6 space-y-4 overflow-y-auto flex-1 min-h-0"> <form onSubmit={handleSubmit} className="space-y-4">
<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 label="Title (English)" value={formData.title} <Input
onChange={(e) => setFormData({ ...formData, title: e.target.value })} required /> label="Title (English)"
<Input label="Title (Spanish)" value={formData.titleEs} value={formData.title}
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} /> onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
<Input
label="Title (Spanish)"
value={formData.titleEs}
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Description (English)</label> <label className="block text-sm font-medium mb-1">Description (English)</label>
<textarea value={formData.description} <textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: 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" 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={3} required /> rows={3}
required
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Description (Spanish)</label> <label className="block text-sm font-medium mb-1">Description (Spanish)</label>
<textarea value={formData.descriptionEs} <textarea
value={formData.descriptionEs}
onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })} onChange={(e) => setFormData({ ...formData, descriptionEs: 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" 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={3} /> rows={3}
/>
</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">
<div> <div>
<label className="block text-sm font-medium mb-1">Short Description (English)</label> <label className="block text-sm font-medium mb-1">Short Description (English)</label>
<textarea value={formData.shortDescription} <textarea
value={formData.shortDescription}
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })} 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" 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)" /> rows={2}
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300</p> 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>
<div> <div>
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label> <label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
<textarea value={formData.shortDescriptionEs} <textarea
value={formData.shortDescriptionEs}
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })} 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" 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 (máx 300 caracteres)" /> rows={2}
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300</p> 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> </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 label="Start Date & Time" type="datetime-local" value={formData.startDatetime} <Input
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })} required /> label="Start Date & Time"
<Input label="End Date & Time" type="datetime-local" value={formData.endDatetime} type="datetime-local"
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })} /> value={formData.startDatetime}
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })}
required
/>
<Input
label="End Date & Time"
type="datetime-local"
value={formData.endDatetime}
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })}
/>
</div> </div>
<Input label="Location" value={formData.location} <Input
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required /> label="Location"
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl} value={formData.location}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} /> onChange={(e) => setFormData({ ...formData, location: e.target.value })}
required
/>
<div className="grid grid-cols-3 gap-4"> <Input
<Input label="Price" type="number" min="0" value={formData.price} label="Location URL (Google Maps)"
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} /> type="url"
value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
label="Price"
type="number"
min="0"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
/>
<div> <div>
<label className="block text-sm font-medium mb-1">Currency</label> <label className="block text-sm font-medium mb-1">Currency</label>
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })} <select
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"> value={formData.currency}
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="PYG">PYG</option> <option value="PYG">PYG</option>
<option value="USD">USD</option> <option value="USD">USD</option>
</select> </select>
</div> </div>
<Input label="Capacity" type="number" min="1" value={formData.capacity} <Input
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} /> label="Capacity"
type="number"
min="1"
value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-sm font-medium mb-1">Status</label>
<select value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value as any })} <select
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"> value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="draft">Draft</option> <option value="draft">Draft</option>
<option value="published">Published</option> <option value="published">Published</option>
<option value="unlisted">Unlisted</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="archived">Archived</option> <option value="archived">Archived</option>
</select> </select>
</div> </div>
{/* External Booking Section */}
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4"> <div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<label className="block text-sm font-medium text-gray-700">External Booking</label> <label className="block text-sm font-medium text-gray-700">External Booking</label>
<p className="text-xs text-gray-500">Redirect users to an external platform</p> <p className="text-xs text-gray-500">Redirect users to an external booking platform</p>
</div> </div>
<button type="button" <button
type="button"
onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })} onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${ className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 ${
formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200' formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
}`}> }`}
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${ >
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0' <span
}`} /> className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button> </button>
</div> </div>
{formData.externalBookingEnabled && ( {formData.externalBookingEnabled && (
<div> <div>
<Input label="External Booking URL" type="url" value={formData.externalBookingUrl} <Input
label="External Booking URL"
type="url"
value={formData.externalBookingUrl}
onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })} onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })}
placeholder="https://example.com/book" required /> placeholder="https://example.com/book"
required
/>
<p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p> <p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p>
</div> </div>
)} )}
</div> </div>
<MediaPicker value={formData.bannerUrl} {/* Image Upload / Media Picker */}
<MediaPicker
value={formData.bannerUrl}
onChange={(url) => setFormData({ ...formData, bannerUrl: url })} onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
relatedId={editingEvent?.id} relatedType="event" /> relatedId={editingEvent?.id}
relatedType="event"
/>
{/* Featured Event Section - Only show for published events when editing */}
{editingEvent && editingEvent.status === 'published' && ( {editingEvent && editingEvent.status === 'published' && (
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50"> <div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2"> <label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
<StarIcon className="w-5 h-5 text-amber-500" /> Featured Event <StarIcon className="w-5 h-5 text-amber-500" />
Featured Event
</label> </label>
<p className="text-xs text-gray-500">Prominently displayed on homepage</p> <p className="text-xs text-gray-500">
Featured events are prominently displayed on the homepage and linktree
</p>
</div> </div>
<button type="button" disabled={settingFeatured !== null} <button
onClick={() => handleSetFeatured(featuredEventId === editingEvent.id ? null : editingEvent.id)} type="button"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${ disabled={settingFeatured !== null}
onClick={() => handleSetFeatured(
featuredEventId === editingEvent.id ? null : editingEvent.id
)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 disabled:opacity-50 ${
featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200' featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200'
}`}> }`}
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${ >
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0' <span
}`} /> className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button> </button>
</div> </div>
{featuredEventId && featuredEventId !== editingEvent.id && ( {featuredEventId && featuredEventId !== editingEvent.id && (
@@ -425,10 +521,14 @@ export default function AdminEventsPage() {
)} )}
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]"> <Button type="submit" isLoading={saving}>
{editingEvent ? 'Update Event' : 'Create Event'} {editingEvent ? 'Update Event' : 'Create Event'}
</Button> </Button>
<Button type="button" variant="outline" onClick={() => { setShowForm(false); resetForm(); }} className="flex-1 min-h-[44px]"> <Button
type="button"
variant="outline"
onClick={() => { setShowForm(false); resetForm(); }}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -437,17 +537,17 @@ export default function AdminEventsPage() {
</div> </div>
)} )}
{/* Desktop: Table */} {/* Events Table */}
<Card className="overflow-hidden hidden md:block"> <Card className="overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Date</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Capacity</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Capacity</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
@@ -459,95 +559,110 @@ export default function AdminEventsPage() {
</tr> </tr>
) : ( ) : (
events.map((event) => ( events.map((event) => (
<tr <tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
key={event.id} <td className="px-6 py-4">
onClick={() => router.push(`/admin/events/${event.id}`)}
className={clsx("hover:bg-gray-50 cursor-pointer", featuredEventId === event.id && "bg-amber-50")}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{event.bannerUrl ? ( {event.bannerUrl ? (
<img src={event.bannerUrl} alt={event.title} <img
className="w-10 h-10 rounded-lg object-cover flex-shrink-0" /> src={event.bannerUrl}
alt={event.title}
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
/>
) : ( ) : (
<div className="w-10 h-10 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0"> <div className="w-12 h-12 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
<PhotoIcon className="w-5 h-5 text-gray-400" /> <PhotoIcon className="w-6 h-6 text-gray-400" />
</div> </div>
)} )}
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium text-sm">{event.title}</p> <p className="font-medium">{event.title}</p>
{featuredEventId === event.id && ( {featuredEventId === event.id && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-amber-100 text-amber-800"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
<StarIconSolid className="w-2.5 h-2.5" /> Featured <StarIconSolid className="w-3 h-3" />
Featured
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500 truncate max-w-xs">{event.location}</p> <p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
</div> </div>
</div> </div>
</td> </td>
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td> <td className="px-6 py-4 text-sm text-gray-600">
<td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td> {formatDate(event.startDatetime)}
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
{getStatusBadge(event.status)}
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
)}
</div>
</td> </td>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}> <td className="px-6 py-4 text-sm">
<div className="flex items-center justify-end gap-1"> {event.bookedCount || 0} / {event.capacity}
</td>
<td className="px-6 py-4">
{getStatusBadge(event.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-1">
{event.status === 'draft' && ( {event.status === 'draft' && (
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}> <Button
size="sm"
variant="ghost"
onClick={() => handleStatusChange(event, 'published')}
>
Publish Publish
</Button> </Button>
)} )}
{event.status === 'published' && ( {event.status === 'published' && (
<button onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)} <button
onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
disabled={settingFeatured !== null} disabled={settingFeatured !== null}
className={clsx("p-2 rounded-btn disabled:opacity-50", className={clsx(
featuredEventId === event.id ? "bg-amber-100 text-amber-600 hover:bg-amber-200" : "hover:bg-amber-100 text-gray-400 hover:text-amber-600")} "p-2 rounded-btn disabled:opacity-50",
title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}> featuredEventId === event.id
{featuredEventId === event.id ? <StarIconSolid className="w-4 h-4" /> : <StarIcon className="w-4 h-4" />} ? "bg-amber-100 text-amber-600 hover:bg-amber-200"
: "hover:bg-amber-100 text-gray-400 hover:text-amber-600"
)}
title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}
>
{featuredEventId === event.id ? (
<StarIconSolid className="w-4 h-4" />
) : (
<StarIcon className="w-4 h-4" />
)}
</button> </button>
)} )}
<Link href={`/admin/events/${event.id}`} <Link
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn" title="Manage"> href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"
title="Manage Event"
>
<EyeIcon className="w-4 h-4" /> <EyeIcon className="w-4 h-4" />
</Link> </Link>
<button onClick={() => handleEdit(event)} className="p-2 hover:bg-gray-100 rounded-btn" title="Edit"> <button
onClick={() => handleEdit(event)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="Edit"
>
<PencilIcon className="w-4 h-4" /> <PencilIcon className="w-4 h-4" />
</button> </button>
<MoreMenu> <button
{(event.status === 'draft' || event.status === 'published') && ( onClick={() => handleDuplicate(event)}
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}> className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted title="Duplicate"
</DropdownItem> >
)} <DocumentDuplicateIcon className="w-4 h-4" />
{event.status === 'unlisted' && ( </button>
<DropdownItem onClick={() => handleStatusChange(event, 'published')}> {event.status !== 'archived' && (
Make Public <button
</DropdownItem> onClick={() => handleArchive(event)}
)} className="p-2 hover:bg-gray-100 text-gray-600 rounded-btn"
{(event.status === 'published' || event.status === 'unlisted') && ( title="Archive"
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}> >
Unpublish <ArchiveBoxIcon className="w-4 h-4" />
</DropdownItem> </button>
)} )}
<DropdownItem onClick={() => handleDuplicate(event)}> <button
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate onClick={() => handleDelete(event.id)}
</DropdownItem> className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
{event.status !== 'archived' && ( title="Delete"
<DropdownItem onClick={() => handleArchive(event)}> >
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive <TrashIcon className="w-4 h-4" />
</DropdownItem> </button>
)}
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
<TrashIcon className="w-4 h-4 mr-2" /> Delete
</DropdownItem>
</MoreMenu>
</div> </div>
</td> </td>
</tr> </tr>
@@ -557,113 +672,6 @@ export default function AdminEventsPage() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{events.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">
No events found. Create your first event!
</div>
) : (
events.map((event) => (
<Card
key={event.id}
className={clsx("p-3 cursor-pointer", featuredEventId === event.id && "ring-2 ring-amber-300")}
onClick={() => router.push(`/admin/events/${event.id}`)}
>
<div className="flex items-start gap-3">
{event.bannerUrl ? (
<img src={event.bannerUrl} alt={event.title}
className="w-14 h-14 rounded-lg object-cover flex-shrink-0" />
) : (
<div className="w-14 h-14 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
<PhotoIcon className="w-6 h-6 text-gray-400" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{event.title}</p>
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
<p className="text-xs text-gray-400 truncate">{event.location}</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
{getStatusBadge(event.status)}
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
)}
{featuredEventId === event.id && (
<StarIconSolid className="w-4 h-4 text-amber-500" />
)}
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Link href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
<EyeIcon className="w-4 h-4" />
</Link>
<MoreMenu>
<DropdownItem onClick={() => handleEdit(event)}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit
</DropdownItem>
{event.status === 'draft' && (
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
Publish
</DropdownItem>
)}
{(event.status === 'draft' || event.status === 'published') && (
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
</DropdownItem>
)}
{event.status === 'unlisted' && (
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
Make Public
</DropdownItem>
)}
{(event.status === 'published' || event.status === 'unlisted') && (
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
Unpublish
</DropdownItem>
)}
{event.status === 'published' && (
<DropdownItem onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}>
<StarIcon className="w-4 h-4 mr-2" />
{featuredEventId === event.id ? 'Unfeature' : 'Set Featured'}
</DropdownItem>
)}
<DropdownItem onClick={() => handleDuplicate(event)}>
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
</DropdownItem>
{event.status !== 'archived' && (
<DropdownItem onClick={() => handleArchive(event)}>
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
</DropdownItem>
)}
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
<TrashIcon className="w-4 h-4 mr-2" /> Delete
</DropdownItem>
</MoreMenu>
</div>
</div>
</div>
</div>
</Card>
))
)}
</div>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={() => { resetForm(); setShowForm(true); }}
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
<PlusIcon className="w-6 h-6" />
</button>
</div>
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -6,7 +6,6 @@ import { faqApi, FaqItemAdmin } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
@@ -16,14 +15,19 @@ import {
Bars3Icon, Bars3Icon,
XMarkIcon, XMarkIcon,
CheckIcon, CheckIcon,
ChevronUpIcon, ArrowLeftIcon,
ChevronDownIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean }; type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean };
const emptyForm: FormState = { const emptyForm: FormState = {
id: null, question: '', questionEs: '', answer: '', answerEs: '', enabled: true, showOnHomepage: false, id: null,
question: '',
questionEs: '',
answer: '',
answerEs: '',
enabled: true,
showOnHomepage: false,
}; };
export default function AdminFaqPage() { export default function AdminFaqPage() {
@@ -36,7 +40,9 @@ export default function AdminFaqPage() {
const [draggedId, setDraggedId] = useState<string | null>(null); const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null); const [dragOverId, setDragOverId] = useState<string | null>(null);
useEffect(() => { loadFaqs(); }, []); useEffect(() => {
loadFaqs();
}, []);
const loadFaqs = async () => { const loadFaqs = async () => {
try { try {
@@ -51,12 +57,20 @@ export default function AdminFaqPage() {
} }
}; };
const handleCreate = () => { setForm(emptyForm); setShowForm(true); }; const handleCreate = () => {
setForm(emptyForm);
setShowForm(true);
};
const handleEdit = (faq: FaqItemAdmin) => { const handleEdit = (faq: FaqItemAdmin) => {
setForm({ setForm({
id: faq.id, question: faq.question, questionEs: faq.questionEs ?? '', id: faq.id,
answer: faq.answer, answerEs: faq.answerEs ?? '', enabled: faq.enabled, showOnHomepage: faq.showOnHomepage, question: faq.question,
questionEs: faq.questionEs ?? '',
answer: faq.answer,
answerEs: faq.answerEs ?? '',
enabled: faq.enabled,
showOnHomepage: faq.showOnHomepage,
}); });
setShowForm(true); setShowForm(true);
}; };
@@ -70,16 +84,22 @@ export default function AdminFaqPage() {
setSaving(true); setSaving(true);
if (form.id) { if (form.id) {
await faqApi.update(form.id, { await faqApi.update(form.id, {
question: form.question.trim(), questionEs: form.questionEs.trim() || null, question: form.question.trim(),
answer: form.answer.trim(), answerEs: form.answerEs.trim() || null, questionEs: form.questionEs.trim() || null,
enabled: form.enabled, showOnHomepage: form.showOnHomepage, answer: form.answer.trim(),
answerEs: form.answerEs.trim() || null,
enabled: form.enabled,
showOnHomepage: form.showOnHomepage,
}); });
toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated'); toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated');
} else { } else {
await faqApi.create({ await faqApi.create({
question: form.question.trim(), questionEs: form.questionEs.trim() || undefined, question: form.question.trim(),
answer: form.answer.trim(), answerEs: form.answerEs.trim() || undefined, questionEs: form.questionEs.trim() || undefined,
enabled: form.enabled, showOnHomepage: form.showOnHomepage, answer: form.answer.trim(),
answerEs: form.answerEs.trim() || undefined,
enabled: form.enabled,
showOnHomepage: form.showOnHomepage,
}); });
toast.success(locale === 'es' ? 'FAQ creado' : 'FAQ created'); toast.success(locale === 'es' ? 'FAQ creado' : 'FAQ created');
} }
@@ -123,44 +143,22 @@ export default function AdminFaqPage() {
} }
}; };
const handleMoveUp = async (index: number) => {
if (index === 0) return;
const newOrder = [...faqs];
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
const ids = newOrder.map(f => f.id);
try {
const res = await faqApi.reorder(ids);
setFaqs(res.faqs);
} catch (err: any) {
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
}
};
const handleMoveDown = async (index: number) => {
if (index >= faqs.length - 1) return;
const newOrder = [...faqs];
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
const ids = newOrder.map(f => f.id);
try {
const res = await faqApi.reorder(ids);
setFaqs(res.faqs);
} catch (err: any) {
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
}
};
// Desktop drag handlers
const handleDragStart = (e: React.DragEvent, id: string) => { const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedId(id); setDraggedId(id);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id); e.dataTransfer.setData('text/plain', id);
}; };
const handleDragOver = (e: React.DragEvent, id: string) => { const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
setDragOverId(id); setDragOverId(id);
}; };
const handleDragLeave = () => { setDragOverId(null); };
const handleDragLeave = () => {
setDragOverId(null);
};
const handleDrop = async (e: React.DragEvent, targetId: string) => { const handleDrop = async (e: React.DragEvent, targetId: string) => {
e.preventDefault(); e.preventDefault();
setDragOverId(null); setDragOverId(null);
@@ -182,7 +180,11 @@ export default function AdminFaqPage() {
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder')); toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
} }
}; };
const handleDragEnd = () => { setDraggedId(null); setDragOverId(null); };
const handleDragEnd = () => {
setDraggedId(null);
setDragOverId(null);
};
if (loading) { if (loading) {
return ( return (
@@ -196,120 +198,179 @@ export default function AdminFaqPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4"> <div className="flex items-center justify-between flex-wrap gap-4">
<div> <div>
<h1 className="text-xl md:text-2xl font-bold font-heading">FAQ</h1> <h1 className="text-2xl font-bold font-heading">
<p className="text-gray-500 text-xs md:text-sm mt-1 hidden md:block"> {locale === 'es' ? 'FAQ' : 'FAQ'}
</h1>
<p className="text-gray-500 text-sm mt-1">
{locale === 'es' {locale === 'es'
? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.' ? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.'
: 'Create and edit FAQ questions. Drag to change order.'} : 'Create and edit FAQ questions. Drag to change order.'}
</p> </p>
</div> </div>
<Button onClick={handleCreate} className="hidden md:flex"> <Button onClick={handleCreate}>
<PlusIcon className="w-4 h-4 mr-2" /> <PlusIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Nueva pregunta' : 'Add question'} {locale === 'es' ? 'Nueva pregunta' : 'Add question'}
</Button> </Button>
</div> </div>
{/* Form Modal - bottom sheet on mobile */}
{showForm && ( {showForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <Card>
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"> <div className="p-6 space-y-4">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0"> <div className="flex justify-between items-center">
<h2 className="text-base font-semibold"> <h2 className="text-lg font-semibold">
{form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')} {form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')}
</h2> </h2>
<button onClick={() => { setForm(emptyForm); setShowForm(false); }} <button
className="p-2 hover:bg-gray-100 rounded-full min-h-[44px] min-w-[44px] flex items-center justify-center"> onClick={() => { setForm(emptyForm); setShowForm(false); }}
className="p-2 hover:bg-gray-100 rounded-full"
>
<XMarkIcon className="w-5 h-5" /> <XMarkIcon className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0"> <div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2"> <div>
<div> <label className="block text-sm font-medium mb-1">Question (EN) *</label>
<label className="block text-sm font-medium mb-1">Question (EN) *</label> <Input
<Input value={form.question} onChange={e => setForm(f => ({ ...f, question: e.target.value }))} placeholder="Question in English" /> value={form.question}
</div> onChange={e => setForm(f => ({ ...f, question: e.target.value }))}
<div> placeholder="Question in English"
<label className="block text-sm font-medium mb-1">Pregunta (ES)</label> />
<Input value={form.questionEs} onChange={e => setForm(f => ({ ...f, questionEs: e.target.value }))} placeholder="Pregunta en español" />
</div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div>
<div> <label className="block text-sm font-medium mb-1">Pregunta (ES)</label>
<label className="block text-sm font-medium mb-1">Answer (EN) *</label> <Input
<textarea className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]" value={form.questionEs}
value={form.answer} onChange={e => setForm(f => ({ ...f, answer: e.target.value }))} placeholder="Answer in English" /> onChange={e => setForm(f => ({ ...f, questionEs: e.target.value }))}
</div> placeholder="Pregunta en español"
<div> />
<label className="block text-sm font-medium mb-1">Respuesta (ES)</label>
<textarea className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
value={form.answerEs} onChange={e => setForm(f => ({ ...f, answerEs: e.target.value }))} placeholder="Respuesta en español" />
</div>
</div>
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
<input type="checkbox" checked={form.enabled} onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} className="rounded border-gray-300 w-4 h-4" />
<span className="text-sm">{locale === 'es' ? 'Mostrar en el sitio' : 'Show on site'}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
<input type="checkbox" checked={form.showOnHomepage} onChange={e => setForm(f => ({ ...f, showOnHomepage: e.target.checked }))} className="rounded border-gray-300 w-4 h-4" />
<span className="text-sm">{locale === 'es' ? 'Mostrar en inicio' : 'Show on homepage'}</span>
</label>
</div>
<div className="flex gap-3 pt-2">
<Button onClick={handleSave} isLoading={saving} className="flex-1 min-h-[44px]">
<CheckIcon className="w-4 h-4 mr-1" /> {locale === 'es' ? 'Guardar' : 'Save'}
</Button>
<Button variant="outline" onClick={() => { setForm(emptyForm); setShowForm(false); }} disabled={saving} className="flex-1 min-h-[44px]">
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
</div> </div>
</div> </div>
</Card> <div className="grid gap-4 sm:grid-cols-2">
</div> <div>
<label className="block text-sm font-medium mb-1">Answer (EN) *</label>
<textarea
className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
value={form.answer}
onChange={e => setForm(f => ({ ...f, answer: e.target.value }))}
placeholder="Answer in English"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Respuesta (ES)</label>
<textarea
className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
value={form.answerEs}
onChange={e => setForm(f => ({ ...f, answerEs: e.target.value }))}
placeholder="Respuesta en español"
/>
</div>
</div>
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.enabled}
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))}
className="rounded border-gray-300"
/>
<span className="text-sm">{locale === 'es' ? 'Mostrar en el sitio' : 'Show on site'}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.showOnHomepage}
onChange={e => setForm(f => ({ ...f, showOnHomepage: e.target.checked }))}
className="rounded border-gray-300"
/>
<span className="text-sm">{locale === 'es' ? 'Mostrar en inicio' : 'Show on homepage'}</span>
</label>
</div>
<div className="flex gap-2">
<Button onClick={handleSave} isLoading={saving}>
<CheckIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Guardar' : 'Save'}
</Button>
<Button variant="outline" onClick={() => { setForm(emptyForm); setShowForm(false); }} disabled={saving}>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
</div>
</div>
</Card>
)} )}
{/* Desktop: Table */} <Card>
<Card className="hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="bg-gray-50 border-b border-gray-200">
<tr> <tr>
<th className="w-10 px-4 py-3" /> <th className="w-10 px-4 py-3" />
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase">{locale === 'es' ? 'Pregunta' : 'Question'}</th> <th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase w-24">{locale === 'es' ? 'En sitio' : 'On site'}</th> {locale === 'es' ? 'Pregunta' : 'Question'}
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase w-28">{locale === 'es' ? 'En inicio' : 'Homepage'}</th> </th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-500 uppercase w-32">{locale === 'es' ? 'Acciones' : 'Actions'}</th> <th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-24">
{locale === 'es' ? 'En sitio' : 'On site'}
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-28">
{locale === 'es' ? 'En inicio' : 'Homepage'}
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase w-32">
{locale === 'es' ? 'Acciones' : 'Actions'}
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200">
{faqs.length === 0 ? ( {faqs.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}</td></tr> <tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}
</td>
</tr>
) : ( ) : (
faqs.map((faq) => ( faqs.map((faq) => (
<tr key={faq.id} draggable onDragStart={e => handleDragStart(e, faq.id)} <tr
onDragOver={e => handleDragOver(e, faq.id)} onDragLeave={handleDragLeave} key={faq.id}
onDrop={e => handleDrop(e, faq.id)} onDragEnd={handleDragEnd} draggable
className={clsx('hover:bg-gray-50', draggedId === faq.id && 'opacity-50', dragOverId === faq.id && 'bg-primary-yellow/10')}> onDragStart={e => handleDragStart(e, faq.id)}
onDragOver={e => handleDragOver(e, faq.id)}
onDragLeave={handleDragLeave}
onDrop={e => handleDrop(e, faq.id)}
onDragEnd={handleDragEnd}
className={clsx(
'hover:bg-gray-50',
draggedId === faq.id && 'opacity-50',
dragOverId === faq.id && 'bg-primary-yellow/10'
)}
>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600" title={locale === 'es' ? 'Arrastrar para reordenar' : 'Drag to reorder'}> <span className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600" title={locale === 'es' ? 'Arrastrar para reordenar' : 'Drag to reorder'}>
<Bars3Icon className="w-5 h-5" /> <Bars3Icon className="w-5 h-5" />
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="font-medium text-primary-dark text-sm line-clamp-1">{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}</p> <p className="font-medium text-primary-dark line-clamp-1">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</p>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button onClick={() => handleToggleEnabled(faq)} <button
className={clsx('inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium min-h-[32px]', onClick={() => handleToggleEnabled(faq)}
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}> className={clsx(
{faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : 'No'} 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
)}
>
{faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : (locale === 'es' ? 'No' : 'No')}
</button> </button>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button onClick={() => handleToggleShowOnHomepage(faq)} <button
className={clsx('inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium min-h-[32px]', onClick={() => handleToggleShowOnHomepage(faq)}
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}> className={clsx(
{faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : 'No'} 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
)}
>
{faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : (locale === 'es' ? 'No' : 'No')}
</button> </button>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
@@ -329,65 +390,6 @@ export default function AdminFaqPage() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{faqs.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}</div>
) : (
faqs.map((faq, index) => (
<Card key={faq.id} className="p-3">
<div className="flex items-start gap-2">
<div className="flex flex-col gap-0.5 flex-shrink-0 pt-0.5">
<button onClick={() => handleMoveUp(index)} disabled={index === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 min-h-[28px] min-w-[28px] flex items-center justify-center">
<ChevronUpIcon className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleMoveDown(index)} disabled={index >= faqs.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 min-h-[28px] min-w-[28px] flex items-center justify-center">
<ChevronDownIcon className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-primary-dark line-clamp-2">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</p>
<div className="flex items-center gap-2 mt-1.5">
<button onClick={() => handleToggleEnabled(faq)}
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium min-h-[28px]',
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
{faq.enabled ? (locale === 'es' ? 'Sitio: Sí' : 'Site: Yes') : (locale === 'es' ? 'Sitio: No' : 'Site: No')}
</button>
<button onClick={() => handleToggleShowOnHomepage(faq)}
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium min-h-[28px]',
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
{faq.showOnHomepage ? (locale === 'es' ? 'Inicio: Sí' : 'Home: Yes') : (locale === 'es' ? 'Inicio: No' : 'Home: No')}
</button>
</div>
</div>
<MoreMenu>
<DropdownItem onClick={() => handleEdit(faq)}>
<PencilSquareIcon className="w-4 h-4 mr-2" /> {locale === 'es' ? 'Editar' : 'Edit'}
</DropdownItem>
<DropdownItem onClick={() => handleDelete(faq.id)} className="text-red-600">
<TrashIcon className="w-4 h-4 mr-2" /> {locale === 'es' ? 'Eliminar' : 'Delete'}
</DropdownItem>
</MoreMenu>
</div>
</Card>
))
)}
</div>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={handleCreate}
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
<PlusIcon className="w-6 h-6" />
</button>
</div>
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -37,56 +37,14 @@ export default function AdminLayout({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const { user, hasAdminAccess, isLoading, logout } = useAuth(); const { user, isAdmin, isLoading, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
const userRole = (user?.role || 'user') as Role;
const navigationWithRoles: { name: string; href: string; icon: typeof HomeIcon; allowedRoles: Role[] }[] = [
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon, allowedRoles: ['admin', 'organizer'] },
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
];
const allowedPathsForRole = new Set(
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href)
);
const defaultAdminRoute =
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
// All hooks must be called unconditionally before any early returns
useEffect(() => { useEffect(() => {
if (!isLoading && (!user || !hasAdminAccess)) { if (!isLoading && (!user || !isAdmin)) {
router.push('/login'); router.push('/login');
} }
}, [user, hasAdminAccess, isLoading, router]); }, [user, isAdmin, isLoading, router]);
useEffect(() => {
if (!user || !hasAdminAccess) return;
if (!pathname.startsWith('/admin')) return;
if (pathname === '/admin' && (userRole === 'staff' || userRole === 'marketing')) {
router.replace(defaultAdminRoute);
return;
}
const isPathAllowed = (path: string) => {
if (allowedPathsForRole.has(path)) return true;
return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/'));
};
if (!isPathAllowed(pathname)) {
router.replace(defaultAdminRoute);
}
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -96,29 +54,31 @@ export default function AdminLayout({
); );
} }
if (!user || !hasAdminAccess) { if (!user || !isAdmin) {
return null; return null;
} }
const visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)); const navigation = [
const navigation = visibleNav; { name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
{ 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.payments'), href: '/admin/payments', icon: CreditCardIcon },
{ 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.emails'), href: '/admin/emails', icon: InboxIcon },
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon },
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon },
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
];
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
router.push('/'); router.push('/');
}; };
// Scanner page gets fullscreen layout without sidebar
const isScannerPage = pathname === '/admin/scanner';
if (isScannerPage) {
return (
<div className="min-h-screen bg-gray-950">
{children}
</div>
);
}
return ( return (
<div className="min-h-screen bg-secondary-gray"> <div className="min-h-screen bg-secondary-gray">
{/* Mobile sidebar backdrop */} {/* Mobile sidebar backdrop */}

View File

@@ -421,46 +421,6 @@ export default function AdminLegalPagesPage() {
</li> </li>
</ul> </ul>
</div> </div>
{/* Available Placeholders */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
<p className="font-medium mb-2">
{locale === 'es' ? 'Marcadores de posición disponibles:' : 'Available placeholders:'}
</p>
<p className="text-blue-700 mb-3">
{locale === 'es'
? 'Puedes usar estos marcadores en el contenido. Se reemplazarán automáticamente con los valores configurados en'
: 'You can use these placeholders in the content. They will be automatically replaced with the values configured in'
}
{' '}
<a href="/admin/settings" className="underline font-medium hover:text-blue-900">
{locale === 'es' ? 'Configuración > Legal' : 'Settings > Legal Settings'}
</a>.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5">
{[
{ placeholder: '{{COMPANY_NAME}}', label: locale === 'es' ? 'Nombre de la empresa' : 'Company name' },
{ placeholder: '{{LEGAL_ENTITY_NAME}}', label: locale === 'es' ? 'Nombre de la entidad legal' : 'Legal entity name' },
{ placeholder: '{{RUC_NUMBER}}', label: locale === 'es' ? 'Número de RUC' : 'RUC number' },
{ placeholder: '{{COMPANY_ADDRESS}}', label: locale === 'es' ? 'Dirección de la empresa' : 'Company address' },
{ placeholder: '{{COMPANY_CITY}}', label: locale === 'es' ? 'Ciudad' : 'City' },
{ placeholder: '{{COMPANY_COUNTRY}}', label: locale === 'es' ? 'País' : 'Country' },
{ placeholder: '{{SUPPORT_EMAIL}}', label: locale === 'es' ? 'Email de soporte' : 'Support email' },
{ placeholder: '{{LEGAL_EMAIL}}', label: locale === 'es' ? 'Email legal' : 'Legal email' },
{ placeholder: '{{GOVERNING_LAW}}', label: locale === 'es' ? 'Ley aplicable' : 'Governing law' },
{ placeholder: '{{JURISDICTION_CITY}}', label: locale === 'es' ? 'Ciudad de jurisdicción' : 'Jurisdiction city' },
{ placeholder: '{{CURRENT_YEAR}}', label: locale === 'es' ? 'Año actual (automático)' : 'Current year (automatic)' },
{ placeholder: '{{LAST_UPDATED_DATE}}', label: locale === 'es' ? 'Fecha de última actualización (automático)' : 'Last updated date (automatic)' },
].map(({ placeholder, label }) => (
<div key={placeholder} className="flex items-center gap-2">
<code className="bg-blue-100 text-blue-900 px-1.5 py-0.5 rounded text-xs font-mono whitespace-nowrap">
{placeholder}
</code>
<span className="text-blue-700 text-xs truncate">{label}</span>
</div>
))}
</div>
</div>
</div> </div>
</Card> </Card>
</div> </div>

View File

@@ -6,7 +6,6 @@ import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPa
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { import {
CheckCircleIcon, CheckCircleIcon,
ArrowPathIcon, ArrowPathIcon,
@@ -21,12 +20,8 @@ import {
BuildingLibraryIcon, BuildingLibraryIcon,
CreditCardIcon, CreditCardIcon,
EnvelopeIcon, EnvelopeIcon,
FunnelIcon,
MagnifyingGlassIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx';
type Tab = 'pending_approval' | 'all'; type Tab = 'pending_approval' | 'all';
@@ -39,9 +34,6 @@ export default function AdminPaymentsPage() {
const [activeTab, setActiveTab] = useState<Tab>('pending_approval'); const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
const [providerFilter, setProviderFilter] = useState<string>(''); const [providerFilter, setProviderFilter] = useState<string>('');
const [eventFilter, setEventFilter] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
// Modal state // Modal state
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null); const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
@@ -62,7 +54,7 @@ export default function AdminPaymentsPage() {
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [statusFilter, providerFilter, eventFilter]); }, [statusFilter, providerFilter]);
const loadData = async () => { const loadData = async () => {
try { try {
@@ -71,8 +63,7 @@ export default function AdminPaymentsPage() {
paymentsApi.getPendingApproval(), paymentsApi.getPendingApproval(),
paymentsApi.getAll({ paymentsApi.getAll({
status: statusFilter || undefined, status: statusFilter || undefined,
provider: providerFilter || undefined, provider: providerFilter || undefined
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
}), }),
eventsApi.getAll(), eventsApi.getAll(),
]); ]);
@@ -338,11 +329,10 @@ export default function AdminPaymentsPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1> <h1 className="text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
<Button onClick={() => setShowExportModal(true)} size="sm" className="min-h-[44px] md:min-h-0"> <Button onClick={() => setShowExportModal(true)}>
<DocumentArrowDownIcon className="w-4 h-4 mr-1.5" /> <DocumentArrowDownIcon className="w-5 h-5 mr-2" />
<span className="hidden md:inline">{locale === 'es' ? 'Exportar Datos' : 'Export Data'}</span> {locale === 'es' ? 'Exportar Datos' : 'Export Data'}
<span className="md:hidden">{locale === 'es' ? 'Exportar' : 'Export'}</span>
</Button> </Button>
</div> </div>
@@ -350,18 +340,11 @@ export default function AdminPaymentsPage() {
{selectedPayment && (() => { {selectedPayment && (() => {
const modalBookingInfo = getBookingInfo(selectedPayment); const modalBookingInfo = getBookingInfo(selectedPayment);
return ( return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"> <Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0"> <h2 className="text-xl font-bold mb-4">
<h2 className="text-base font-bold"> {locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'} </h2>
</h2>
<button onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-4 overflow-y-auto flex-1 min-h-0">
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
@@ -459,24 +442,43 @@ export default function AdminPaymentsPage() {
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]"> <Button
onClick={() => handleApprove(selectedPayment)}
isLoading={processing}
className="flex-1"
>
<CheckCircleIcon className="w-5 h-5 mr-2" /> <CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Aprobar' : 'Approve'} {locale === 'es' ? 'Aprobar' : 'Approve'}
</Button> </Button>
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing} <Button
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]"> variant="outline"
onClick={() => handleReject(selectedPayment)}
isLoading={processing}
className="flex-1 border-red-300 text-red-600 hover:bg-red-50"
>
<XCircleIcon className="w-5 h-5 mr-2" /> <XCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Rechazar' : 'Reject'} {locale === 'es' ? 'Rechazar' : 'Reject'}
</Button> </Button>
</div> </div>
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]"> <Button
variant="outline"
onClick={() => handleSendReminder(selectedPayment)}
isLoading={sendingReminder}
className="w-full"
>
<EnvelopeIcon className="w-5 h-5 mr-2" /> <EnvelopeIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'} {locale === 'es' ? 'Enviar recordatorio de pago' : 'Send payment reminder'}
</Button> </Button>
</div> </div>
</div>
<button
onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</button>
</Card> </Card>
</div> </div>
); );
@@ -484,16 +486,9 @@ export default function AdminPaymentsPage() {
{/* Export Modal */} {/* Export Modal */}
{showExportModal && ( {showExportModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"> <Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0"> <h2 className="text-xl font-bold mb-6">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
<h2 className="text-base font-bold">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
<button onClick={() => { setShowExportModal(false); setExportData(null); }}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-4 overflow-y-auto flex-1 min-h-0">
{!exportData ? ( {!exportData ? (
<div className="space-y-4"> <div className="space-y-4">
@@ -527,10 +522,10 @@ export default function AdminPaymentsPage() {
</div> </div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button onClick={handleExport} isLoading={exporting} className="flex-1 min-h-[44px]"> <Button onClick={handleExport} isLoading={exporting}>
{locale === 'es' ? 'Generar Reporte' : 'Generate Report'} {locale === 'es' ? 'Generar Reporte' : 'Generate Report'}
</Button> </Button>
<Button variant="outline" onClick={() => setShowExportModal(false)} className="flex-1 min-h-[44px]"> <Button variant="outline" onClick={() => setShowExportModal(false)}>
{locale === 'es' ? 'Cancelar' : 'Cancel'} {locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button> </Button>
</div> </div>
@@ -590,21 +585,20 @@ export default function AdminPaymentsPage() {
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex gap-3">
<Button onClick={downloadCSV} className="min-h-[44px]"> <Button onClick={downloadCSV}>
<ArrowDownTrayIcon className="w-4 h-4 mr-2" /> <ArrowDownTrayIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'} {locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
</Button> </Button>
<Button variant="outline" onClick={() => setExportData(null)} className="min-h-[44px]"> <Button variant="outline" onClick={() => setExportData(null)}>
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'} {locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
</Button> </Button>
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }} className="min-h-[44px]"> <Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }}>
{locale === 'es' ? 'Cerrar' : 'Close'} {locale === 'es' ? 'Cerrar' : 'Close'}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
</div>
</Card> </Card>
</div> </div>
)} )}
@@ -663,19 +657,31 @@ export default function AdminPaymentsPage() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="border-b mb-6 overflow-x-auto scrollbar-hide"> <div className="border-b mb-6">
<nav className="flex gap-4 min-w-max"> <nav className="flex gap-4">
<button onClick={() => setActiveTab('pending_approval')} <button
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]', onClick={() => setActiveTab('pending_approval')}
activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}> className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
{locale === 'es' ? 'Pendientes' : 'Pending Approval'} activeTab === 'pending_approval'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}
{pendingApprovalPayments.length > 0 && ( {pendingApprovalPayments.length > 0 && (
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{pendingApprovalPayments.length}</span> <span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">
{pendingApprovalPayments.length}
</span>
)} )}
</button> </button>
<button onClick={() => setActiveTab('all')} <button
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]', onClick={() => setActiveTab('all')}
activeTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}> className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'all'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{locale === 'es' ? 'Todos los Pagos' : 'All Payments'} {locale === 'es' ? 'Todos los Pagos' : 'All Payments'}
</button> </button>
</nav> </nav>
@@ -742,7 +748,7 @@ export default function AdminPaymentsPage() {
)} )}
</div> </div>
</div> </div>
<Button onClick={() => setSelectedPayment(payment)} size="sm" className="min-h-[44px] md:min-h-0 flex-shrink-0"> <Button onClick={() => setSelectedPayment(payment)}>
{locale === 'es' ? 'Revisar' : 'Review'} {locale === 'es' ? 'Revisar' : 'Review'}
</Button> </Button>
</div> </div>
@@ -755,44 +761,18 @@ export default function AdminPaymentsPage() {
)} )}
{/* All Payments Tab */} {/* All Payments Tab */}
{activeTab === 'all' && (() => { {activeTab === 'all' && (
const q = searchQuery.trim().toLowerCase();
const filteredPayments = q
? payments.filter((p) => {
const name = `${p.ticket?.attendeeFirstName || ''} ${p.ticket?.attendeeLastName || ''}`.trim().toLowerCase();
const email = (p.ticket?.attendeeEmail || '').toLowerCase();
const phone = (p.ticket?.attendeePhone || '').toLowerCase();
const eventTitle = (p.event?.title || '').toLowerCase();
const payerName = (p.payerName || '').toLowerCase();
const reference = (p.reference || '').toLowerCase();
const id = (p.id || '').toLowerCase();
return name.includes(q) || email.includes(q) || phone.includes(q) ||
eventTitle.includes(q) || payerName.includes(q) || reference.includes(q) || id.includes(q);
})
: payments;
return (
<> <>
{/* Desktop Filters */} {/* Filters */}
<Card className="p-4 mb-6 hidden md:block"> <Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Buscar' : 'Search'}</label>
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder={locale === 'es' ? 'Nombre, email, evento...' : 'Name, email, event...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow min-w-[200px]"
/>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-sm font-medium mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} <select
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm"> value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option> <option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option> <option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option> <option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
@@ -803,126 +783,119 @@ export default function AdminPaymentsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label> <label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
<select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)} <select
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm"> value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option> <option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
<option value="lightning">Lightning</option> <option value="lightning">Lightning</option>
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option> <option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option> <option value="bank_transfer">{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}</option>
<option value="tpago">TPago</option> <option value="tpago">TPago</option>
</select> </select>
</div> </div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Evento(s)' : 'Event(s)'}</label>
<select
value=""
onChange={(e) => {
const id = e.target.value;
if (id && !eventFilter.includes(id)) setEventFilter([...eventFilter, id]);
e.target.value = '';
}}
className="px-4 py-2 rounded-btn border border-secondary-light-gray w-full text-sm"
>
<option value="">{locale === 'es' ? 'Agregar evento...' : 'Add event...'}</option>
{events.filter(e => !eventFilter.includes(e.id)).map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
{eventFilter.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{eventFilter.map((id) => {
const ev = events.find(e => e.id === id);
return (
<span key={id} className="inline-flex items-center gap-1 px-2 py-0.5 bg-primary-yellow/20 rounded text-xs">
{ev?.title || id}
<button type="button" onClick={() => setEventFilter(eventFilter.filter(x => x !== id))} className="hover:text-red-600">×</button>
</span>
);
})}
<button type="button" onClick={() => setEventFilter([])} className="text-xs text-gray-500 hover:text-primary-dark">
{locale === 'es' ? 'Limpiar' : 'Clear'}
</button>
</div>
)}
</div>
</div> </div>
</Card> </Card>
{/* Mobile Search & Filter Toolbar */} {/* Payments Table */}
<div className="md:hidden mb-4 space-y-2"> <Card className="overflow-hidden">
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder={locale === 'es' ? 'Nombre, email, evento...' : 'Name, email, event...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setMobileFilterOpen(true)}
className={clsx('flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
(statusFilter || providerFilter || eventFilter.length > 0) ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600')}>
<FunnelIcon className="w-4 h-4" /> Filters
</button>
{(statusFilter || providerFilter || eventFilter.length > 0 || searchQuery) && (
<button onClick={() => { setStatusFilter(''); setProviderFilter(''); setEventFilter([]); setSearchQuery(''); }}
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
)}
</div>
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Asistente' : 'Attendee'}</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Evento' : 'Event'}</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Evento' : 'Event'}</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Monto' : 'Amount'}</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Monto' : 'Amount'}</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Método' : 'Method'}</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Método' : 'Method'}</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Fecha' : 'Date'}</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Acciones' : 'Actions'}</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{filteredPayments.length === 0 ? ( {payments.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr> <tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}
</td>
</tr>
) : ( ) : (
filteredPayments.map((payment) => { payments.map((payment) => {
const bookingInfo = getBookingInfo(payment); const bookingInfo = getBookingInfo(payment);
return ( return (
<tr key={payment.id} className="hover:bg-gray-50"> <tr key={payment.id} className="hover:bg-gray-50">
<td className="px-4 py-3"> <td className="px-6 py-4">
{payment.ticket ? ( {payment.ticket ? (
<div> <div>
<p className="font-medium text-sm">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p> <p className="font-medium text-sm">
<p className="text-xs text-gray-500 truncate max-w-[180px]">{payment.ticket.attendeeEmail}</p> {payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
</p>
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
{payment.payerName && (
<p className="text-xs text-amber-600 mt-1">
{locale === 'es' ? 'Pagado por:' : 'Paid by:'} {payment.payerName}
</p>
)}
</div> </div>
) : <span className="text-gray-400 text-sm">-</span>} ) : (
<span className="text-gray-400 text-sm">-</span>
)}
</td> </td>
<td className="px-4 py-3 text-sm truncate max-w-[150px]">{payment.event?.title || '-'}</td> <td className="px-6 py-4">
<td className="px-4 py-3"> {payment.event ? (
<p className="font-medium text-sm">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p> <p className="text-sm">{payment.event.title}</p>
{bookingInfo.ticketCount > 1 && <p className="text-[10px] text-purple-600">{bookingInfo.ticketCount} tickets</p>} ) : (
<span className="text-gray-400 text-sm">-</span>
)}
</td> </td>
<td className="px-4 py-3"> <td className="px-6 py-4">
<div className="flex items-center gap-1.5 text-xs text-gray-600"> <div>
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} <p className="font-medium">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
{bookingInfo.ticketCount > 1 && (
<p className="text-xs text-purple-600 mt-1">
📦 {bookingInfo.ticketCount} × {formatCurrency(payment.amount, payment.currency)}
</p>
)}
</div> </div>
</td> </td>
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td> <td className="px-6 py-4">
<td className="px-4 py-3"> <div className="flex items-center gap-2 text-sm text-gray-600">
<div className="flex items-center justify-end gap-1"> {getProviderIcon(payment.provider)}
{getProviderLabel(payment.provider)}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(payment.createdAt)}
</td>
<td className="px-6 py-4">
<div className="space-y-1">
{getStatusBadge(payment.status)}
{payment.ticket?.bookingId && (
<p className="text-xs text-purple-600" title="Part of multi-ticket booking">
📦 {locale === 'es' ? 'Grupo' : 'Group'}
</p>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{(payment.status === 'pending' || payment.status === 'pending_approval') && ( {(payment.status === 'pending' || payment.status === 'pending_approval') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1"> <Button
size="sm"
onClick={() => setSelectedPayment(payment)}
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Revisar' : 'Review'} {locale === 'es' ? 'Revisar' : 'Review'}
</Button> </Button>
)} )}
{payment.status === 'paid' && ( {payment.status === 'paid' && (
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2 py-1"> <Button
size="sm"
variant="outline"
onClick={() => handleRefund(payment.id)}
>
<ArrowPathIcon className="w-4 h-4 mr-1" />
{t('admin.payments.refund')} {t('admin.payments.refund')}
</Button> </Button>
)} )}
@@ -936,117 +909,8 @@ export default function AdminPaymentsPage() {
</table> </table>
</div> </div>
</Card> </Card>
{(searchQuery || filteredPayments.length !== payments.length) && (
<p className="hidden md:block text-sm text-gray-500 mb-2">
{locale === 'es' ? 'Mostrando' : 'Showing'} {filteredPayments.length} {locale === 'es' ? 'de' : 'of'} {payments.length}
</p>
)}
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{filteredPayments.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</div>
) : (
filteredPayments.map((payment) => {
const bookingInfo = getBookingInfo(payment);
return (
<Card key={payment.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{payment.ticket ? (
<p className="font-medium text-sm truncate">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p>
) : <p className="text-sm text-gray-400">-</p>}
<p className="text-xs text-gray-500 truncate">{payment.event?.title || '-'}</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{getStatusBadge(payment.status)}
</div>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
<span className="text-gray-300">|</span>
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}</span>
{bookingInfo.ticketCount > 1 && (
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
)}
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
<div className="flex items-center gap-1">
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
)}
{payment.status === 'paid' && (
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{t('admin.payments.refund')}
</Button>
)}
</div>
</div>
</Card>
);
})
)}
</div>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title={locale === 'es' ? 'Filtros' : 'Filters'}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{locale === 'es' ? 'Evento(s)' : 'Event(s)'}</label>
<div className="max-h-40 overflow-y-auto border border-secondary-light-gray rounded-btn p-2 space-y-1">
{events.map((event) => (
<label key={event.id} className="flex items-center gap-2 py-1.5 cursor-pointer">
<input
type="checkbox"
checked={eventFilter.includes(event.id)}
onChange={(e) => {
if (e.target.checked) setEventFilter([...eventFilter, event.id]);
else setEventFilter(eventFilter.filter(id => id !== event.id));
}}
className="w-4 h-4 rounded border-gray-300 text-primary-yellow focus:ring-primary-yellow"
/>
<span className="text-sm">{event.title}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
<select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
<option value="lightning">Lightning</option>
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
<option value="tpago">TPago</option>
</select>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => { setStatusFilter(''); setProviderFilter(''); setEventFilter([]); setSearchQuery(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
</div>
</div>
</BottomSheet>
</> </>
); )}
})()}
<AdminMobileStyles />
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,7 @@ import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { CheckCircleIcon, XCircleIcon, PlusIcon } from '@heroicons/react/24/outline';
import { CheckCircleIcon, XCircleIcon, PlusIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -18,17 +17,26 @@ export default function AdminTicketsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedEvent, setSelectedEvent] = useState<string>(''); const [selectedEvent, setSelectedEvent] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
// Manual ticket creation state
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
eventId: '', firstName: '', lastName: '', email: '', phone: '', eventId: '',
preferredLanguage: 'en' as 'en' | 'es', autoCheckin: false, adminNote: '', firstName: '',
lastName: '',
email: '',
phone: '',
preferredLanguage: 'en' as 'en' | 'es',
autoCheckin: false,
adminNote: '',
}); });
useEffect(() => { useEffect(() => {
Promise.all([ticketsApi.getAll(), eventsApi.getAll()]) Promise.all([
ticketsApi.getAll(),
eventsApi.getAll(),
])
.then(([ticketsRes, eventsRes]) => { .then(([ticketsRes, eventsRes]) => {
setTickets(ticketsRes.tickets); setTickets(ticketsRes.tickets);
setEvents(eventsRes.events); setEvents(eventsRes.events);
@@ -50,7 +58,9 @@ export default function AdminTicketsPage() {
}; };
useEffect(() => { useEffect(() => {
if (!loading) loadTickets(); if (!loading) {
loadTickets();
}
}, [selectedEvent, statusFilter]); }, [selectedEvent, statusFilter]);
const handleCheckin = async (id: string) => { const handleCheckin = async (id: string) => {
@@ -65,6 +75,7 @@ export default function AdminTicketsPage() {
const handleCancel = async (id: string) => { const handleCancel = async (id: string) => {
if (!confirm('Are you sure you want to cancel this ticket?')) return; if (!confirm('Are you sure you want to cancel this ticket?')) return;
try { try {
await ticketsApi.cancel(id); await ticketsApi.cancel(id);
toast.success('Ticket cancelled'); toast.success('Ticket cancelled');
@@ -86,18 +97,35 @@ export default function AdminTicketsPage() {
const handleCreateTicket = async (e: React.FormEvent) => { const handleCreateTicket = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!createForm.eventId) { toast.error('Please select an event'); return; } if (!createForm.eventId) {
toast.error('Please select an event');
return;
}
setCreating(true); setCreating(true);
try { try {
await ticketsApi.adminCreate({ await ticketsApi.adminCreate({
eventId: createForm.eventId, firstName: createForm.firstName, eventId: createForm.eventId,
lastName: createForm.lastName || undefined, email: createForm.email, firstName: createForm.firstName,
phone: createForm.phone, preferredLanguage: createForm.preferredLanguage, lastName: createForm.lastName || undefined,
autoCheckin: createForm.autoCheckin, adminNote: createForm.adminNote || undefined, email: createForm.email,
phone: createForm.phone,
preferredLanguage: createForm.preferredLanguage,
autoCheckin: createForm.autoCheckin,
adminNote: createForm.adminNote || undefined,
}); });
toast.success('Ticket created successfully'); toast.success('Ticket created successfully');
setShowCreateForm(false); setShowCreateForm(false);
setCreateForm({ eventId: '', firstName: '', lastName: '', email: '', phone: '', preferredLanguage: 'en', autoCheckin: false, adminNote: '' }); setCreateForm({
eventId: '',
firstName: '',
lastName: '',
email: '',
phone: '',
preferredLanguage: 'en',
autoCheckin: false,
adminNote: '',
});
loadTickets(); loadTickets();
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Failed to create ticket'); toast.error(error.message || 'Failed to create ticket');
@@ -108,29 +136,33 @@ export default function AdminTicketsPage() {
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion', month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
}); });
}; };
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
const styles: Record<string, string> = { const styles: Record<string, string> = {
pending: 'badge-warning', confirmed: 'badge-success', cancelled: 'badge-danger', checked_in: 'badge-info', pending: 'badge-warning',
confirmed: 'badge-success',
cancelled: 'badge-danger',
checked_in: 'badge-info',
}; };
const labels: Record<string, string> = { const labels: Record<string, string> = {
pending: t('admin.tickets.status.pending'), confirmed: t('admin.tickets.status.confirmed'), pending: t('admin.tickets.status.pending'),
cancelled: t('admin.tickets.status.cancelled'), checked_in: t('admin.tickets.status.checkedIn'), confirmed: t('admin.tickets.status.confirmed'),
cancelled: t('admin.tickets.status.cancelled'),
checked_in: t('admin.tickets.status.checkedIn'),
}; };
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>; return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>;
}; };
const getEventName = (eventId: string) => events.find(e => e.id === eventId)?.title || 'Unknown Event'; const getEventName = (eventId: string) => {
const event = events.find(e => e.id === eventId);
const hasActiveFilters = selectedEvent || statusFilter; return event?.title || 'Unknown Event';
const getPrimaryAction = (ticket: Ticket) => {
if (ticket.status === 'pending') return { label: 'Confirm', onClick: () => handleConfirm(ticket.id) };
if (ticket.status === 'confirmed') return { label: t('admin.tickets.checkin'), onClick: () => handleCheckin(ticket.id) };
return null;
}; };
if (loading) { if (loading) {
@@ -144,86 +176,134 @@ export default function AdminTicketsPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1> <h1 className="text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1>
<Button onClick={() => setShowCreateForm(true)} className="hidden md:flex"> <Button onClick={() => setShowCreateForm(true)}>
<PlusIcon className="w-5 h-5 mr-2" /> Create Ticket <PlusIcon className="w-5 h-5 mr-2" />
Create Ticket
</Button> </Button>
</div> </div>
{/* Create Ticket Modal */} {/* Manual Ticket Creation Modal */}
{showCreateForm && ( {showCreateForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"> <Card className="w-full max-w-lg p-6">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0"> <h2 className="text-xl font-bold mb-6">Create Ticket Manually</h2>
<h2 className="text-base font-bold">Create Ticket Manually</h2> <form onSubmit={handleCreateTicket} className="space-y-4">
<button onClick={() => setShowCreateForm(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleCreateTicket} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
<div> <div>
<label className="block text-sm font-medium mb-1">Event *</label> <label className="block text-sm font-medium mb-1">Event *</label>
<select value={createForm.eventId} <select
value={createForm.eventId}
onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })} onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray min-h-[44px]" required> className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
required
>
<option value="">Select an event</option> <option value="">Select an event</option>
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => ( {events.filter(e => e.status === 'published').map((event) => (
<option key={event.id} value={event.id}>{event.title} ({event.availableSeats} spots left)</option> <option key={event.id} value={event.id}>
{event.title} ({event.availableSeats} spots left)
</option>
))} ))}
</select> </select>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input label="First Name *" value={createForm.firstName} <Input
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })} required placeholder="First name" /> label="First Name *"
<Input label="Last Name" value={createForm.lastName} value={createForm.firstName}
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })} placeholder="Last name" /> onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })}
required
placeholder="First name"
/>
<Input
label="Last Name (optional)"
value={createForm.lastName}
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })}
placeholder="Last name"
/>
</div> </div>
<Input label="Email (optional)" type="email" value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })} placeholder="attendee@email.com" /> <Input
<Input label="Phone (optional)" value={createForm.phone} label="Email (optional)"
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })} placeholder="+595 XXX XXX XXX" /> type="email"
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
placeholder="attendee@email.com"
/>
<Input
label="Phone (optional)"
value={createForm.phone}
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })}
placeholder="+595 XXX XXX XXX"
/>
<div> <div>
<label className="block text-sm font-medium mb-1">Preferred Language</label> <label className="block text-sm font-medium mb-1">Preferred Language</label>
<select value={createForm.preferredLanguage} <select
value={createForm.preferredLanguage}
onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })} onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray min-h-[44px]"> className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="en">English</option> <option value="en">English</option>
<option value="es">Spanish</option> <option value="es">Spanish</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Admin Note</label> <label className="block text-sm font-medium mb-1">Admin Note</label>
<textarea value={createForm.adminNote} <textarea
value={createForm.adminNote}
onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })} onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" rows={2} className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
placeholder="Internal note (optional)" /> rows={2}
placeholder="Internal note about this booking (optional)"
/>
</div> </div>
<div className="flex items-center gap-3">
<input type="checkbox" id="autoCheckin" checked={createForm.autoCheckin} <div className="flex items-center gap-2">
<input
type="checkbox"
id="autoCheckin"
checked={createForm.autoCheckin}
onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })} onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })}
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" /> className="w-4 h-4"
<label htmlFor="autoCheckin" className="text-sm">Auto check-in immediately</label> />
<label htmlFor="autoCheckin" className="text-sm">
Automatically check in (mark as present)
</label>
</div> </div>
<div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800"> <div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800">
Creates a ticket with cash payment marked as paid. Use for walk-ins at the door. Note: This creates a ticket with cash payment marked as paid. Use this for walk-ins at the door. Email and phone are optional for door entries.
</div> </div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowCreateForm(false)} className="flex-1 min-h-[44px]">Cancel</Button> <div className="flex gap-3 pt-4">
<Button type="submit" isLoading={creating} className="flex-1 min-h-[44px]">Create Ticket</Button> <Button type="submit" isLoading={creating}>
Create Ticket
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowCreateForm(false)}
>
Cancel
</Button>
</div> </div>
</form> </form>
</Card> </Card>
</div> </div>
)} )}
{/* Desktop Filters */} {/* Filters */}
<Card className="p-4 mb-6 hidden md:block"> <Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Event</label> <label className="block text-sm font-medium mb-1">Event</label>
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)} <select
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px] text-sm"> value={selectedEvent}
onChange={(e) => setSelectedEvent(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px]"
>
<option value="">All Events</option> <option value="">All Events</option>
{events.map((event) => ( {events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option> <option key={event.id} value={event.id}>{event.title}</option>
@@ -232,8 +312,11 @@ export default function AdminTicketsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-sm font-medium mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} <select
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm"> value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="confirmed">Confirmed</option> <option value="confirmed">Confirmed</option>
@@ -244,61 +327,70 @@ export default function AdminTicketsPage() {
</div> </div>
</Card> </Card>
{/* Mobile Toolbar */} {/* Tickets Table */}
<div className="md:hidden mb-4 flex items-center gap-2"> <Card className="overflow-hidden">
<button onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
hasActiveFilters ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
)}>
<FunnelIcon className="w-4 h-4" />
Filters {hasActiveFilters && `(${tickets.length})`}
</button>
{hasActiveFilters && (
<button onClick={() => { setSelectedEvent(''); setStatusFilter(''); }}
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
)}
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Ticket</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Ticket</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{tickets.length === 0 ? ( {tickets.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No tickets found</td></tr> <tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No tickets found
</td>
</tr>
) : ( ) : (
tickets.map((ticket) => ( tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50"> <tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-4 py-3"> <td className="px-6 py-4">
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p> <div>
<p className="text-[10px] text-gray-400">ID: {ticket.id.slice(0, 8)}...</p> <p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
<p className="text-xs text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
</div>
</td> </td>
<td className="px-4 py-3 text-sm">{getEventName(ticket.eventId)}</td> <td className="px-6 py-4 text-sm">
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(ticket.createdAt)}</td> {getEventName(ticket.eventId)}
<td className="px-4 py-3">{getStatusBadge(ticket.status)}</td> </td>
<td className="px-4 py-3"> <td className="px-6 py-4 text-sm text-gray-600">
<div className="flex items-center justify-end gap-1"> {formatDate(ticket.createdAt)}
</td>
<td className="px-6 py-4">
{getStatusBadge(ticket.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{ticket.status === 'pending' && ( {ticket.status === 'pending' && (
<Button size="sm" variant="ghost" onClick={() => handleConfirm(ticket.id)}>Confirm</Button> <Button
size="sm"
variant="ghost"
onClick={() => handleConfirm(ticket.id)}
>
Confirm
</Button>
)} )}
{ticket.status === 'confirmed' && ( {ticket.status === 'confirmed' && (
<Button size="sm" onClick={() => handleCheckin(ticket.id)}> <Button
<CheckCircleIcon className="w-4 h-4 mr-1" /> {t('admin.tickets.checkin')} size="sm"
onClick={() => handleCheckin(ticket.id)}
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
{t('admin.tickets.checkin')}
</Button> </Button>
)} )}
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && ( {ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
<button onClick={() => handleCancel(ticket.id)} <button
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" title="Cancel"> onClick={() => handleCancel(ticket.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Cancel"
>
<XCircleIcon className="w-4 h-4" /> <XCircleIcon className="w-4 h-4" />
</button> </button>
)} )}
@@ -311,102 +403,6 @@ export default function AdminTicketsPage() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{tickets.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">No tickets found</div>
) : (
tickets.map((ticket) => {
const primary = getPrimaryAction(ticket);
return (
<Card key={ticket.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
<p className="text-xs text-gray-500 truncate">{getEventName(ticket.eventId)}</p>
</div>
{getStatusBadge(ticket.status)}
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">{formatDate(ticket.createdAt)}</p>
<div className="flex items-center gap-1">
{primary && (
<Button size="sm" variant={ticket.status === 'confirmed' ? 'primary' : 'outline'}
onClick={primary.onClick} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{primary.label}
</Button>
)}
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
<MoreMenu>
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel Ticket
</DropdownItem>
</MoreMenu>
)}
{ticket.status === 'checked_in' && (
<span className="text-[10px] text-green-600 flex items-center gap-1">
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
</span>
)}
</div>
</div>
</Card>
);
})
)}
</div>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={() => setShowCreateForm(true)}
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
<PlusIcon className="w-6 h-6" />
</button>
</div>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<div className="space-y-1">
{[
{ value: '', label: 'All Statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'checked_in', label: 'Checked In' },
{ value: 'cancelled', label: 'Cancelled' },
].map((opt) => (
<button key={opt.value} onClick={() => setStatusFilter(opt.value)}
className={clsx(
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
statusFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}>
{opt.label}
{statusFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => { setSelectedEvent(''); setStatusFilter(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
</div>
</div>
</BottomSheet>
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -6,11 +6,8 @@ import { usersApi, User } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { TrashIcon, PencilSquareIcon } from '@heroicons/react/24/outline';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { CheckCircleIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx';
export default function AdminUsersPage() { export default function AdminUsersPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
@@ -27,7 +24,6 @@ export default function AdminUsersPage() {
accountStatus: '' as string, accountStatus: '' as string,
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
useEffect(() => { useEffect(() => {
loadUsers(); loadUsers();
@@ -56,6 +52,7 @@ export default function AdminUsersPage() {
const handleDelete = async (userId: string) => { const handleDelete = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user?')) return; if (!confirm('Are you sure you want to delete this user?')) return;
try { try {
await usersApi.delete(userId); await usersApi.delete(userId);
toast.success('User deleted'); toast.success('User deleted');
@@ -68,8 +65,11 @@ export default function AdminUsersPage() {
const openEditModal = (user: User) => { const openEditModal = (user: User) => {
setEditingUser(user); setEditingUser(user);
setEditForm({ setEditForm({
name: user.name, email: user.email, phone: user.phone || '', name: user.name,
role: user.role, languagePreference: user.languagePreference || '', email: user.email,
phone: user.phone || '',
role: user.role,
languagePreference: user.languagePreference || '',
accountStatus: user.accountStatus || 'active', accountStatus: user.accountStatus || 'active',
}); });
}; };
@@ -77,6 +77,7 @@ export default function AdminUsersPage() {
const handleEditSubmit = async (e: React.FormEvent) => { const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!editingUser) return; if (!editingUser) return;
if (!editForm.name.trim() || editForm.name.trim().length < 2) { if (!editForm.name.trim() || editForm.name.trim().length < 2) {
toast.error('Name must be at least 2 characters'); toast.error('Name must be at least 2 characters');
return; return;
@@ -85,11 +86,14 @@ export default function AdminUsersPage() {
toast.error('Email is required'); toast.error('Email is required');
return; return;
} }
setSaving(true); setSaving(true);
try { try {
await usersApi.update(editingUser.id, { await usersApi.update(editingUser.id, {
name: editForm.name.trim(), email: editForm.email.trim(), name: editForm.name.trim(),
phone: editForm.phone.trim() || undefined, role: editForm.role, email: editForm.email.trim(),
phone: editForm.phone.trim() || undefined,
role: editForm.role,
languagePreference: editForm.languagePreference || undefined, languagePreference: editForm.languagePreference || undefined,
accountStatus: editForm.accountStatus || undefined, accountStatus: editForm.accountStatus || undefined,
} as Partial<User>); } as Partial<User>);
@@ -105,14 +109,20 @@ export default function AdminUsersPage() {
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion', year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'America/Asuncion',
}); });
}; };
const getRoleBadge = (role: string) => { const getRoleBadge = (role: string) => {
const styles: Record<string, string> = { const styles: Record<string, string> = {
admin: 'badge-danger', organizer: 'badge-info', staff: 'badge-warning', admin: 'badge-danger',
marketing: 'badge-success', user: 'badge-gray', organizer: 'badge-info',
staff: 'badge-warning',
marketing: 'badge-success',
user: 'badge-gray',
}; };
return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>; return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>;
}; };
@@ -128,16 +138,19 @@ export default function AdminUsersPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1> <h1 className="text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1>
</div> </div>
{/* Desktop Filters */} {/* Filters */}
<Card className="p-4 mb-6 hidden md:block"> <Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label> <label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)} <select
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm"> value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">All Roles</option> <option value="">All Roles</option>
<option value="admin">{t('admin.users.roles.admin')}</option> <option value="admin">{t('admin.users.roles.admin')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option> <option value="organizer">{t('admin.users.roles.organizer')}</option>
@@ -149,58 +162,51 @@ export default function AdminUsersPage() {
</div> </div>
</Card> </Card>
{/* Mobile Toolbar */} {/* Users Table */}
<div className="md:hidden mb-4 flex items-center gap-2"> <Card className="overflow-hidden">
<button onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
roleFilter ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
)}>
<FunnelIcon className="w-4 h-4" />
{roleFilter ? t(`admin.users.roles.${roleFilter}`) : 'Filter by Role'}
</button>
{roleFilter && (
<button onClick={() => setRoleFilter('')} className="text-xs text-primary-yellow min-h-[44px] flex items-center">
Clear
</button>
)}
<span className="text-xs text-gray-500 ml-auto">{users.length} users</span>
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">User</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">User</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Contact</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Role</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th> <th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Joined</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{users.length === 0 ? ( {users.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr> <tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No users found
</td>
</tr>
) : ( ) : (
users.map((user) => ( users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50"> <tr key={user.id} className="hover:bg-gray-50">
<td className="px-4 py-3"> <td className="px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
<span className="font-semibold text-sm text-primary-dark">{user.name.charAt(0).toUpperCase()}</span> <span className="font-semibold text-primary-dark">
{user.name.charAt(0).toUpperCase()}
</span>
</div> </div>
<div> <div>
<p className="font-medium text-sm">{user.name}</p> <p className="font-medium">{user.name}</p>
<p className="text-xs text-gray-500">{user.email}</p> <p className="text-sm text-gray-500">{user.email}</p>
</div> </div>
</div> </div>
</td> </td>
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td> <td className="px-6 py-4 text-sm text-gray-600">
<td className="px-4 py-3"> {user.phone || '-'}
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)} </td>
className="px-2 py-1 rounded border border-secondary-light-gray text-sm"> <td className="px-6 py-4">
<select
value={user.role}
onChange={(e) => handleRoleChange(user.id, e.target.value)}
className="px-2 py-1 rounded border border-secondary-light-gray text-sm"
>
<option value="user">{t('admin.users.roles.user')}</option> <option value="user">{t('admin.users.roles.user')}</option>
<option value="staff">{t('admin.users.roles.staff')}</option> <option value="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option> <option value="marketing">{t('admin.users.roles.marketing')}</option>
@@ -208,15 +214,23 @@ export default function AdminUsersPage() {
<option value="admin">{t('admin.users.roles.admin')}</option> <option value="admin">{t('admin.users.roles.admin')}</option>
</select> </select>
</td> </td>
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(user.createdAt)}</td> <td className="px-6 py-4 text-sm text-gray-600">
<td className="px-4 py-3"> {formatDate(user.createdAt)}
<div className="flex items-center justify-end gap-1"> </td>
<button onClick={() => openEditModal(user)} <td className="px-6 py-4">
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn" title="Edit"> <div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditModal(user)}
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
title="Edit"
>
<PencilSquareIcon className="w-4 h-4" /> <PencilSquareIcon className="w-4 h-4" />
</button> </button>
<button onClick={() => handleDelete(user.id)} <button
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" title="Delete"> onClick={() => handleDelete(user.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Delete"
>
<TrashIcon className="w-4 h-4" /> <TrashIcon className="w-4 h-4" />
</button> </button>
</div> </div>
@@ -229,90 +243,43 @@ export default function AdminUsersPage() {
</div> </div>
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{users.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">No users found</div>
) : (
users.map((user) => (
<Card key={user.id} className="p-3">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
<span className="font-semibold text-primary-dark">{user.name.charAt(0).toUpperCase()}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{user.name}</p>
<p className="text-xs text-gray-500 truncate">{user.email}</p>
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
</div>
{getRoleBadge(user.role)}
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">Joined {formatDate(user.createdAt)}</p>
<MoreMenu>
<DropdownItem onClick={() => openEditModal(user)}>
<PencilSquareIcon className="w-4 h-4 mr-2" /> Edit User
</DropdownItem>
<DropdownItem onClick={() => handleDelete(user.id)} className="text-red-600">
<TrashIcon className="w-4 h-4 mr-2" /> Delete
</DropdownItem>
</MoreMenu>
</div>
</div>
</div>
</Card>
))
)}
</div>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filter by Role">
<div className="space-y-1">
{[
{ value: '', label: 'All Roles' },
{ value: 'admin', label: t('admin.users.roles.admin') },
{ value: 'organizer', label: t('admin.users.roles.organizer') },
{ value: 'staff', label: t('admin.users.roles.staff') },
{ value: 'marketing', label: t('admin.users.roles.marketing') },
{ value: 'user', label: t('admin.users.roles.user') },
].map((opt) => (
<button key={opt.value}
onClick={() => { setRoleFilter(opt.value); setMobileFilterOpen(false); }}
className={clsx(
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
roleFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}>
{opt.label}
{roleFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</BottomSheet>
{/* Edit User Modal */} {/* Edit User Modal */}
{editingUser && ( {editingUser && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"> <Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0"> <h2 className="text-xl font-bold text-primary-dark mb-6">Edit User</h2>
<h2 className="text-base font-bold">Edit User</h2>
<button onClick={() => setEditingUser(null)} <form onSubmit={handleEditSubmit} className="space-y-4">
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"> <Input
<XMarkIcon className="w-5 h-5" /> label="Name"
</button> value={editForm.name}
</div> onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
<form onSubmit={handleEditSubmit} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0"> required
<Input label="Name" value={editForm.name} minLength={2}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required minLength={2} /> />
<Input label="Email" type="email" value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })} required /> <Input
<Input label="Phone" value={editForm.phone} label="Email"
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })} placeholder="Optional" /> type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
required
/>
<Input
label="Phone"
value={editForm.phone}
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
placeholder="Optional"
/>
<div> <div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label> <label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label>
<select value={editForm.role} onChange={(e) => setEditForm({ ...editForm, role: e.target.value as User['role'] })} <select
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]"> value={editForm.role}
onChange={(e) => setEditForm({ ...editForm, role: e.target.value as User['role'] })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
>
<option value="user">{t('admin.users.roles.user')}</option> <option value="user">{t('admin.users.roles.user')}</option>
<option value="staff">{t('admin.users.roles.staff')}</option> <option value="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option> <option value="marketing">{t('admin.users.roles.marketing')}</option>
@@ -320,36 +287,49 @@ export default function AdminUsersPage() {
<option value="admin">{t('admin.users.roles.admin')}</option> <option value="admin">{t('admin.users.roles.admin')}</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label> <label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label>
<select value={editForm.languagePreference} <select
value={editForm.languagePreference}
onChange={(e) => setEditForm({ ...editForm, languagePreference: e.target.value })} onChange={(e) => setEditForm({ ...editForm, languagePreference: 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 min-h-[44px]"> className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
>
<option value="">Not set</option> <option value="">Not set</option>
<option value="en">English</option> <option value="en">English</option>
<option value="es">Espa&#241;ol</option> <option value="es">Espa&#241;ol</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label> <label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label>
<select value={editForm.accountStatus} <select
value={editForm.accountStatus}
onChange={(e) => setEditForm({ ...editForm, accountStatus: e.target.value })} onChange={(e) => setEditForm({ ...editForm, accountStatus: 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 min-h-[44px]"> className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
>
<option value="active">Active</option> <option value="active">Active</option>
<option value="unclaimed">Unclaimed</option> <option value="unclaimed">Unclaimed</option>
<option value="suspended">Suspended</option> <option value="suspended">Suspended</option>
</select> </select>
</div> </div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setEditingUser(null)} className="flex-1 min-h-[44px]">Cancel</Button> <div className="flex gap-4 justify-end mt-6 pt-4 border-t border-secondary-light-gray">
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">Save Changes</Button> <Button
type="button"
variant="outline"
onClick={() => setEditingUser(null)}
>
Cancel
</Button>
<Button type="submit" isLoading={saving}>
Save Changes
</Button>
</div> </div>
</form> </form>
</Card> </Card>
</div> </div>
)} )}
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -2,13 +2,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils'; import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import { import {
CalendarIcon, CalendarIcon,
MapPinIcon, MapPinIcon,
ChatBubbleLeftRightIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
export default function LinktreePage() { export default function LinktreePage() {
@@ -24,16 +24,7 @@ export default function LinktreePage() {
useEffect(() => { useEffect(() => {
eventsApi.getNextUpcoming() eventsApi.getNextUpcoming()
.then(({ event }) => { .then(({ event }) => setNextEvent(event))
if (event) {
const endTime = event.endDatetime || event.startDatetime;
if (new Date(endTime).getTime() <= Date.now()) {
setNextEvent(null);
return;
}
}
setNextEvent(event);
})
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -59,8 +50,8 @@ export default function LinktreePage() {
<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">
<div className="w-24 h-24 mx-auto rounded-full overflow-hidden flex items-center justify-center mb-4 shadow-lg bg-white"> <div className="w-24 h-24 mx-auto bg-primary-yellow rounded-full flex items-center justify-center mb-4 shadow-lg">
<Image src="/images/spanglish-icon.png" alt="Spanglish" width={96} height={96} className="object-contain" /> <ChatBubbleLeftRightIcon className="w-12 h-12 text-primary-dark" />
</div> </div>
<h1 className="text-2xl font-bold text-white">Spanglish</h1> <h1 className="text-2xl font-bold text-white">Spanglish</h1>
<p className="text-gray-400 mt-1">{t('linktree.tagline')}</p> <p className="text-gray-400 mt-1">{t('linktree.tagline')}</p>

View File

@@ -28,7 +28,7 @@ interface LlmsEvent {
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> { async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
try { try {
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, { const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
cache: 'no-store', next: { tags: ['next-event'] },
}); });
if (!response.ok) return null; if (!response.ok) return null;
const data = await response.json(); const data = await response.json();
@@ -41,7 +41,7 @@ async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
async function getUpcomingEvents(): Promise<LlmsEvent[]> { async function getUpcomingEvents(): Promise<LlmsEvent[]> {
try { try {
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, { const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
cache: 'no-store', next: { tags: ['next-event'] },
}); });
if (!response.ok) return []; if (!response.ok) return [];
const data = await response.json(); const data = await response.json();
@@ -115,7 +115,7 @@ function getEventStatus(event: LlmsEvent): string {
async function getHomepageFaqs(): Promise<LlmsFaq[]> { async function getHomepageFaqs(): Promise<LlmsFaq[]> {
try { try {
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, { const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
cache: 'no-store', next: { revalidate: 3600 },
}); });
if (!response.ok) return []; if (!response.ok) return [];
const data = await response.json(); const data = await response.json();
@@ -128,8 +128,6 @@ async function getHomepageFaqs(): Promise<LlmsFaq[]> {
} }
} }
export const dynamic = 'force-dynamic';
export async function GET() { export async function GET() {
const [nextEvent, upcomingEvents, faqs] = await Promise.all([ const [nextEvent, upcomingEvents, faqs] = await Promise.all([
getNextUpcomingEvent(), getNextUpcomingEvent(),

View File

@@ -1,183 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon, EllipsisVerticalIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
// ----- Skeleton loaders -----
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="animate-pulse">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-4 py-3 border-b border-gray-100">
<div className="h-4 bg-gray-200 rounded w-1/4" />
<div className="h-4 bg-gray-200 rounded w-1/5" />
<div className="h-4 bg-gray-200 rounded w-16" />
<div className="h-4 bg-gray-200 rounded w-20" />
<div className="h-4 bg-gray-200 rounded w-16 ml-auto" />
</div>
))}
</div>
);
}
export function CardSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="space-y-3 animate-pulse">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="bg-white rounded-card shadow-card p-4">
<div className="flex items-center justify-between mb-2">
<div className="h-4 bg-gray-200 rounded w-1/3" />
<div className="h-5 bg-gray-200 rounded-full w-16" />
</div>
<div className="h-3 bg-gray-200 rounded w-1/2 mb-2" />
<div className="h-3 bg-gray-200 rounded w-1/4" />
</div>
))}
</div>
);
}
// ----- Dropdown component (portal-based to escape overflow:hidden) -----
export function Dropdown({ trigger, children, open, onOpenChange, align = 'right' }: {
trigger: React.ReactNode;
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
align?: 'left' | 'right';
}) {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useEffect(() => {
if (open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const menuWidth = 192;
let left = align === 'right' ? rect.right - menuWidth : rect.left;
left = Math.max(8, Math.min(left, window.innerWidth - menuWidth - 8));
setPos({ top: rect.bottom + 4, left });
}
}, [open, align]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (
triggerRef.current && !triggerRef.current.contains(target) &&
menuRef.current && !menuRef.current.contains(target)
) {
onOpenChange(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open, onOpenChange]);
useEffect(() => {
if (!open) return;
const handler = () => onOpenChange(false);
window.addEventListener('scroll', handler, true);
return () => window.removeEventListener('scroll', handler, true);
}, [open, onOpenChange]);
return (
<>
<div ref={triggerRef} className="inline-block">
<div onClick={() => onOpenChange(!open)}>{trigger}</div>
</div>
{open && pos && createPortal(
<div
ref={menuRef}
className="fixed z-[9999] w-48 bg-white border border-secondary-light-gray rounded-btn shadow-lg py-1"
style={{ top: pos.top, left: pos.left }}
>
{children}
</div>,
document.body
)}
</>
);
}
export function DropdownItem({ onClick, children, className }: { onClick: () => void; children: React.ReactNode; className?: string }) {
return (
<button onClick={onClick} className={clsx('w-full text-left px-4 py-2 text-sm hover:bg-gray-50 min-h-[44px] flex items-center', className)}>
{children}
</button>
);
}
// ----- Bottom Sheet (mobile) -----
export function BottomSheet({ open, onClose, title, children }: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center md:hidden" onClick={onClose}>
<div className="fixed inset-0 bg-black/50" />
<div
className="relative w-full bg-white rounded-t-2xl max-h-[80vh] overflow-auto animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-gray-100 sticky top-0 bg-white z-10">
<h3 className="font-semibold text-base">{title}</h3>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}
// ----- More Menu (per-row) -----
export function MoreMenu({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<Dropdown
open={open}
onOpenChange={setOpen}
trigger={
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<EllipsisVerticalIcon className="w-5 h-5 text-gray-500" />
</button>
}
>
{children}
</Dropdown>
);
}
// ----- Global CSS for animations -----
export function AdminMobileStyles() {
return (
<style jsx global>{`
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.25s ease-out;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
`}</style>
);
}

View File

@@ -43,7 +43,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
export default function Header() { export default function Header() {
const { t } = useLanguage(); const { t } = useLanguage();
const { user, hasAdminAccess, logout } = useAuth(); const { user, isAdmin, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef<number>(0); const touchStartX = useRef<number>(0);
@@ -148,7 +148,7 @@ export default function Header() {
{t('nav.dashboard')} {t('nav.dashboard')}
</Button> </Button>
</Link> </Link>
{hasAdminAccess && ( {isAdmin && (
<Link href="/admin"> <Link href="/admin">
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
{t('nav.admin')} {t('nav.admin')}
@@ -270,7 +270,7 @@ export default function Header() {
{t('nav.dashboard')} {t('nav.dashboard')}
</Button> </Button>
</Link> </Link>
{hasAdminAccess && ( {isAdmin && (
<Link href="/admin" onClick={closeMenu}> <Link href="/admin" onClick={closeMenu}>
<Button variant="outline" className="w-full justify-center"> <Button variant="outline" className="w-full justify-center">
{t('nav.admin')} {t('nav.admin')}

View File

@@ -21,7 +21,6 @@ interface AuthContextType {
token: string | null; token: string | null;
isLoading: boolean; isLoading: boolean;
isAdmin: boolean; isAdmin: boolean;
hasAdminAccess: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (credential: string) => Promise<void>; loginWithGoogle: (credential: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<void>; loginWithMagicLink: (token: string) => Promise<void>;
@@ -178,7 +177,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const isAdmin = user?.role === 'admin' || user?.role === 'organizer'; const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
const hasAdminAccess = user?.role === 'admin' || user?.role === 'organizer' || user?.role === 'staff' || user?.role === 'marketing';
return ( return (
<AuthContext.Provider <AuthContext.Provider
@@ -187,7 +185,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
token, token,
isLoading, isLoading,
isAdmin, isAdmin,
hasAdminAccess,
login, login,
loginWithGoogle, loginWithGoogle,
loginWithMagicLink, loginWithMagicLink,

View File

@@ -1,41 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
export function useStatsPrivacy() {
const [showStats, setShowStatsState] = useState(true);
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
setShowStatsState(stored !== 'true');
}
} catch {
// ignore
}
}, []);
const setShowStats = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
setShowStatsState((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
try {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, String(!next));
}
} catch {
// ignore
}
return next;
});
}, []);
const toggleStats = useCallback(() => {
setShowStats((prev) => !prev);
}, [setShowStats]);
return [showStats, setShowStats, toggleStats] as const;
}

View File

@@ -93,27 +93,6 @@ export const ticketsApi = {
body: JSON.stringify({ code, eventId }), body: JSON.stringify({ code, eventId }),
}), }),
// Search tickets by name/email (for scanner manual search)
search: (query: string, eventId?: string) =>
fetchApi<{ tickets: TicketSearchResult[] }>('/api/tickets/search', {
method: 'POST',
body: JSON.stringify({ query, eventId }),
}),
// Get event check-in stats (for scanner header counter)
getCheckinStats: (eventId: string) =>
fetchApi<{ eventId: string; capacity: number; checkedIn: number; totalActive: number }>(
`/api/tickets/stats/checkin?eventId=${eventId}`
),
// Live search tickets (GET - for scanner live search with debounce)
searchLive: (q: string, eventId?: string) => {
const params = new URLSearchParams();
params.set('q', q);
if (eventId) params.set('eventId', eventId);
return fetchApi<{ tickets: LiveSearchResult[] }>(`/api/tickets/search?${params}`);
},
checkin: (id: string) => checkin: (id: string) =>
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; 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',
@@ -236,13 +215,11 @@ export const usersApi = {
// Payments API // Payments API
export const paymentsApi = { export const paymentsApi = {
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => { getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.status) query.set('status', params.status); if (params?.status) query.set('status', params.status);
if (params?.provider) query.set('provider', params.provider); if (params?.provider) query.set('provider', params.provider);
if (params?.pendingApproval) query.set('pendingApproval', 'true'); if (params?.pendingApproval) query.set('pendingApproval', 'true');
if (params?.eventId) query.set('eventId', params.eventId);
if (params?.eventIds && params.eventIds.length > 0) query.set('eventIds', params.eventIds.join(','));
return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`); return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`);
}, },
@@ -374,49 +351,6 @@ export const adminApi = {
if (params?.eventId) query.set('eventId', params.eventId); if (params?.eventId) query.set('eventId', params.eventId);
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`); return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
}, },
/** Download attendee export as a file (CSV). Returns a Blob. */
exportAttendees: async (eventId: string, params?: { status?: string; format?: string; q?: string }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.format) query.set('format', params.format);
if (params?.q) query.set('q', params.q);
const token = typeof window !== 'undefined'
? localStorage.getItem('spanglish-token')
: null;
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/attendees/export?${query}`, { headers });
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
throw new Error(errorData.error || 'Export failed');
}
const disposition = res.headers.get('Content-Disposition') || '';
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
const filename = filenameMatch ? filenameMatch[1] : `attendees-${new Date().toISOString().split('T')[0]}.csv`;
const blob = await res.blob();
return { blob, filename };
},
/** Download tickets export as CSV. Returns a Blob. */
exportTicketsCSV: async (eventId: string, params?: { status?: string; q?: string }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.q) query.set('q', params.q);
const token = typeof window !== 'undefined'
? localStorage.getItem('spanglish-token')
: null;
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/tickets/export?${query}`, { headers });
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
throw new Error(errorData.error || 'Export failed');
}
const disposition = res.headers.get('Content-Disposition') || '';
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
const filename = filenameMatch ? filenameMatch[1] : `tickets-${new Date().toISOString().split('T')[0]}.csv`;
const blob = await res.blob();
return { blob, filename };
},
}; };
// Emails API // Emails API
@@ -450,7 +384,7 @@ export const emailsApi = {
customVariables?: Record<string, any>; customVariables?: Record<string, any>;
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in'; recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
}) => }) =>
fetchApi<{ success: boolean; queuedCount: number; error?: string }>( fetchApi<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }>(
`/api/emails/send/event/${eventId}`, `/api/emails/send/event/${eventId}`,
{ {
method: 'POST', method: 'POST',
@@ -493,11 +427,6 @@ export const emailsApi = {
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`), getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
resendLog: (id: string) =>
fetchApi<{ success: boolean; error?: string }>(`/api/emails/logs/${id}/resend`, {
method: 'POST',
}),
getStats: (eventId?: string) => { getStats: (eventId?: string) => {
const query = eventId ? `?eventId=${eventId}` : ''; const query = eventId ? `?eventId=${eventId}` : '';
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`); return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
@@ -523,7 +452,7 @@ export interface Event {
price: number; price: number;
currency: string; currency: string;
capacity: number; capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string; bannerUrl?: string;
externalBookingEnabled?: boolean; externalBookingEnabled?: boolean;
externalBookingUrl?: string; externalBookingUrl?: string;
@@ -579,39 +508,6 @@ export interface TicketValidationResult {
error?: string; error?: string;
} }
export interface TicketSearchResult {
id: string;
qrCode: string;
attendeeName: string;
attendeeEmail?: string;
attendeePhone?: string;
status: string;
checkinAt?: string;
event?: {
id: string;
title: string;
startDatetime: string;
location: string;
} | null;
}
export interface LiveSearchResult {
ticket_id: string;
name: string;
email?: string;
status: string;
checked_in: boolean;
checkinAt?: string;
event_id: string;
qrCode: string;
event?: {
id: string;
title: string;
startDatetime: string;
location: string;
} | null;
}
export interface Payment { export interface Payment {
id: string; id: string;
ticketId: string; ticketId: string;
@@ -797,8 +693,6 @@ export interface EmailLog {
sentAt?: string; sentAt?: string;
sentBy?: string; sentBy?: string;
createdAt: string; createdAt: string;
resendAttempts?: number;
lastResentAt?: string;
} }
export interface EmailStats { export interface EmailStats {
@@ -1114,34 +1008,6 @@ export const siteSettingsApi = {
}), }),
}; };
// ==================== Legal Settings API ====================
export interface LegalSettingsData {
id?: string;
companyName?: string | null;
legalEntityName?: string | null;
rucNumber?: string | null;
companyAddress?: string | null;
companyCity?: string | null;
companyCountry?: string | null;
supportEmail?: string | null;
legalEmail?: string | null;
governingLaw?: string | null;
jurisdictionCity?: string | null;
updatedAt?: string;
updatedBy?: string;
}
export const legalSettingsApi = {
get: () => fetchApi<{ settings: LegalSettingsData }>('/api/legal-settings'),
update: (data: Partial<LegalSettingsData>) =>
fetchApi<{ settings: LegalSettingsData; message: string }>('/api/legal-settings', {
method: 'PUT',
body: JSON.stringify(data),
}),
};
// ==================== Legal Pages Types ==================== // ==================== Legal Pages Types ====================
export interface LegalPage { export interface LegalPage {

View File

@@ -102,7 +102,7 @@ export function getLegalPageFromFilesystem(slug: string, locale: string = 'en'):
// Get a specific legal page content - tries API first, falls back to filesystem // Get a specific legal page content - tries API first, falls back to filesystem
export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise<LegalPage | null> { export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise<LegalPage | null> {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
// Try to fetch from API with locale parameter // Try to fetch from API with locale parameter
try { try {

View File

@@ -15,9 +15,7 @@
"start:frontend": "npm run start --workspace=frontend", "start:frontend": "npm run start --workspace=frontend",
"db:generate": "npm run db:generate --workspace=backend", "db:generate": "npm run db:generate --workspace=backend",
"db:migrate": "npm run db:migrate --workspace=backend", "db:migrate": "npm run db:migrate --workspace=backend",
"db:studio": "npm run db:studio --workspace=backend", "db:studio": "npm run db:studio --workspace=backend"
"db:export": "npm run db:export --workspace=backend --",
"db:import": "npm run db:import --workspace=backend --"
}, },
"workspaces": [ "workspaces": [
"backend", "backend",