3 Commits

Author SHA1 Message Date
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
66 changed files with 3562 additions and 6998 deletions

View File

@@ -64,8 +64,6 @@ npm run start
npm run db:generate
npm run db:migrate
npm run db:studio
npm run db:export # Backup database
npm run db:import # Restore from backup
```
You can also run per workspace:
@@ -119,25 +117,6 @@ Then run:
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)
This repo includes example configs in `deploy/`:

View File

@@ -19,7 +19,7 @@ GOOGLE_CLIENT_ID=
# Server Configuration
PORT=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)
# 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
# Mailgun: SMTP_HOST=smtp.mailgun.org, 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",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"db:export": "tsx src/db/export.ts",
"db:import": "tsx src/db/import.ts"
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@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`
CREATE TABLE IF NOT EXISTS email_settings (
id TEXT PRIMARY KEY,
@@ -444,25 +437,6 @@ async function migrate() {
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 {
// PostgreSQL migrations
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`
CREATE TABLE IF NOT EXISTS email_settings (
id UUID PRIMARY KEY,
@@ -855,25 +822,6 @@ async function migrate() {
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!');

View File

@@ -75,7 +75,7 @@ export const sqliteEvents = sqliteTable('events', {
price: real('price').notNull().default(0),
currency: text('currency').notNull().default('PYG'),
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'),
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
externalBookingUrl: text('external_booking_url'),
@@ -243,8 +243,6 @@ export const sqliteEmailLogs = sqliteTable('email_logs', {
sentAt: text('sent_at'),
sentBy: text('sent_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(),
resendAttempts: integer('resend_attempts').notNull().default(0),
lastResentAt: text('last_resent_at'),
});
export const sqliteEmailSettings = sqliteTable('email_settings', {
@@ -283,23 +281,6 @@ export const sqliteFaqQuestions = sqliteTable('faq_questions', {
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
export const sqliteSiteSettings = sqliteTable('site_settings', {
id: text('id').primaryKey(),
@@ -559,8 +540,6 @@ export const pgEmailLogs = pgTable('email_logs', {
sentAt: timestamp('sent_at'),
sentBy: uuid('sent_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(),
resendAttempts: pgInteger('resend_attempts').notNull().default(0),
lastResentAt: timestamp('last_resent_at'),
});
export const pgEmailSettings = pgTable('email_settings', {
@@ -599,23 +578,6 @@ export const pgFaqQuestions = pgTable('faq_questions', {
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
export const pgSiteSettings = pgTable('site_settings', {
id: uuid('id').primaryKey(),
@@ -661,7 +623,6 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings;
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
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 FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
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 siteSettingsRoutes from './routes/site-settings.js';
import legalPagesRoutes from './routes/legal-pages.js';
import legalSettingsRoutes from './routes/legal-settings.js';
import faqRoutes from './routes/faq.js';
import emailService from './lib/email.js';
import { initEmailQueue } from './lib/emailQueue.js';
const app = new Hono();
@@ -1858,7 +1856,6 @@ app.route('/api/payment-options', paymentOptionsRoutes);
app.route('/api/dashboard', dashboardRoutes);
app.route('/api/site-settings', siteSettingsRoutes);
app.route('/api/legal-pages', legalPagesRoutes);
app.route('/api/legal-settings', legalSettingsRoutes);
app.route('/api/faq', faqRoutes);
// 404 handler
@@ -1874,9 +1871,6 @@ app.onError((err, c) => {
const port = parseInt(process.env.PORT || '3001');
// Initialize email queue with the email service reference
initEmailQueue(emailService);
// Initialize email templates on startup
emailService.seedDefaultTemplates().catch(err => {
console.error('[Email] Failed to seed templates:', err);

View File

@@ -10,7 +10,6 @@ import {
defaultTemplates,
type DefaultTemplate
} from './emailTemplates.js';
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
@@ -1174,100 +1173,6 @@ export const emailService = {
errors,
};
},
/**
* 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)
@@ -1278,11 +1183,10 @@ export const emailService = {
subject: string;
bodyHtml: string;
bodyText?: string;
replyTo?: string;
eventId?: string;
sentBy?: string | null;
sentBy: 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 = {
...this.getCommonVariables(),
@@ -1304,7 +1208,7 @@ export const emailService = {
subject,
bodyHtml: finalBodyHtml,
status: 'pending',
sentBy: sentBy || null,
sentBy,
createdAt: now,
});
@@ -1314,7 +1218,6 @@ export const emailService = {
subject,
html: finalBodyHtml,
text: bodyText,
replyTo,
});
// Update log
@@ -1342,61 +1245,6 @@ export const emailService = {
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

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 { 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 { getNow } from '../lib/utils.js';
@@ -222,211 +222,6 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
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)
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
const startDate = c.req.query('startDate');

View File

@@ -1,37 +1,13 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
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 { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
import { emailService } from '../lib/email.js';
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({
name: z.string().min(2),
email: z.string().email(),
@@ -53,74 +29,17 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
const now = getNow();
const id = generateId();
// Sanitize header-sensitive values to prevent email header injection
const sanitizedEmail = sanitizeHeaderValue(data.email);
const sanitizedName = sanitizeHeaderValue(data.name);
const newContact = {
id,
name: sanitizedName,
email: sanitizedEmail,
name: data.name,
email: data.email,
message: data.message,
status: 'new' as const,
createdAt: now,
};
// Always store the message in admin, regardless of email outcome
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);
});

View File

@@ -5,7 +5,6 @@ import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js';
import emailService from '../lib/email.js';
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
import { getQueueStatus } from '../lib/emailQueue.js';
const emailsRouter = new Hono();
@@ -196,7 +195,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
// ==================== 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) => {
const { eventId } = c.req.param();
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);
}
// Queue emails for background processing instead of sending synchronously
const result = await emailService.queueEventEmails({
const result = await emailService.sendToEventAttendees({
eventId,
templateSlug,
customVariables,
@@ -349,23 +347,6 @@ emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) =>
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
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
@@ -430,10 +411,4 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
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;

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 { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
interface UserContext {
id: string;
@@ -16,6 +15,29 @@ interface 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
// PostgreSQL decimal returns strings, booleans are stored as integers
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),
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),
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
bannerUrl: z.string().optional().nullable().or(z.literal('')),
// 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
eventsRouter.get('/next/upcoming', async (c) => {
const now = getNow();
const nowMs = Date.now();
// First, check if there's a featured event in site settings
const settings = await dbGet<any>(
@@ -231,6 +252,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
let shouldUnsetFeatured = false;
if (settings?.featuredEventId) {
// Get the featured event
featuredEvent = await dbGet<any>(
(db as any)
.select()
@@ -239,30 +261,37 @@ eventsRouter.get('/next/upcoming', async (c) => {
);
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 isPublished = featuredEvent.status === 'published';
const hasNotEnded = new Date(eventEndTime).getTime() > nowMs;
const hasNotEnded = eventEndTime >= now;
if (!isPublished || !hasNotEnded) {
// Featured event is no longer valid - mark for unsetting
shouldUnsetFeatured = true;
featuredEvent = null;
}
} else {
// Featured event no longer exists
shouldUnsetFeatured = true;
}
}
// If we need to unset the featured event, do it asynchronously
if (shouldUnsetFeatured && settings) {
try {
await (db as any)
.update(siteSettings)
.set({ featuredEventId: null, updatedAt: now })
.where(eq((siteSettings as any).id, settings.id));
console.log('Featured event auto-cleared (event ended or unpublished)');
revalidateFrontendCache();
} catch (err: any) {
console.error('Failed to clear featured event:', err);
}
// Unset featured event in background (don't await to avoid blocking response)
(db as any)
.update(siteSettings)
.set({ featuredEventId: null, updatedAt: now })
.where(eq((siteSettings as any).id, settings.id))
.then(() => {
console.log('Featured event auto-cleared (event ended or unpublished)');
})
.catch((err: any) => {
console.error('Failed to clear featured event:', err);
});
}
// 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 { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js';
import { replaceLegalPlaceholders } from '../lib/legal-placeholders.js';
import fs from 'fs';
import path from 'path';
@@ -172,15 +171,12 @@ legalPagesRouter.get('/:slug', async (c) => {
// Get localized content with fallback
const { title, contentMarkdown } = getLocalizedContent(page, locale);
// Replace legal placeholders before returning
const processedContent = await replaceLegalPlaceholders(contentMarkdown, page.updatedAt);
return c.json({
page: {
id: page.id,
slug: page.slug,
title,
contentMarkdown: processedContent,
contentMarkdown,
updatedAt: page.updatedAt,
source: 'database',
}
@@ -199,14 +195,11 @@ legalPagesRouter.get('/:slug', async (c) => {
? (titles?.es || titles?.en || slug)
: (titles?.en || titles?.es || slug);
// Replace legal placeholders in filesystem content too
const processedContent = await replaceLegalPlaceholders(content);
return c.json({
page: {
slug,
title,
contentMarkdown: processedContent,
contentMarkdown: content,
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 provider = c.req.query('provider');
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
let allPayments = await dbAll<any>(
@@ -57,7 +55,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
}
// Enrich with ticket and event data
let enrichedPayments = await Promise.all(
const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => {
const ticket = await dbGet<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 });
});

View File

@@ -5,7 +5,6 @@ import { db, dbGet, siteSettings, events } from '../db/index.js';
import { eq, and, gte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
interface UserContext {
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))
);
// Revalidate frontend cache if featured event changed
if (data.featuredEventId !== undefined) {
revalidateFrontendCache();
}
return c.json({ settings: updated, message: 'Settings updated successfully' });
});
@@ -200,15 +194,8 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
if (event.status !== 'published') {
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
const existing = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
@@ -229,9 +216,6 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
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' });
}
@@ -245,9 +229,6 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
})
.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' });
});

View File

@@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
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 { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.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);
}
if (!['published', 'unlisted'].includes(event.status)) {
if (event.status !== 'published') {
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
ticketsRouter.get('/:id', async (c) => {
const id = c.req.param('id');
@@ -673,65 +554,6 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
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)
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const body = await c.req.json().catch(() => ({}));
@@ -1394,7 +1216,7 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
}, 201);
});
// Get all tickets (admin) - includes payment for each ticket
// Get all tickets (admin)
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
const status = c.req.query('status');
@@ -1413,25 +1235,9 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(and(...conditions));
}
const ticketsList = await dbAll(query);
const ticketIds = ticketsList.map((t: any) => t.id);
let paymentByTicketId: Record<string, any> = {};
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 });
const result = await dbAll(query);
return c.json({ tickets: result });
});
export default ticketsRouter;

View File

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

View File

@@ -25,9 +25,6 @@ NEXT_PUBLIC_TIKTOK=spanglishsocialpy
# Must match the REVALIDATE_SECRET in backend/.env
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)
NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -6,7 +6,7 @@ import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { formatPrice } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
@@ -145,7 +145,7 @@ export default function BookingPage() {
paymentOptionsApi.getForEvent(params.eventId as string),
])
.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');
router.push('/events');
return;
@@ -217,8 +217,21 @@ export default function BookingPage() {
}
}, [user]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof BookingFormData, string>> = {};
@@ -866,7 +879,7 @@ export default function BookingPage() {
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {event && fmtTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {event && formatTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
</div>
</div>
@@ -942,7 +955,7 @@ export default function BookingPage() {
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {fmtTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {formatTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
</div>
</div>
@@ -1032,7 +1045,7 @@ export default function BookingPage() {
<div className="p-4 space-y-2 text-sm">
<div className="flex items-center gap-3">
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
<span>{formatDate(event.startDatetime)} {fmtTime(event.startDatetime)}</span>
<span>{formatDate(event.startDatetime)} {formatTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-3">
<MapPinIcon className="w-5 h-5 text-primary-yellow" />

View File

@@ -5,7 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { formatPrice } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
@@ -152,8 +152,21 @@ export default function BookingPaymentPage() {
}
};
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
// Loading state
if (step === 'loading') {
@@ -224,7 +237,7 @@ export default function BookingPaymentPage() {
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
</div>
)}
@@ -273,7 +286,7 @@ export default function BookingPaymentPage() {
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
</div>
)}
@@ -320,7 +333,7 @@ export default function BookingPaymentPage() {
<div className="p-4 space-y-2 text-sm">
<div className="flex items-center gap-3">
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
<span>{formatDate(ticket.event.startDatetime)} - {fmtTime(ticket.event.startDatetime)}</span>
<span>{formatDate(ticket.event.startDatetime)} - {formatTime(ticket.event.startDatetime)}</span>
</div>
<div className="flex items-center gap-3">
<MapPinIcon className="w-5 h-5 text-primary-yellow" />

View File

@@ -5,7 +5,6 @@ import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, Ticket } from '@/lib/api';
import { formatDateLong, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
@@ -70,8 +69,21 @@ export default function BookingSuccessPage() {
};
}, [ticketId]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
@@ -179,7 +191,7 @@ export default function BookingSuccessPage() {
<>
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
</>
)}

View File

@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
import { formatPrice } from '@/lib/utils';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
interface NextEventSectionProps {
initialEvent?: Event | null;
@@ -14,41 +16,32 @@ interface NextEventSectionProps {
export default function NextEventSection({ initialEvent }: NextEventSectionProps) {
const { t, locale } = useLanguage();
const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
const [loading, setLoading] = useState(initialEvent === undefined);
const [loading, setLoading] = useState(!initialEvent);
useEffect(() => {
if (initialEvent !== undefined) {
if (initialEvent) {
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;
}
// Skip fetch if we already have server-provided data
if (initialEvent !== undefined) return;
eventsApi.getNextUpcoming()
.then(({ event }) => setNextEvent(event))
.catch(console.error)
.finally(() => setLoading(false));
}, [initialEvent]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
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))
: '';
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
@@ -69,72 +62,56 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
}
return (
<Link href={`/events/${nextEvent.id}`} className="block group">
<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]">
<div className="flex flex-col md:flex-row">
{/* Banner */}
{nextEvent.bannerUrl ? (
<div className="relative w-full md:w-2/5 flex-shrink-0">
<img
src={nextEvent.bannerUrl}
alt={title}
className="w-full h-48 md:h-full object-cover"
/>
</div>
) : (
<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="flex-1 p-5 md:p-8 flex flex-col justify-between">
<div>
<h3 className="text-xl md:text-2xl font-bold text-primary-dark group-hover:text-brand-navy transition-colors">
{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>
<Link href={`/events/${nextEvent.id}`} className="block">
<Card variant="elevated" className="p-8 cursor-pointer hover:shadow-lg transition-shadow">
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-1">
<h3 className="text-2xl font-bold text-primary-dark">
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
</h3>
<p className="mt-3 text-gray-600 whitespace-pre-line">
{locale === 'es'
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
: (nextEvent.shortDescription || nextEvent.description)}
</p>
<div className="mt-6 space-y-3">
<div className="flex items-center gap-3 text-gray-700">
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
<span>{formatDate(nextEvent.startDatetime)}</span>
</div>
</div>
<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)}
<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">
</span>
{!nextEvent.externalBookingEnabled && nextEvent.availableSeats != null && (
<p className="text-xs text-gray-500 mt-0.5">
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
</p>
)}
<span>{formatTime(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-3 text-gray-700">
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
<span>{nextEvent.location}</span>
</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 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>
</Card>
</Link>
);
}

View File

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

View File

@@ -25,7 +25,6 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'America/Asuncion',
});
};

View File

@@ -118,7 +118,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
{profile?.memberSince
? new Date(profile.memberSince).toLocaleDateString(
language === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
{ year: 'numeric', month: 'long', day: 'numeric' }
)
: '-'}
</span>

View File

@@ -153,7 +153,6 @@ export default function SecurityTab() {
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
};

View File

@@ -30,7 +30,6 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'America/Asuncion',
});
};

View File

@@ -7,7 +7,6 @@ import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
import { formatDateLong, formatTime } from '@/lib/utils';
import toast from 'react-hot-toast';
import Link from 'next/link';
import {
@@ -86,8 +85,21 @@ export default function DashboardPage() {
);
}
const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es');
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(language === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="section-padding min-h-[70vh]">

View File

@@ -5,7 +5,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { formatPrice } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons';
@@ -54,13 +54,26 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
setTicketQuantity(prev => Math.min(maxTickets, prev + 1));
};
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const isCancelled = event.status === 'cancelled';
// Only calculate isPastEvent after mount to avoid hydration mismatch
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
const BookingCardContent = () => (
@@ -215,8 +228,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
<div>
<p className="font-medium text-sm">{t('events.details.time')}</p>
<p className="text-gray-600" suppressHydrationWarning>
{fmtTime(event.startDatetime)}
{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}
{formatTime(event.startDatetime)}
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
</p>
</div>
</div>

View File

@@ -20,7 +20,7 @@ interface Event {
price: number;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string;
availableSeats?: number;
bookedCount?: number;
@@ -95,9 +95,11 @@ function generateEventJsonLd(event: Event) {
startDate: event.startDatetime,
endDate: event.endDatetime || event.startDatetime,
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
eventStatus: isCancelled
? 'https://schema.org/EventCancelled'
: 'https://schema.org/EventScheduled',
eventStatus: isCancelled
? 'https://schema.org/EventCancelled'
: isPastEvent
? 'https://schema.org/EventPostponed'
: 'https://schema.org/EventScheduled',
location: {
'@type': 'Place',
name: event.location,

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import { formatPrice } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
@@ -33,8 +33,20 @@ export default function EventsPage() {
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusBadge = (event: Event) => {
if (event.status === 'cancelled') {
@@ -118,7 +130,7 @@ export default function EventsPage() {
<div className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
<span>{formatDate(event.startDatetime)} - {formatTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-2">
<MapPinIcon className="w-4 h-4" />

View File

@@ -20,7 +20,7 @@ export const metadata: Metadata = {
const organizationSchema = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Spanglish Community',
name: 'Spanglish',
url: siteUrl,
logo: `${siteUrl}/images/logo.png`,
description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.',
@@ -30,7 +30,7 @@ const organizationSchema = {
addressCountry: 'PY',
},
sameAs: [
'https://instagram.com/spanglishsocialpy',
process.env.NEXT_PUBLIC_INSTAGRAM_URL,
process.env.NEXT_PUBLIC_WHATSAPP_URL,
process.env.NEXT_PUBLIC_TELEGRAM_URL,
].filter(Boolean),

View File

@@ -38,10 +38,8 @@ interface NextEvent {
async function getNextUpcomingEvent(): Promise<NextEvent | null> {
try {
const revalidateSeconds =
parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600;
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
next: { tags: ['next-event'], revalidate: revalidateSeconds },
next: { tags: ['next-event'] },
});
if (!response.ok) return null;
const data = await response.json();
@@ -68,7 +66,6 @@ export async function generateMetadata(): Promise<Metadata> {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'America/Asuncion',
});
const description = `Next event: ${eventDate} ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;

View File

@@ -5,7 +5,6 @@ import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import {
TicketIcon,
CheckCircleIcon,
@@ -15,10 +14,8 @@ import {
EnvelopeIcon,
PhoneIcon,
FunnelIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
interface TicketWithDetails extends Omit<Ticket, 'payment'> {
bookingId?: string;
@@ -43,11 +40,10 @@ export default function AdminBookingsPage() {
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState<string | null>(null);
// Filters
const [selectedEvent, setSelectedEvent] = useState<string>('');
const [selectedStatus, setSelectedStatus] = useState<string>('');
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
useEffect(() => {
loadData();
@@ -59,13 +55,20 @@ export default function AdminBookingsPage() {
ticketsApi.getAll(),
eventsApi.getAll(),
]);
const ticketsWithEvent = ticketsRes.tickets.map((ticket) => ({
...ticket,
event: eventsRes.events.find((e) => e.id === ticket.eventId),
}));
setTickets(ticketsWithEvent);
// Fetch full ticket details with payment info
const ticketsWithDetails = await Promise.all(
ticketsRes.tickets.map(async (ticket) => {
try {
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id);
return fullTicket;
} catch {
return ticket;
}
})
);
setTickets(ticketsWithDetails);
setEvents(eventsRes.events);
} catch (error) {
toast.error('Failed to load bookings');
@@ -122,68 +125,67 @@ export default function AdminBookingsPage() {
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed': return 'bg-green-100 text-green-800';
case 'pending': return 'bg-yellow-100 text-yellow-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';
case 'confirmed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-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) => {
switch (status) {
case 'paid': return 'bg-green-100 text-green-800';
case 'pending': return 'bg-yellow-100 text-yellow-800';
case 'paid':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed':
case 'cancelled': return 'bg-red-100 text-red-800';
case 'refunded': return 'bg-purple-100 text-purple-800';
default: return 'bg-gray-100 text-gray-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
case 'refunded':
return 'bg-purple-100 text-purple-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getPaymentMethodLabel = (provider: string | null) => {
if (provider == null) return '—';
const labels: Record<string, string> = {
cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
lightning: 'Lightning',
tpago: 'TPago',
bancard: 'Bancard',
};
return labels[provider] || 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;
const getPaymentMethodLabel = (provider: string) => {
switch (provider) {
case 'bancard':
return 'TPago / Card';
case 'lightning':
return 'Bitcoin Lightning';
case 'cash':
return 'Cash at Event';
default:
return provider;
}
return null;
};
// Filter tickets
const filteredTickets = tickets.filter((ticket) => {
if (selectedEvent && ticket.eventId !== selectedEvent) return false;
if (selectedStatus && ticket.status !== selectedStatus) 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;
});
// Sort by created date (newest first)
const sortedTickets = [...filteredTickets].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
// Stats
const stats = {
total: tickets.length,
pending: tickets.filter(t => t.status === 'pending').length,
@@ -193,36 +195,23 @@ export default function AdminBookingsPage() {
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) => {
if (!ticket.bookingId) {
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 {
ticketCount: bookingTickets.length,
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) {
return (
<div className="flex items-center justify-center py-12">
@@ -234,61 +223,51 @@ export default function AdminBookingsPage() {
return (
<div>
<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>
{/* Stats Cards */}
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-6 gap-2 md:gap-4 mb-6">
<Card className="p-3 md:p-4 text-center">
<p className="text-xl md:text-2xl font-bold text-primary-dark">{stats.total}</p>
<p className="text-xs md:text-sm text-gray-500">Total</p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-primary-dark">{stats.total}</p>
<p className="text-sm text-gray-500">Total</p>
</Card>
<Card className="p-3 md: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-xs md:text-sm text-gray-500">Pending</p>
<Card className="p-4 text-center border-l-4 border-yellow-400">
<p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
<p className="text-sm text-gray-500">Pending</p>
</Card>
<Card className="p-3 md: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-xs md:text-sm text-gray-500">Confirmed</p>
<Card className="p-4 text-center border-l-4 border-green-400">
<p className="text-2xl font-bold text-green-600">{stats.confirmed}</p>
<p className="text-sm text-gray-500">Confirmed</p>
</Card>
<Card className="p-3 md: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-xs md:text-sm text-gray-500">Checked In</p>
<Card className="p-4 text-center border-l-4 border-blue-400">
<p className="text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
<p className="text-sm text-gray-500">Checked In</p>
</Card>
<Card className="p-3 md: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-xs md:text-sm text-gray-500">Cancelled</p>
<Card className="p-4 text-center border-l-4 border-red-400">
<p className="text-2xl font-bold text-red-600">{stats.cancelled}</p>
<p className="text-sm text-gray-500">Cancelled</p>
</Card>
<Card className="p-3 md: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-xs md:text-sm text-gray-500">Pending Pay</p>
<Card className="p-4 text-center border-l-4 border-orange-400">
<p className="text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
<p className="text-sm text-gray-500">Pending Payment</p>
</Card>
</div>
{/* Desktop Filters */}
<Card className="p-4 mb-6 hidden md:block">
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex items-center gap-2 mb-4">
<FunnelIcon className="w-5 h-5 text-gray-500" />
<span className="font-medium">Filters</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 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 className="grid grid-cols-1 md:grid-cols-3 gap-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 rounded-btn border border-secondary-light-gray text-sm">
<select
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>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
@@ -297,8 +276,11 @@ export default function AdminBookingsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
<select value={selectedStatus} onChange={(e) => setSelectedStatus(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
<select
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="pending">Pending</option>
<option value="confirmed">Confirmed</option>
@@ -308,9 +290,12 @@ export default function AdminBookingsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
<select value={selectedPaymentStatus} onChange={(e) => setSelectedPaymentStatus(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All Payments</option>
<select
value={selectedPaymentStatus}
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="paid">Paid</option>
<option value="refunded">Refunded</option>
@@ -318,66 +303,26 @@ export default function AdminBookingsPage() {
</select>
</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>
{/* Mobile Toolbar */}
<div className="md:hidden space-y-2 mb-4">
<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">
{/* Bookings List */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<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-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">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-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">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-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Payment</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{sortedTickets.length === 0 ? (
<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.
</td>
</tr>
@@ -385,69 +330,123 @@ export default function AdminBookingsPage() {
sortedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket);
return (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
</td>
<td className="px-4 py-3">
<span className="text-sm truncate max-w-[150px] block">
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4 text-gray-400" />
<span className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<EnvelopeIcon className="w-4 h-4" />
<span>{ticket.attendeeEmail || 'N/A'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<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'}
</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 && (
<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>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
{ticket.status.replace('_', ' ')}
</span>
{ticket.bookingId && (
<p className="text-[10px] text-purple-600 mt-0.5">Group Booking</p>
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
{ticket.status}
</span>
{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">
{formatDate(ticket.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
<Button size="sm" variant="outline" onClick={() => handleMarkPaid(ticket.id)}
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
Mark Paid
</Button>
)}
{ticket.status === 'confirmed' && (
<Button size="sm" onClick={() => handleCheckin(ticket.id)}
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
Check In
</Button>
)}
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
<MoreMenu>
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel
</DropdownItem>
</MoreMenu>
)}
{ticket.status === 'checked_in' && (
<span className="text-xs text-green-600 flex items-center gap-1">
<CheckCircleIcon className="w-4 h-4" /> Attended
</span>
)}
{ticket.status === 'cancelled' && (
<span className="text-xs text-gray-400">Cancelled</span>
)}
</div>
</td>
</tr>
{/* Check-in (for confirmed tickets) */}
{ticket.status === 'confirmed' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleCheckin(ticket.id)}
isLoading={processing === ticket.id}
className="text-blue-600 hover:bg-blue-50"
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
Check In
</Button>
)}
{/* Cancel (for pending/confirmed) */}
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
<Button
size="sm"
variant="ghost"
onClick={() => handleCancel(ticket.id)}
isLoading={processing === ticket.id}
className="text-red-600 hover:bg-red-50"
>
<XCircleIcon className="w-4 h-4 mr-1" />
Cancel
</Button>
)}
{ticket.status === 'checked_in' && (
<span className="text-sm text-green-600 flex items-center gap-1">
<CheckCircleIcon className="w-4 h-4" />
Attended
</span>
)}
{ticket.status === 'cancelled' && (
<span className="text-sm text-gray-400">Cancelled</span>
)}
</div>
</td>
</tr>
);
})
)}
@@ -455,158 +454,6 @@ export default function AdminBookingsPage() {
</table>
</div>
</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>
);
}

View File

@@ -49,7 +49,6 @@ export default function AdminContactsPage() {
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
};

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,23 +2,19 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import MediaPicker from '@/components/MediaPicker';
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, XMarkIcon, LinkIcon } from '@heroicons/react/24/outline';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon } from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
import toast from 'react-hot-toast';
import clsx from 'clsx';
export default function AdminEventsPage() {
const router = useRouter();
const { t, locale } = useLanguage();
const searchParams = useSearchParams();
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
@@ -41,7 +37,7 @@ export default function AdminEventsPage() {
price: number;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl: string;
externalBookingEnabled: boolean;
externalBookingUrl: string;
@@ -70,14 +66,6 @@ export default function AdminEventsPage() {
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 () => {
try {
const { events } = await eventsApi.getAll();
@@ -94,7 +82,7 @@ export default function AdminEventsPage() {
const { settings } = await siteSettingsApi.get();
setFeaturedEventId(settings.featuredEventId || null);
} 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 = () => {
setFormData({
title: '', titleEs: '', description: '', descriptionEs: '',
shortDescription: '', shortDescriptionEs: '',
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
title: '',
titleEs: '',
description: '',
descriptionEs: '',
shortDescription: '',
shortDescriptionEs: '',
startDatetime: '',
endDatetime: '',
location: '',
locationUrl: '',
price: 0,
currency: 'PYG',
capacity: 50,
status: 'draft' as const,
bannerUrl: '',
externalBookingEnabled: false,
externalBookingUrl: '',
});
setEditingEvent(null);
};
// Convert ISO UTC string to local datetime-local format (YYYY-MM-DDTHH:MM)
const isoToLocalDatetime = (isoString: string): string => {
const date = new Date(isoString);
const year = date.getFullYear();
@@ -134,14 +135,21 @@ export default function AdminEventsPage() {
const handleEdit = (event: Event) => {
setFormData({
title: event.title, titleEs: event.titleEs || '',
description: event.description, descriptionEs: event.descriptionEs || '',
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
title: event.title,
titleEs: event.titleEs || '',
description: event.description,
descriptionEs: event.descriptionEs || '',
shortDescription: event.shortDescription || '',
shortDescriptionEs: event.shortDescriptionEs || '',
startDatetime: isoToLocalDatetime(event.startDatetime),
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
location: event.location, locationUrl: event.locationUrl || '',
price: event.price, currency: event.currency, capacity: event.capacity,
status: event.status, bannerUrl: event.bannerUrl || '',
location: event.location,
locationUrl: event.locationUrl || '',
price: event.price,
currency: event.currency,
capacity: event.capacity,
status: event.status,
bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '',
});
@@ -152,7 +160,9 @@ export default function AdminEventsPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
// Validate external booking URL if enabled
if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
toast.error('External booking URL is required when external booking is enabled');
setSaving(false);
@@ -163,18 +173,27 @@ export default function AdminEventsPage() {
setSaving(false);
return;
}
const eventData = {
title: formData.title, titleEs: formData.titleEs || undefined,
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
title: formData.title,
titleEs: formData.titleEs || undefined,
description: formData.description,
descriptionEs: formData.descriptionEs || undefined,
shortDescription: formData.shortDescription || undefined,
shortDescriptionEs: formData.shortDescriptionEs || undefined,
startDatetime: new Date(formData.startDatetime).toISOString(),
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
location: formData.location, locationUrl: formData.locationUrl || undefined,
price: formData.price, currency: formData.currency, capacity: formData.capacity,
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
location: formData.location,
locationUrl: formData.locationUrl || undefined,
price: formData.price,
currency: formData.currency,
capacity: formData.capacity,
status: formData.status,
bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
};
if (editingEvent) {
await eventsApi.update(editingEvent.id, eventData);
toast.success('Event updated');
@@ -182,6 +201,7 @@ export default function AdminEventsPage() {
await eventsApi.create(eventData);
toast.success('Event created');
}
setShowForm(false);
resetForm();
loadEvents();
@@ -194,6 +214,7 @@ export default function AdminEventsPage() {
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this event?')) return;
try {
await eventsApi.delete(id);
toast.success('Event deleted');
@@ -213,21 +234,22 @@ export default function AdminEventsPage() {
}
};
const formatDate = (dateStr: string) => {
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',
});
};
const isEventOver = (event: Event) => {
const refDate = event.endDatetime || event.startDatetime;
return new Date(refDate) < new Date();
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
draft: 'badge-gray', published: 'badge-success', unlisted: 'badge-warning',
cancelled: 'badge-danger', completed: 'badge-info', archived: 'badge-gray',
draft: 'badge-gray',
published: 'badge-success',
cancelled: 'badge-danger',
completed: 'badge-info',
archived: 'badge-gray',
};
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
};
@@ -263,8 +285,8 @@ export default function AdminEventsPage() {
return (
<div>
<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>
<Button onClick={() => { resetForm(); setShowForm(true); }} className="hidden md:flex">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
<Button onClick={() => { resetForm(); setShowForm(true); }}>
<PlusIcon className="w-5 h-5 mr-2" />
{t('admin.events.create')}
</Button>
@@ -272,148 +294,221 @@ export default function AdminEventsPage() {
{/* Event Form Modal */}
{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 className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 md:p-6 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-lg md:text-xl font-bold">
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
</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>
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold mb-6">
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
</h2>
<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">
<Input label="Title (English)" value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })} required />
<Input label="Title (Spanish)" value={formData.titleEs}
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
<Input
label="Title (English)"
value={formData.title}
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>
<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 })}
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>
<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 })}
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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Short Description (English)</label>
<textarea value={formData.shortDescription}
<textarea
value={formData.shortDescription}
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} maxLength={300} placeholder="Brief summary for SEO and cards (max 300 chars)" />
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300</p>
rows={2}
maxLength={300}
placeholder="Brief summary for SEO and cards (max 300 chars)"
/>
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300 characters</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
<textarea value={formData.shortDescriptionEs}
<textarea
value={formData.shortDescriptionEs}
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} maxLength={300} placeholder="Resumen breve (máx 300 caracteres)" />
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300</p>
rows={2}
maxLength={300}
placeholder="Resumen breve para SEO y tarjetas (máx 300 caracteres)"
/>
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300 characters</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Start Date & Time" type="datetime-local" 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 })} />
<Input
label="Start Date & Time"
type="datetime-local"
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>
<Input label="Location" value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
<Input
label="Location"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
required
/>
<div className="grid grid-cols-3 gap-4">
<Input label="Price" type="number" min="0" value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
<Input
label="Location URL (Google Maps)"
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>
<label className="block text-sm font-medium mb-1">Currency</label>
<select 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">
<select
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="USD">USD</option>
</select>
</div>
<Input label="Capacity" type="number" min="1" value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
<Input
label="Capacity"
type="number"
min="1"
value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })}
/>
</div>
<div>
<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 })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
<select
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="published">Published</option>
<option value="unlisted">Unlisted</option>
<option value="cancelled">Cancelled</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
</div>
{/* External Booking Section */}
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<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>
<button type="button"
<button
type="button"
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'
}`}>
<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>
</div>
{formData.externalBookingEnabled && (
<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 })}
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>
</div>
)}
</div>
<MediaPicker value={formData.bannerUrl}
{/* Image Upload / Media Picker */}
<MediaPicker
value={formData.bannerUrl}
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' && (
<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>
<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>
<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>
<button type="button" 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 disabled:opacity-50 ${
<button
type="button"
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'
}`}>
<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>
</div>
{featuredEventId && featuredEventId !== editingEvent.id && (
@@ -425,10 +520,14 @@ export default function AdminEventsPage() {
)}
<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'}
</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
</Button>
</div>
@@ -437,17 +536,17 @@ export default function AdminEventsPage() {
</div>
)}
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
{/* Events Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<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-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">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-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">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-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Date</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Capacity</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">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
@@ -459,95 +558,110 @@ export default function AdminEventsPage() {
</tr>
) : (
events.map((event) => (
<tr
key={event.id}
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">
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{event.bannerUrl ? (
<img src={event.bannerUrl} alt={event.title}
className="w-10 h-10 rounded-lg object-cover flex-shrink-0" />
<img
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">
<PhotoIcon className="w-5 h-5 text-gray-400" />
<div className="w-12 h-12 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
<PhotoIcon className="w-6 h-6 text-gray-400" />
</div>
)}
<div>
<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 && (
<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">
<StarIconSolid className="w-2.5 h-2.5" /> Featured
<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-3 h-3" />
Featured
</span>
)}
</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>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
<td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td>
<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 className="px-6 py-4 text-sm text-gray-600">
{formatDate(event.startDatetime)}
</td>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
<td className="px-6 py-4 text-sm">
{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' && (
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
<Button
size="sm"
variant="ghost"
onClick={() => handleStatusChange(event, 'published')}
>
Publish
</Button>
)}
{event.status === 'published' && (
<button onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
<button
onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
disabled={settingFeatured !== null}
className={clsx("p-2 rounded-btn disabled:opacity-50",
featuredEventId === event.id ? "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" />}
className={clsx(
"p-2 rounded-btn disabled:opacity-50",
featuredEventId === event.id
? "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>
)}
<Link href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn" title="Manage">
<Link
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" />
</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" />
</button>
<MoreMenu>
{(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>
)}
<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>
<button
onClick={() => handleDuplicate(event)}
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
title="Duplicate"
>
<DocumentDuplicateIcon className="w-4 h-4" />
</button>
{event.status !== 'archived' && (
<button
onClick={() => handleArchive(event)}
className="p-2 hover:bg-gray-100 text-gray-600 rounded-btn"
title="Archive"
>
<ArchiveBoxIcon className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDelete(event.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
@@ -557,113 +671,6 @@ export default function AdminEventsPage() {
</table>
</div>
</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>
);
}

View File

@@ -6,7 +6,6 @@ import { faqApi, FaqItemAdmin } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import {
@@ -16,14 +15,19 @@ import {
Bars3Icon,
XMarkIcon,
CheckIcon,
ChevronUpIcon,
ChevronDownIcon,
ArrowLeftIcon,
} from '@heroicons/react/24/outline';
type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean };
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() {
@@ -36,7 +40,9 @@ export default function AdminFaqPage() {
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
useEffect(() => { loadFaqs(); }, []);
useEffect(() => {
loadFaqs();
}, []);
const loadFaqs = async () => {
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) => {
setForm({
id: faq.id, question: faq.question, questionEs: faq.questionEs ?? '',
answer: faq.answer, answerEs: faq.answerEs ?? '', enabled: faq.enabled, showOnHomepage: faq.showOnHomepage,
id: faq.id,
question: faq.question,
questionEs: faq.questionEs ?? '',
answer: faq.answer,
answerEs: faq.answerEs ?? '',
enabled: faq.enabled,
showOnHomepage: faq.showOnHomepage,
});
setShowForm(true);
};
@@ -70,16 +84,22 @@ export default function AdminFaqPage() {
setSaving(true);
if (form.id) {
await faqApi.update(form.id, {
question: form.question.trim(), questionEs: form.questionEs.trim() || null,
answer: form.answer.trim(), answerEs: form.answerEs.trim() || null,
enabled: form.enabled, showOnHomepage: form.showOnHomepage,
question: form.question.trim(),
questionEs: form.questionEs.trim() || null,
answer: form.answer.trim(),
answerEs: form.answerEs.trim() || null,
enabled: form.enabled,
showOnHomepage: form.showOnHomepage,
});
toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated');
} else {
await faqApi.create({
question: form.question.trim(), questionEs: form.questionEs.trim() || undefined,
answer: form.answer.trim(), answerEs: form.answerEs.trim() || undefined,
enabled: form.enabled, showOnHomepage: form.showOnHomepage,
question: form.question.trim(),
questionEs: form.questionEs.trim() || undefined,
answer: form.answer.trim(),
answerEs: form.answerEs.trim() || undefined,
enabled: form.enabled,
showOnHomepage: form.showOnHomepage,
});
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) => {
setDraggedId(id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
};
const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverId(id);
};
const handleDragLeave = () => { setDragOverId(null); };
const handleDragLeave = () => {
setDragOverId(null);
};
const handleDrop = async (e: React.DragEvent, targetId: string) => {
e.preventDefault();
setDragOverId(null);
@@ -182,7 +180,11 @@ export default function AdminFaqPage() {
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) {
return (
@@ -196,120 +198,179 @@ export default function AdminFaqPage() {
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-xl md:text-2xl font-bold font-heading">FAQ</h1>
<p className="text-gray-500 text-xs md:text-sm mt-1 hidden md:block">
<h1 className="text-2xl font-bold font-heading">
{locale === 'es' ? 'FAQ' : 'FAQ'}
</h1>
<p className="text-gray-500 text-sm mt-1">
{locale === 'es'
? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.'
: 'Create and edit FAQ questions. Drag to change order.'}
</p>
</div>
<Button onClick={handleCreate} className="hidden md:flex">
<Button onClick={handleCreate}>
<PlusIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Nueva pregunta' : 'Add question'}
</Button>
</div>
{/* Form Modal - bottom sheet on mobile */}
{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 className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-semibold">
<Card>
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">
{form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')}
</h2>
<button onClick={() => { setForm(emptyForm); setShowForm(false); }}
className="p-2 hover:bg-gray-100 rounded-full min-h-[44px] min-w-[44px] flex items-center justify-center">
<button
onClick={() => { setForm(emptyForm); setShowForm(false); }}
className="p-2 hover:bg-gray-100 rounded-full"
>
<XMarkIcon className="w-5 h-5" />
</button>
</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>
<label className="block text-sm font-medium mb-1">Question (EN) *</label>
<Input value={form.question} onChange={e => setForm(f => ({ ...f, question: e.target.value }))} placeholder="Question in English" />
</div>
<div>
<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 className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-1">Question (EN) *</label>
<Input
value={form.question}
onChange={e => setForm(f => ({ ...f, question: e.target.value }))}
placeholder="Question in English"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<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 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>
<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>
</Card>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<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 className="hidden md:block">
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<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-2 text-left text-xs font-semibold text-gray-500 uppercase w-24">{locale === 'es' ? 'En sitio' : 'On site'}</th>
<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 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">
{locale === 'es' ? 'Pregunta' : 'Question'}
</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>
</thead>
<tbody className="divide-y divide-gray-200">
{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) => (
<tr key={faq.id} draggable 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')}>
<tr
key={faq.id}
draggable
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">
<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" />
</span>
</td>
<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 className="px-4 py-3">
<button onClick={() => handleToggleEnabled(faq)}
className={clsx('inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium min-h-[32px]',
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
{faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : 'No'}
<button
onClick={() => handleToggleEnabled(faq)}
className={clsx(
'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>
</td>
<td className="px-4 py-3">
<button onClick={() => handleToggleShowOnHomepage(faq)}
className={clsx('inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium min-h-[32px]',
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
{faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : 'No'}
<button
onClick={() => handleToggleShowOnHomepage(faq)}
className={clsx(
'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>
</td>
<td className="px-4 py-3 text-right">
@@ -329,65 +390,6 @@ export default function AdminFaqPage() {
</table>
</div>
</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>
);
}

View File

@@ -112,7 +112,6 @@ export default function AdminGalleryPage() {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'America/Asuncion',
});
};

View File

@@ -37,56 +37,14 @@ export default function AdminLayout({
const router = useRouter();
const pathname = usePathname();
const { t, locale } = useLanguage();
const { user, hasAdminAccess, isLoading, logout } = useAuth();
const { user, isAdmin, isLoading, logout } = useAuth();
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(() => {
if (!isLoading && (!user || !hasAdminAccess)) {
if (!isLoading && (!user || !isAdmin)) {
router.push('/login');
}
}, [user, hasAdminAccess, 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]);
}, [user, isAdmin, isLoading, router]);
if (isLoading) {
return (
@@ -96,29 +54,31 @@ export default function AdminLayout({
);
}
if (!user || !hasAdminAccess) {
if (!user || !isAdmin) {
return null;
}
const visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole));
const navigation = visibleNav;
const navigation = [
{ 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 = () => {
logout();
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 (
<div className="min-h-screen bg-secondary-gray">
{/* Mobile sidebar backdrop */}

View File

@@ -164,7 +164,6 @@ export default function AdminLegalPagesPage() {
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
} catch {
return dateStr;
@@ -421,46 +420,6 @@ export default function AdminLegalPagesPage() {
</li>
</ul>
</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>
</Card>
</div>

View File

@@ -35,7 +35,6 @@ export default function AdminDashboardPage() {
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
};

View File

@@ -6,7 +6,6 @@ import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPa
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import {
CheckCircleIcon,
ArrowPathIcon,
@@ -21,12 +20,8 @@ import {
BuildingLibraryIcon,
CreditCardIcon,
EnvelopeIcon,
FunnelIcon,
MagnifyingGlassIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
type Tab = 'pending_approval' | 'all';
@@ -39,9 +34,6 @@ export default function AdminPaymentsPage() {
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
const [statusFilter, setStatusFilter] = useState<string>('');
const [providerFilter, setProviderFilter] = useState<string>('');
const [eventFilter, setEventFilter] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
// Modal state
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
@@ -62,7 +54,7 @@ export default function AdminPaymentsPage() {
useEffect(() => {
loadData();
}, [statusFilter, providerFilter, eventFilter]);
}, [statusFilter, providerFilter]);
const loadData = async () => {
try {
@@ -71,8 +63,7 @@ export default function AdminPaymentsPage() {
paymentsApi.getPendingApproval(),
paymentsApi.getAll({
status: statusFilter || undefined,
provider: providerFilter || undefined,
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
provider: providerFilter || undefined
}),
eventsApi.getAll(),
]);
@@ -208,7 +199,6 @@ export default function AdminPaymentsPage() {
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
};
@@ -338,11 +328,10 @@ export default function AdminPaymentsPage() {
return (
<div>
<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>
<Button onClick={() => setShowExportModal(true)} size="sm" className="min-h-[44px] md:min-h-0">
<DocumentArrowDownIcon className="w-4 h-4 mr-1.5" />
<span className="hidden md:inline">{locale === 'es' ? 'Exportar Datos' : 'Export Data'}</span>
<span className="md:hidden">{locale === 'es' ? 'Exportar' : 'Export'}</span>
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
<Button onClick={() => setShowExportModal(true)}>
<DocumentArrowDownIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Exportar Datos' : 'Export Data'}
</Button>
</div>
@@ -350,18 +339,11 @@ export default function AdminPaymentsPage() {
{selectedPayment && (() => {
const modalBookingInfo = getBookingInfo(selectedPayment);
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-bold">
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
</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="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold mb-4">
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
</h2>
<div className="space-y-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4">
@@ -459,24 +441,43 @@ export default function AdminPaymentsPage() {
</div>
<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" />
{locale === 'es' ? 'Aprobar' : 'Approve'}
</Button>
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
<Button
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" />
{locale === 'es' ? 'Rechazar' : 'Reject'}
</Button>
</div>
<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" />
{locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'}
{locale === 'es' ? 'Enviar recordatorio de pago' : 'Send payment reminder'}
</Button>
</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>
</div>
);
@@ -484,16 +485,9 @@ export default function AdminPaymentsPage() {
{/* Export Modal */}
{showExportModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<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">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold mb-6">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
{!exportData ? (
<div className="space-y-4">
@@ -527,10 +521,10 @@ export default function AdminPaymentsPage() {
</div>
<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'}
</Button>
<Button variant="outline" onClick={() => setShowExportModal(false)} className="flex-1 min-h-[44px]">
<Button variant="outline" onClick={() => setShowExportModal(false)}>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
</div>
@@ -590,21 +584,20 @@ export default function AdminPaymentsPage() {
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button onClick={downloadCSV} className="min-h-[44px]">
<div className="flex gap-3">
<Button onClick={downloadCSV}>
<ArrowDownTrayIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
</Button>
<Button variant="outline" onClick={() => setExportData(null)} className="min-h-[44px]">
<Button variant="outline" onClick={() => setExportData(null)}>
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
</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'}
</Button>
</div>
</div>
)}
</div>
</Card>
</div>
)}
@@ -663,19 +656,31 @@ export default function AdminPaymentsPage() {
</div>
{/* Tabs */}
<div className="border-b mb-6 overflow-x-auto scrollbar-hide">
<nav className="flex gap-4 min-w-max">
<button onClick={() => setActiveTab('pending_approval')}
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
{locale === 'es' ? 'Pendientes' : 'Pending Approval'}
<div className="border-b mb-6">
<nav className="flex gap-4">
<button
onClick={() => setActiveTab('pending_approval')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
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 && (
<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 onClick={() => setActiveTab('all')}
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
activeTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
<button
onClick={() => setActiveTab('all')}
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'}
</button>
</nav>
@@ -742,7 +747,7 @@ export default function AdminPaymentsPage() {
)}
</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'}
</Button>
</div>
@@ -755,44 +760,18 @@ export default function AdminPaymentsPage() {
)}
{/* All Payments Tab */}
{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 (
{activeTab === 'all' && (
<>
{/* Desktop Filters */}
<Card className="p-4 mb-6 hidden md:block">
{/* Filters */}
<Card className="p-4 mb-6">
<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>
<label className="block text-sm font-medium mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
<select
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="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
@@ -803,126 +782,119 @@ export default function AdminPaymentsPage() {
</div>
<div>
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
<select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
<select
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="lightning">Lightning</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>
</select>
</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>
</Card>
{/* Mobile Search & Filter Toolbar */}
<div className="md:hidden mb-4 space-y-2">
<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">
{/* Payments Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<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-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{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-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{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-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">{locale === 'es' ? 'Asistente' : 'Attendee'}</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-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Monto' : 'Amount'}</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-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Fecha' : 'Date'}</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>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{filteredPayments.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>
{payments.length === 0 ? (
<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);
return (
<tr key={payment.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<td className="px-6 py-4">
{payment.ticket ? (
<div>
<p className="font-medium text-sm">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p>
<p className="text-xs text-gray-500 truncate max-w-[180px]">{payment.ticket.attendeeEmail}</p>
<p className="font-medium text-sm">
{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>
) : <span className="text-gray-400 text-sm">-</span>}
) : (
<span className="text-gray-400 text-sm">-</span>
)}
</td>
<td className="px-4 py-3 text-sm truncate max-w-[150px]">{payment.event?.title || '-'}</td>
<td className="px-4 py-3">
<p className="font-medium text-sm">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
{bookingInfo.ticketCount > 1 && <p className="text-[10px] text-purple-600">{bookingInfo.ticketCount} tickets</p>}
<td className="px-6 py-4">
{payment.event ? (
<p className="text-sm">{payment.event.title}</p>
) : (
<span className="text-gray-400 text-sm">-</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
<td className="px-6 py-4">
<div>
<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>
</td>
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
{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') && (
<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'}
</Button>
)}
{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')}
</Button>
)}
@@ -936,117 +908,8 @@ export default function AdminPaymentsPage() {
</table>
</div>
</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>
);
}

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 Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { CheckCircleIcon, XCircleIcon, PlusIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { CheckCircleIcon, XCircleIcon, PlusIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
@@ -18,17 +17,26 @@ export default function AdminTicketsPage() {
const [loading, setLoading] = useState(true);
const [selectedEvent, setSelectedEvent] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
// Manual ticket creation state
const [showCreateForm, setShowCreateForm] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm, setCreateForm] = useState({
eventId: '', firstName: '', lastName: '', email: '', phone: '',
preferredLanguage: 'en' as 'en' | 'es', autoCheckin: false, adminNote: '',
eventId: '',
firstName: '',
lastName: '',
email: '',
phone: '',
preferredLanguage: 'en' as 'en' | 'es',
autoCheckin: false,
adminNote: '',
});
useEffect(() => {
Promise.all([ticketsApi.getAll(), eventsApi.getAll()])
Promise.all([
ticketsApi.getAll(),
eventsApi.getAll(),
])
.then(([ticketsRes, eventsRes]) => {
setTickets(ticketsRes.tickets);
setEvents(eventsRes.events);
@@ -50,7 +58,9 @@ export default function AdminTicketsPage() {
};
useEffect(() => {
if (!loading) loadTickets();
if (!loading) {
loadTickets();
}
}, [selectedEvent, statusFilter]);
const handleCheckin = async (id: string) => {
@@ -65,6 +75,7 @@ export default function AdminTicketsPage() {
const handleCancel = async (id: string) => {
if (!confirm('Are you sure you want to cancel this ticket?')) return;
try {
await ticketsApi.cancel(id);
toast.success('Ticket cancelled');
@@ -86,18 +97,35 @@ export default function AdminTicketsPage() {
const handleCreateTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!createForm.eventId) { toast.error('Please select an event'); return; }
if (!createForm.eventId) {
toast.error('Please select an event');
return;
}
setCreating(true);
try {
await ticketsApi.adminCreate({
eventId: createForm.eventId, firstName: createForm.firstName,
lastName: createForm.lastName || undefined, email: createForm.email,
phone: createForm.phone, preferredLanguage: createForm.preferredLanguage,
autoCheckin: createForm.autoCheckin, adminNote: createForm.adminNote || undefined,
eventId: createForm.eventId,
firstName: createForm.firstName,
lastName: createForm.lastName || undefined,
email: createForm.email,
phone: createForm.phone,
preferredLanguage: createForm.preferredLanguage,
autoCheckin: createForm.autoCheckin,
adminNote: createForm.adminNote || undefined,
});
toast.success('Ticket created successfully');
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();
} catch (error: any) {
toast.error(error.message || 'Failed to create ticket');
@@ -108,29 +136,32 @@ export default function AdminTicketsPage() {
const formatDate = (dateStr: string) => {
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',
});
};
const getStatusBadge = (status: 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> = {
pending: t('admin.tickets.status.pending'), confirmed: t('admin.tickets.status.confirmed'),
cancelled: t('admin.tickets.status.cancelled'), checked_in: t('admin.tickets.status.checkedIn'),
pending: t('admin.tickets.status.pending'),
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>;
};
const getEventName = (eventId: string) => events.find(e => e.id === eventId)?.title || 'Unknown Event';
const hasActiveFilters = selectedEvent || statusFilter;
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;
const getEventName = (eventId: string) => {
const event = events.find(e => e.id === eventId);
return event?.title || 'Unknown Event';
};
if (loading) {
@@ -144,86 +175,134 @@ export default function AdminTicketsPage() {
return (
<div>
<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>
<Button onClick={() => setShowCreateForm(true)} className="hidden md:flex">
<PlusIcon className="w-5 h-5 mr-2" /> Create Ticket
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1>
<Button onClick={() => setShowCreateForm(true)}>
<PlusIcon className="w-5 h-5 mr-2" />
Create Ticket
</Button>
</div>
{/* Create Ticket Modal */}
{/* Manual Ticket Creation Modal */}
{showCreateForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-bold">Create Ticket Manually</h2>
<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 className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg p-6">
<h2 className="text-xl font-bold mb-6">Create Ticket Manually</h2>
<form onSubmit={handleCreateTicket} className="space-y-4">
<div>
<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 })}
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>
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
<option key={event.id} value={event.id}>{event.title} ({event.availableSeats} spots left)</option>
{events.filter(e => e.status === 'published').map((event) => (
<option key={event.id} value={event.id}>
{event.title} ({event.availableSeats} spots left)
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="First Name *" value={createForm.firstName}
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })} required placeholder="First name" />
<Input label="Last Name" value={createForm.lastName}
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })} placeholder="Last name" />
<Input
label="First Name *"
value={createForm.firstName}
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>
<Input label="Email (optional)" 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" />
<Input
label="Email (optional)"
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>
<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' })}
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="es">Spanish</option>
</select>
</div>
<div>
<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 })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" rows={2}
placeholder="Internal note (optional)" />
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
rows={2}
placeholder="Internal note about this booking (optional)"
/>
</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 })}
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
<label htmlFor="autoCheckin" className="text-sm">Auto check-in immediately</label>
className="w-4 h-4"
/>
<label htmlFor="autoCheckin" className="text-sm">
Automatically check in (mark as present)
</label>
</div>
<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 className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowCreateForm(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={creating} className="flex-1 min-h-[44px]">Create Ticket</Button>
<div className="flex gap-3 pt-4">
<Button type="submit" isLoading={creating}>
Create Ticket
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowCreateForm(false)}
>
Cancel
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Desktop Filters */}
<Card className="p-4 mb-6 hidden md:block">
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-medium mb-1">Event</label>
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px] text-sm">
<select
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>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
@@ -232,8 +311,11 @@ export default function AdminTicketsPage() {
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
<select
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="pending">Pending</option>
<option value="confirmed">Confirmed</option>
@@ -244,61 +326,70 @@ export default function AdminTicketsPage() {
</div>
</Card>
{/* Mobile Toolbar */}
<div className="md:hidden mb-4 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 && `(${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">
{/* Tickets Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<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-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">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-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">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-left px-6 py-3 text-sm font-medium text-gray-600">Ticket</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</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">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{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) => (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
<p className="text-[10px] text-gray-400">ID: {ticket.id.slice(0, 8)}...</p>
<td className="px-6 py-4">
<div>
<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 className="px-4 py-3 text-sm">{getEventName(ticket.eventId)}</td>
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(ticket.createdAt)}</td>
<td className="px-4 py-3">{getStatusBadge(ticket.status)}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<td className="px-6 py-4 text-sm">
{getEventName(ticket.eventId)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{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' && (
<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' && (
<Button size="sm" onClick={() => handleCheckin(ticket.id)}>
<CheckCircleIcon className="w-4 h-4 mr-1" /> {t('admin.tickets.checkin')}
<Button
size="sm"
onClick={() => handleCheckin(ticket.id)}
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
{t('admin.tickets.checkin')}
</Button>
)}
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
<button onClick={() => handleCancel(ticket.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" title="Cancel">
<button
onClick={() => handleCancel(ticket.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Cancel"
>
<XCircleIcon className="w-4 h-4" />
</button>
)}
@@ -311,102 +402,6 @@ export default function AdminTicketsPage() {
</table>
</div>
</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>
);
}

View File

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

View File

@@ -2,13 +2,13 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import { formatPrice } from '@/lib/utils';
import {
CalendarIcon,
MapPinIcon,
ChatBubbleLeftRightIcon,
} from '@heroicons/react/24/outline';
export default function LinktreePage() {
@@ -24,22 +24,25 @@ export default function LinktreePage() {
useEffect(() => {
eventsApi.getNextUpcoming()
.then(({ event }) => {
if (event) {
const endTime = event.endDatetime || event.startDatetime;
if (new Date(endTime).getTime() <= Date.now()) {
setNextEvent(null);
return;
}
}
setNextEvent(event);
})
.then(({ event }) => setNextEvent(event))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
// Handle both full URLs and handles
const instagramUrl = instagramHandle
@@ -59,8 +62,8 @@ export default function LinktreePage() {
<div className="max-w-md mx-auto px-4 py-8 pb-16">
{/* Profile Header */}
<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">
<Image src="/images/spanglish-icon.png" alt="Spanglish" width={96} height={96} className="object-contain" />
<div className="w-24 h-24 mx-auto bg-primary-yellow rounded-full flex items-center justify-center mb-4 shadow-lg">
<ChatBubbleLeftRightIcon className="w-12 h-12 text-primary-dark" />
</div>
<h1 className="text-2xl font-bold text-white">Spanglish</h1>
<p className="text-gray-400 mt-1">{t('linktree.tagline')}</p>
@@ -86,7 +89,7 @@ export default function LinktreePage() {
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-gray-300 text-sm">
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span>{formatDate(nextEvent.startDatetime)} {fmtTime(nextEvent.startDatetime)}</span>
<span>{formatDate(nextEvent.startDatetime)} {formatTime(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-2 text-gray-300 text-sm">
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />

View File

@@ -28,7 +28,7 @@ interface LlmsEvent {
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
try {
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
cache: 'no-store',
next: { tags: ['next-event'] },
});
if (!response.ok) return null;
const data = await response.json();
@@ -41,7 +41,7 @@ async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
try {
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
cache: 'no-store',
next: { tags: ['next-event'] },
});
if (!response.ok) return [];
const data = await response.json();
@@ -51,17 +51,12 @@ async function getUpcomingEvents(): Promise<LlmsEvent[]> {
}
}
// Event times are always shown in Paraguay time (America/Asuncion) so llms.txt
// matches what users see on the website, regardless of server timezone.
const EVENT_TIMEZONE = 'America/Asuncion';
function formatEventDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: EVENT_TIMEZONE,
});
}
@@ -70,7 +65,6 @@ function formatEventTime(dateStr: string): string {
hour: '2-digit',
minute: '2-digit',
hour12: true,
timeZone: EVENT_TIMEZONE,
});
}
@@ -79,43 +73,10 @@ function formatPrice(price: number, currency: string): string {
return `${price.toLocaleString()} ${currency}`;
}
function formatISODate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
function formatISOTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: EVENT_TIMEZONE,
});
}
function getTodayISO(): string {
return new Date().toLocaleDateString('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
function getEventStatus(event: LlmsEvent): string {
if (event.availableSeats !== undefined && event.availableSeats === 0) return 'Sold Out';
if (event.status === 'published') return 'Available';
return event.status;
}
async function getHomepageFaqs(): Promise<LlmsFaq[]> {
try {
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
cache: 'no-store',
next: { revalidate: 3600 },
});
if (!response.ok) return [];
const data = await response.json();
@@ -128,8 +89,6 @@ async function getHomepageFaqs(): Promise<LlmsFaq[]> {
}
}
export const dynamic = 'force-dynamic';
export async function GET() {
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
getNextUpcomingEvent(),
@@ -142,15 +101,6 @@ export async function GET() {
// Header
lines.push('# Spanglish Community');
lines.push('');
lines.push('## Metadata');
lines.push('');
lines.push('- Type: Event Community');
lines.push('- Primary Language: English, Spanish');
lines.push('- Location: Asunción, Paraguay');
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
lines.push(`- Last Updated: ${getTodayISO()}`);
lines.push(`- Canonical URL: ${siteUrl}`);
lines.push('');
lines.push('> English-Spanish language exchange community organizing social events and meetups in Asunción, Paraguay.');
lines.push('');
lines.push(`- Website: ${siteUrl}`);
@@ -162,8 +112,8 @@ export async function GET() {
const telegram = process.env.NEXT_PUBLIC_TELEGRAM;
const email = process.env.NEXT_PUBLIC_EMAIL;
if (instagram) lines.push(`- Instagram: ${instagram}`);
if (telegram) lines.push(`- Telegram: ${telegram}`);
if (instagram) lines.push(`- Instagram: https://instagram.com/${instagram}`);
if (telegram) lines.push(`- Telegram: https://t.me/${telegram}`);
if (email) lines.push(`- Email: ${email}`);
if (whatsapp) lines.push(`- WhatsApp: ${whatsapp}`);
@@ -174,25 +124,18 @@ export async function GET() {
lines.push('');
if (nextEvent) {
const status = getEventStatus(nextEvent);
lines.push(`- Event Name: ${nextEvent.title}`);
lines.push(`- Event ID: ${nextEvent.id}`);
lines.push(`- Status: ${status}`);
lines.push(`- Date: ${formatISODate(nextEvent.startDatetime)}`);
lines.push(`- Start Time: ${formatISOTime(nextEvent.startDatetime)}`);
lines.push(`- Event: ${nextEvent.title}`);
lines.push(`- Date: ${formatEventDate(nextEvent.startDatetime)}`);
lines.push(`- Time: ${formatEventTime(nextEvent.startDatetime)}`);
if (nextEvent.endDatetime) {
lines.push(`- End Time: ${formatISOTime(nextEvent.endDatetime)}`);
lines.push(`- End time: ${formatEventTime(nextEvent.endDatetime)}`);
}
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
lines.push(`- Venue: ${nextEvent.location}`);
lines.push('- City: Asunción');
lines.push('- Country: Paraguay');
lines.push(`- Price: ${nextEvent.price === 0 ? 'Free' : nextEvent.price}`);
lines.push(`- Currency: ${nextEvent.currency}`);
lines.push(`- Location: ${nextEvent.location}, Asunción, Paraguay`);
lines.push(`- Price: ${formatPrice(nextEvent.price, nextEvent.currency)}`);
if (nextEvent.availableSeats !== undefined) {
lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`);
lines.push(`- Available spots: ${nextEvent.availableSeats}`);
}
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.id}`);
lines.push(`- Details and tickets: ${siteUrl}/events/${nextEvent.id}`);
if (nextEvent.shortDescription) {
lines.push(`- Description: ${nextEvent.shortDescription}`);
}
@@ -207,25 +150,12 @@ export async function GET() {
lines.push('## All Upcoming Events');
lines.push('');
for (const event of upcomingEvents) {
const status = getEventStatus(event);
lines.push(`### ${event.title}`);
lines.push(`- Event ID: ${event.id}`);
lines.push(`- Status: ${status}`);
lines.push(`- Date: ${formatISODate(event.startDatetime)}`);
lines.push(`- Start Time: ${formatISOTime(event.startDatetime)}`);
if (event.endDatetime) {
lines.push(`- End Time: ${formatISOTime(event.endDatetime)}`);
}
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
lines.push(`- Venue: ${event.location}`);
lines.push('- City: Asunción');
lines.push('- Country: Paraguay');
lines.push(`- Price: ${event.price === 0 ? 'Free' : event.price}`);
lines.push(`- Currency: ${event.currency}`);
if (event.availableSeats !== undefined) {
lines.push(`- Capacity Remaining: ${event.availableSeats}`);
}
lines.push(`- Tickets URL: ${siteUrl}/events/${event.id}`);
lines.push(`- Date: ${formatEventDate(event.startDatetime)}`);
lines.push(`- Time: ${formatEventTime(event.startDatetime)}`);
lines.push(`- Location: ${event.location}, Asunción, Paraguay`);
lines.push(`- Price: ${formatPrice(event.price, event.currency)}`);
lines.push(`- Details: ${siteUrl}/events/${event.id}`);
lines.push('');
}
}
@@ -249,18 +179,6 @@ export async function GET() {
lines.push(`- More FAQ: ${siteUrl}/faq`);
lines.push('');
// Update Policy
lines.push('## Update Policy');
lines.push('');
lines.push('Event information is updated whenever new events are published or ticket availability changes.');
lines.push('');
// AI Summary
lines.push('## AI Summary');
lines.push('');
lines.push('Spanglish Community organizes English-Spanish language exchange events in Asunción, Paraguay. Events require registration via the website.');
lines.push('');
const content = lines.join('\n');
return new NextResponse(content, {

View File

@@ -7,16 +7,30 @@ export default function robots(): MetadataRoute.Robots {
rules: [
{
userAgent: '*',
allow: '/',
allow: [
'/',
'/events',
'/events/*',
'/community',
'/contact',
'/faq',
'/legal/*',
'/llms.txt',
],
disallow: [
'/admin/',
'/dashboard/',
'/api/',
'/book/',
'/booking/',
'/admin',
'/admin/*',
'/dashboard',
'/dashboard/*',
'/api',
'/api/*',
'/book',
'/book/*',
'/booking',
'/booking/*',
'/login',
'/register',
'/auth/',
'/auth/*',
],
},
],

View File

@@ -3,109 +3,89 @@ import { MetadataRoute } from 'next';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface SitemapEvent {
interface Event {
id: string;
status: string;
startDatetime: string;
updatedAt: string;
}
/**
* Fetch all indexable events: published, completed, and cancelled.
* Sold-out / past events stay in the index (marked as expired, not removed).
* Only draft and archived events are excluded.
*/
async function getIndexableEvents(): Promise<SitemapEvent[]> {
async function getPublishedEvents(): Promise<Event[]> {
try {
const [publishedRes, completedRes] = await Promise.all([
fetch(`${apiUrl}/api/events?status=published`, {
next: { tags: ['events-sitemap'] },
}),
fetch(`${apiUrl}/api/events?status=completed`, {
next: { tags: ['events-sitemap'] },
}),
]);
const published = publishedRes.ok
? ((await publishedRes.json()).events as SitemapEvent[]) || []
: [];
const completed = completedRes.ok
? ((await completedRes.json()).events as SitemapEvent[]) || []
: [];
return [...published, ...completed];
const response = await fetch(`${apiUrl}/api/events?status=published`, {
next: { tags: ['events-sitemap'] },
});
if (!response.ok) return [];
const data = await response.json();
return data.events || [];
} catch {
return [];
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const events = await getIndexableEvents();
const now = new Date();
// Fetch published events for dynamic event pages
const events = await getPublishedEvents();
// Static pages
const staticPages: MetadataRoute.Sitemap = [
{
url: siteUrl,
lastModified: now,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: `${siteUrl}/events`,
lastModified: now,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${siteUrl}/community`,
lastModified: now,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
},
{
url: `${siteUrl}/contact`,
lastModified: now,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.6,
},
{
url: `${siteUrl}/faq`,
lastModified: now,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.6,
},
// Legal pages
{
url: `${siteUrl}/legal/terms-policy`,
lastModified: now,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
{
url: `${siteUrl}/legal/privacy-policy`,
lastModified: now,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
{
url: `${siteUrl}/legal/refund-cancelation-policy`,
lastModified: now,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
];
// Dynamic event pages — upcoming events get higher priority
const eventPages: MetadataRoute.Sitemap = events.map((event) => {
const isUpcoming = new Date(event.startDatetime) > now;
return {
url: `${siteUrl}/events/${event.id}`,
lastModified: new Date(event.updatedAt),
changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const),
priority: isUpcoming ? 0.8 : 0.5,
};
});
// Dynamic event pages
const eventPages: MetadataRoute.Sitemap = events.map((event) => ({
url: `${siteUrl}/events/${event.id}`,
lastModified: new Date(event.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
return [...staticPages, ...eventPages];
}

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

View File

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

@@ -92,27 +92,6 @@ export const ticketsApi = {
method: 'POST',
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) =>
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
@@ -236,13 +215,11 @@ export const usersApi = {
// Payments API
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();
if (params?.status) query.set('status', params.status);
if (params?.provider) query.set('provider', params.provider);
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}`);
},
@@ -374,49 +351,6 @@ export const adminApi = {
if (params?.eventId) query.set('eventId', params.eventId);
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
@@ -450,7 +384,7 @@ export const emailsApi = {
customVariables?: Record<string, any>;
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}`,
{
method: 'POST',
@@ -492,12 +426,7 @@ export const emailsApi = {
},
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) => {
const query = eventId ? `?eventId=${eventId}` : '';
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
@@ -523,7 +452,7 @@ export interface Event {
price: number;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string;
externalBookingEnabled?: boolean;
externalBookingUrl?: string;
@@ -579,39 +508,6 @@ export interface TicketValidationResult {
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 {
id: string;
ticketId: string;
@@ -797,8 +693,6 @@ export interface EmailLog {
sentAt?: string;
sentBy?: string;
createdAt: string;
resendAttempts?: number;
lastResentAt?: string;
}
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 ====================
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
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 {

View File

@@ -1,111 +1,3 @@
// ---------------------------------------------------------------------------
// Date / time formatting
// ---------------------------------------------------------------------------
// All helpers pin the timezone to America/Asuncion so the output is identical
// on the server (often UTC) and the client (user's local TZ). This prevents
// React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client).
// ---------------------------------------------------------------------------
const EVENT_TIMEZONE = 'America/Asuncion';
type Locale = 'en' | 'es';
function pickLocale(locale: Locale): string {
return locale === 'es' ? 'es-ES' : 'en-US';
}
/**
* "Sat, Feb 14" / "sáb, 14 feb"
*/
export function formatDateShort(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: EVENT_TIMEZONE,
});
}
/**
* "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026"
*/
export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: EVENT_TIMEZONE,
});
}
/**
* "February 14, 2026" / "14 de febrero de 2026" (no weekday)
*/
export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: EVENT_TIMEZONE,
});
}
/**
* "Feb 14, 2026" / "14 feb 2026"
*/
export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: EVENT_TIMEZONE,
});
}
/**
* "04:30 PM" / "16:30"
*/
export function formatTime(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleTimeString(pickLocale(locale), {
hour: '2-digit',
minute: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
/**
* "Feb 14, 2026, 04:30 PM" — compact date + time combined
*/
export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleString(pickLocale(locale), {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
/**
* "Sat, Feb 14, 04:30 PM" — short date + time combined
*/
export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleString(pickLocale(locale), {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
// ---------------------------------------------------------------------------
// Price formatting
// ---------------------------------------------------------------------------
/**
* Format price - shows decimals only if needed
* Uses space as thousands separator (common in Paraguay)

View File

@@ -15,9 +15,7 @@
"start:frontend": "npm run start --workspace=frontend",
"db:generate": "npm run db:generate --workspace=backend",
"db:migrate": "npm run db:migrate --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 --"
"db:studio": "npm run db:studio --workspace=backend"
},
"workspaces": [
"backend",