23 Commits

Author SHA1 Message Date
f0128f66b0 Merge pull request 'Bug fixes and improvements' (#14) from dev into main
Reviewed-on: #14
2026-03-07 22:53:13 +00:00
Michilis
2f45966932 Add db:export and db:import for database backups
Made-with: Cursor
2026-03-07 19:44:27 +00:00
Michilis
7c1fdbf382 Add Spanglish icon image
Made-with: Cursor
2026-03-07 19:41:16 +00:00
Michilis
596ec71191 Fix stale social media preview: revalidate next-event fetch, reject past featured events
Made-with: Cursor
2026-03-07 19:36:12 +00:00
Michilis
25b7018743 Bookings/payments/linktree: fix payment method display, event filter, logo, search
- Bookings: align payment method labels with payments page (bank_transfer, tpago, etc), add sibling fallback
- Payments: add event filter (single/multi), add search by name/email/event
- Linktree: use Spanglish logo instead of icon
- API: payments getAll supports eventId/eventIds

Made-with: Cursor
2026-03-07 18:06:35 +00:00
b33c68feb0 Merge pull request 'dev' (#13) from dev into main
Reviewed-on: #13
2026-02-19 02:23:19 +00:00
Michilis
bbfaa1172a Add unlisted event status: hidden from listings but accessible by URL
- Backend: add 'unlisted' to schema enum and Zod validation; allow booking for unlisted events
- Frontend: Event type and guards updated; unlisted events bookable, excluded from public listing/sitemap
- Admin: badge, status dropdown, Make Unlisted / Make Public / Unpublish actions; scanner/emails/tickets include unlisted

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 02:21:41 +00:00
Michilis
958181e049 Mobile-friendly admin pages, redesigned homepage Next Event card
- Extract shared mobile components (BottomSheet, MoreMenu, Dropdown, etc.) into MobileComponents.tsx
- Make admin pages mobile-friendly: bookings, emails, events, faq, payments, tickets, users
- Redesign homepage Next Event card with banner image, responsive layout, and updated styling
- Fix past events showing on homepage/linktree: use proper Date comparison, auto-unfeature expired events
- Add "Over" tag to admin events list for past events
- Fix backend FRONTEND_URL for cache revalidation

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 03:27:49 +00:00
15655e3987 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-02-16 23:11:52 +00:00
Michilis
5263fa6834 Make llms.txt always fetch fresh data from the backend
- Switch from tag-based caching to cache: no-store for all backend fetches
- Add dynamic = force-dynamic to prevent Next.js static caching
- Ensures llms.txt always reflects the current featured event and FAQ data

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 23:10:33 +00:00
Michilis
923c86a3b3 Fix FRONTEND_URL pointing to wrong port, breaking cache revalidation
- Update FRONTEND_URL default from localhost:3002 to localhost:3019 (actual frontend port)
- Reorder systemd service so EnvironmentFile loads before Environment overrides

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 22:53:59 +00:00
d8b3864411 Merge pull request 'Fix stale featured event on homepage: revalidate cache when featured event changes' (#11) from dev into main
Reviewed-on: #11
2026-02-16 22:44:19 +00:00
Michilis
4aaffe99c7 Fix stale featured event on homepage: revalidate cache when featured event changes
- Extract revalidateFrontendCache() to backend/src/lib/revalidate.ts
- Call revalidation from site-settings when featuredEventId is set/cleared
- Ensures homepage shows updated featured event after admin changes

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 22:42:55 +00:00
194cbd6ca8 Merge pull request 'Scanner: close button on valid ticket, camera lifecycle fix' (#10) from dev into main
Reviewed-on: #10
2026-02-14 19:04:42 +00:00
Michilis
a11da5a977 Scanner: close button on valid ticket, camera lifecycle fix
- Add X close button on valid ticket screen to dismiss without check-in
- Rewrite QRScanner: full unmount when leaving Scan tab, stop MediaStream tracks
- Remount scanner via key when tab active; no hidden DOM
- Use 100dvh for mobile height; force layout reflow after camera start
- visibilitychange handler for tab suspend/resume

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 19:03:29 +00:00
d5445c2282 Merge pull request 'Admin event page: redesign UI, export endpoints, mobile fixes' (#9) from dev into main
Reviewed-on: #9
2026-02-14 18:38:57 +00:00
Michilis
6bc7e13e78 Admin event page: redesign UI, export endpoints, mobile fixes
- Backend: Add /events/:eventId/attendees/export and /events/:eventId/tickets/export with q/status; legacy redirect for old export path
- API: exportAttendees q param, new exportTicketsCSV for tickets CSV
- Admin event page: unified tabs+content container, portal dropdowns to fix clipping, separate mobile export/add-ticket sheets (fix double menu), responsive tab bar and card layout

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 18:27:27 +00:00
dcfefc8371 Merge pull request 'feat(admin): add event attendees export (CSV) with status filters' (#8) from dev into main
Reviewed-on: #8
2026-02-14 05:28:24 +00:00
Michilis
c3897efd02 feat(admin): add event attendees export (CSV) with status filters
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 05:27:17 +00:00
b5f14335c4 Merge pull request 'Mobile scanner redesign + backend live search' (#7) from dev into main
Reviewed-on: #7
2026-02-14 04:28:44 +00:00
Michilis
62bf048680 Mobile scanner redesign + backend live search
- Scanner page: fullscreen mobile-first layout, Scan/Search/Recent tabs
- Scan tab: auto-start camera, switch camera, vibration/sound feedback
- Valid/invalid fullscreen states, confirm check-in, auto-return to camera
- Search tab: live backend search (300ms debounce), tap card for detail + check-in
- Recent tab: last 20 check-ins, session counter
- Backend: GET /api/tickets/search (live search), GET /api/tickets/stats/checkin
- Admin layout: hide sidebar on scanner page; fix hooks order (no early return before useEffect)
- Back button to dashboard/events (staff → events, others → admin)
- API: searchLive, getCheckinStats, LiveSearchResult; PostgreSQL LOWER cast for UUID

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 04:26:44 +00:00
d44ac949b5 Merge pull request 'Email queue + async sending; legal settings and placeholders' (#6) from dev into main
Reviewed-on: #6
2026-02-12 21:04:58 +00:00
Michilis
b9f46b02cc Email queue + async sending; legal settings and placeholders
- Add in-memory email queue with rate limiting (MAX_EMAILS_PER_HOUR)
- Bulk send to event attendees now queues and returns immediately
- Frontend shows 'Emails are being sent in the background'
- Legal pages, settings, and placeholders updates

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 21:03:49 +00:00
50 changed files with 6421 additions and 3360 deletions

View File

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

View File

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

View File

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

96
backend/src/db/export.ts Normal file
View File

@@ -0,0 +1,96 @@
import 'dotenv/config';
import { existsSync, mkdirSync, writeFileSync } 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 result = spawnSync(
'pg_dump',
['--clean', '--if-exists', connString],
{
stdio: ['ignore', 'pipe', '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);
}
writeFileSync(outputPath, result.stdout);
console.log(`Exported to ${outputPath}`);
}
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);
});

91
backend/src/db/import.ts Normal file
View File

@@ -0,0 +1,91 @@
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

@@ -437,6 +437,25 @@ async function migrate() {
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
) )
`); `);
// Legal settings table for legal page placeholder values
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS legal_settings (
id TEXT PRIMARY KEY,
company_name TEXT,
legal_entity_name TEXT,
ruc_number TEXT,
company_address TEXT,
company_city TEXT,
company_country TEXT,
support_email TEXT,
legal_email TEXT,
governing_law TEXT,
jurisdiction_city TEXT,
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id)
)
`);
} else { } else {
// PostgreSQL migrations // PostgreSQL migrations
await (db as any).execute(sql` await (db as any).execute(sql`
@@ -822,6 +841,25 @@ async function migrate() {
updated_at TIMESTAMP NOT NULL updated_at TIMESTAMP NOT NULL
) )
`); `);
// Legal settings table for legal page placeholder values
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS legal_settings (
id UUID PRIMARY KEY,
company_name VARCHAR(255),
legal_entity_name VARCHAR(255),
ruc_number VARCHAR(50),
company_address TEXT,
company_city VARCHAR(100),
company_country VARCHAR(100),
support_email VARCHAR(255),
legal_email VARCHAR(255),
governing_law VARCHAR(255),
jurisdiction_city VARCHAR(100),
updated_at TIMESTAMP NOT NULL,
updated_by UUID REFERENCES users(id)
)
`);
} }
console.log('Migrations completed successfully!'); console.log('Migrations completed successfully!');

View File

@@ -75,7 +75,7 @@ export const sqliteEvents = sqliteTable('events', {
price: real('price').notNull().default(0), price: real('price').notNull().default(0),
currency: text('currency').notNull().default('PYG'), currency: text('currency').notNull().default('PYG'),
capacity: integer('capacity').notNull().default(50), capacity: integer('capacity').notNull().default(50),
status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'), status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
bannerUrl: text('banner_url'), bannerUrl: text('banner_url'),
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false), externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
externalBookingUrl: text('external_booking_url'), externalBookingUrl: text('external_booking_url'),
@@ -281,6 +281,23 @@ export const sqliteFaqQuestions = sqliteTable('faq_questions', {
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
// Legal Settings table for legal page placeholder values
export const sqliteLegalSettings = sqliteTable('legal_settings', {
id: text('id').primaryKey(),
companyName: text('company_name'),
legalEntityName: text('legal_entity_name'),
rucNumber: text('ruc_number'),
companyAddress: text('company_address'),
companyCity: text('company_city'),
companyCountry: text('company_country'),
supportEmail: text('support_email'),
legalEmail: text('legal_email'),
governingLaw: text('governing_law'),
jurisdictionCity: text('jurisdiction_city'),
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
});
// Site Settings table for global website configuration // Site Settings table for global website configuration
export const sqliteSiteSettings = sqliteTable('site_settings', { export const sqliteSiteSettings = sqliteTable('site_settings', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -578,6 +595,23 @@ export const pgFaqQuestions = pgTable('faq_questions', {
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
// Legal Settings table for legal page placeholder values
export const pgLegalSettings = pgTable('legal_settings', {
id: uuid('id').primaryKey(),
companyName: varchar('company_name', { length: 255 }),
legalEntityName: varchar('legal_entity_name', { length: 255 }),
rucNumber: varchar('ruc_number', { length: 50 }),
companyAddress: pgText('company_address'),
companyCity: varchar('company_city', { length: 100 }),
companyCountry: varchar('company_country', { length: 100 }),
supportEmail: varchar('support_email', { length: 255 }),
legalEmail: varchar('legal_email', { length: 255 }),
governingLaw: varchar('governing_law', { length: 255 }),
jurisdictionCity: varchar('jurisdiction_city', { length: 100 }),
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
});
// Site Settings table for global website configuration // Site Settings table for global website configuration
export const pgSiteSettings = pgTable('site_settings', { export const pgSiteSettings = pgTable('site_settings', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
@@ -623,6 +657,7 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens; export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions; export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices; export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings;
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings; export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages; export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions; export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
@@ -657,4 +692,6 @@ export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert;
export type LegalPage = typeof sqliteLegalPages.$inferSelect; export type LegalPage = typeof sqliteLegalPages.$inferSelect;
export type NewLegalPage = typeof sqliteLegalPages.$inferInsert; export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect; export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert; export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;

View File

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

View File

@@ -10,6 +10,7 @@ import {
defaultTemplates, defaultTemplates,
type DefaultTemplate type DefaultTemplate
} from './emailTemplates.js'; } from './emailTemplates.js';
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer'; import type { Transporter } from 'nodemailer';
@@ -1173,6 +1174,100 @@ export const emailService = {
errors, 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) * Send a custom email (not from template)
@@ -1183,10 +1278,11 @@ export const emailService = {
subject: string; subject: string;
bodyHtml: string; bodyHtml: string;
bodyText?: string; bodyText?: string;
replyTo?: string;
eventId?: string; eventId?: string;
sentBy: string; sentBy?: string | null;
}): Promise<{ success: boolean; logId?: string; error?: string }> { }): Promise<{ success: boolean; logId?: string; error?: string }> {
const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params; const { to, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params;
const allVariables = { const allVariables = {
...this.getCommonVariables(), ...this.getCommonVariables(),
@@ -1208,7 +1304,7 @@ export const emailService = {
subject, subject,
bodyHtml: finalBodyHtml, bodyHtml: finalBodyHtml,
status: 'pending', status: 'pending',
sentBy, sentBy: sentBy || null,
createdAt: now, createdAt: now,
}); });
@@ -1218,6 +1314,7 @@ export const emailService = {
subject, subject,
html: finalBodyHtml, html: finalBodyHtml,
text: bodyText, text: bodyText,
replyTo,
}); });
// Update log // Update log

View File

@@ -0,0 +1,194 @@
// 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

@@ -0,0 +1,80 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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