Compare commits
28 Commits
23d0325d8d
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8ebc3760d | ||
|
|
4da26e7ef1 | ||
|
|
e09ff4ed60 | ||
|
|
2f45966932 | ||
|
|
7c1fdbf382 | ||
|
|
596ec71191 | ||
|
|
25b7018743 | ||
|
|
bbfaa1172a | ||
|
|
958181e049 | ||
|
|
5263fa6834 | ||
|
|
923c86a3b3 | ||
|
|
4aaffe99c7 | ||
|
|
a11da5a977 | ||
|
|
6bc7e13e78 | ||
|
|
c3897efd02 | ||
|
|
62bf048680 | ||
|
|
b9f46b02cc | ||
|
|
18254c566e | ||
|
|
95ee5a5dec | ||
|
|
77e92e5d96 | ||
|
|
07ba357194 | ||
|
|
5885044369 | ||
|
|
af94c99fd2 | ||
|
|
74464b0a7a | ||
|
|
6a807a7cc6 | ||
|
|
fe75912f23 | ||
|
|
8315029091 | ||
|
|
2b2f2cc4ed |
21
README.md
21
README.md
@@ -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/`:
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ 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)
|
||||||
|
# Must match the REVALIDATE_SECRET in frontend/.env
|
||||||
|
REVALIDATE_SECRET=change-me-to-a-random-secret
|
||||||
|
|
||||||
# Payment Providers (optional)
|
# Payment Providers (optional)
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
@@ -63,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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
100
backend/src/db/export.ts
Normal file
100
backend/src/db/export.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { closeSync, existsSync, mkdirSync, openSync } from 'fs';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
|
const dbPath = process.env.DATABASE_URL || './data/spanglish.db';
|
||||||
|
const BACKUP_DIR = resolve(process.cwd(), 'data', 'backups');
|
||||||
|
|
||||||
|
function parseArgs(): { output?: string } {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const result: { output?: string } = {};
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === '-o' || args[i] === '--output') {
|
||||||
|
result.output = args[i + 1];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimestamp(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
const h = String(now.getHours()).padStart(2, '0');
|
||||||
|
const min = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}-${h}${min}${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportSqlite(outputPath: string): void {
|
||||||
|
const db = new Database(resolve(process.cwd(), dbPath), { readonly: true });
|
||||||
|
try {
|
||||||
|
db.backup(outputPath);
|
||||||
|
console.log(`Exported to ${outputPath}`);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPostgres(outputPath: string): void {
|
||||||
|
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
|
||||||
|
const outFd = openSync(outputPath, 'w');
|
||||||
|
try {
|
||||||
|
const result = spawnSync(
|
||||||
|
'pg_dump',
|
||||||
|
['--clean', '--if-exists', connString],
|
||||||
|
{
|
||||||
|
stdio: ['ignore', outFd, 'pipe'],
|
||||||
|
encoding: 'utf-8',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error('pg_dump failed. Ensure pg_dump is installed and in PATH.');
|
||||||
|
console.error(result.error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
console.error('pg_dump failed:', result.stderr);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Exported to ${outputPath}`);
|
||||||
|
} finally {
|
||||||
|
closeSync(outFd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { output } = parseArgs();
|
||||||
|
const ext = dbType === 'postgres' ? '.sql' : '.db';
|
||||||
|
const defaultName = `spanglish-${getTimestamp()}${ext}`;
|
||||||
|
|
||||||
|
const outputPath = output
|
||||||
|
? resolve(process.cwd(), output)
|
||||||
|
: resolve(BACKUP_DIR, defaultName);
|
||||||
|
|
||||||
|
const dir = dirname(outputPath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Database type: ${dbType}`);
|
||||||
|
if (dbType === 'sqlite') {
|
||||||
|
exportSqlite(outputPath);
|
||||||
|
} else {
|
||||||
|
exportPostgres(outputPath);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Export failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
91
backend/src/db/import.ts
Normal file
91
backend/src/db/import.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -368,6 +368,13 @@ async function migrate() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS email_settings (
|
CREATE TABLE IF NOT EXISTS email_settings (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -421,6 +428,41 @@ async function migrate() {
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// FAQ questions table
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS faq_questions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
question_es TEXT,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
answer_es TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
show_on_homepage INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rank INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_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`
|
||||||
@@ -737,6 +779,13 @@ async function migrate() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TIMESTAMP`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS email_settings (
|
CREATE TABLE IF NOT EXISTS email_settings (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -790,6 +839,41 @@ async function migrate() {
|
|||||||
created_at TIMESTAMP NOT NULL
|
created_at TIMESTAMP NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// FAQ questions table
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS faq_questions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
question_es TEXT,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
answer_es TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
show_on_homepage INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rank INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_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!');
|
||||||
|
|||||||
@@ -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'),
|
||||||
@@ -243,6 +243,8 @@ export const sqliteEmailLogs = sqliteTable('email_logs', {
|
|||||||
sentAt: text('sent_at'),
|
sentAt: text('sent_at'),
|
||||||
sentBy: text('sent_by').references(() => sqliteUsers.id),
|
sentBy: text('sent_by').references(() => sqliteUsers.id),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
|
resendAttempts: integer('resend_attempts').notNull().default(0),
|
||||||
|
lastResentAt: text('last_resent_at'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sqliteEmailSettings = sqliteTable('email_settings', {
|
export const sqliteEmailSettings = sqliteTable('email_settings', {
|
||||||
@@ -267,6 +269,37 @@ export const sqliteLegalPages = sqliteTable('legal_pages', {
|
|||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FAQ questions table (admin-managed, shown on /faq and optionally on homepage)
|
||||||
|
export const sqliteFaqQuestions = sqliteTable('faq_questions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
question: text('question').notNull(),
|
||||||
|
questionEs: text('question_es'),
|
||||||
|
answer: text('answer').notNull(),
|
||||||
|
answerEs: text('answer_es'),
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
showOnHomepage: integer('show_on_homepage', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
rank: integer('rank').notNull().default(0),
|
||||||
|
createdAt: text('created_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(),
|
||||||
@@ -526,6 +559,8 @@ export const pgEmailLogs = pgTable('email_logs', {
|
|||||||
sentAt: timestamp('sent_at'),
|
sentAt: timestamp('sent_at'),
|
||||||
sentBy: uuid('sent_by').references(() => pgUsers.id),
|
sentBy: uuid('sent_by').references(() => pgUsers.id),
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
resendAttempts: pgInteger('resend_attempts').notNull().default(0),
|
||||||
|
lastResentAt: timestamp('last_resent_at'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pgEmailSettings = pgTable('email_settings', {
|
export const pgEmailSettings = pgTable('email_settings', {
|
||||||
@@ -550,6 +585,37 @@ export const pgLegalPages = pgTable('legal_pages', {
|
|||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FAQ questions table (admin-managed)
|
||||||
|
export const pgFaqQuestions = pgTable('faq_questions', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
question: pgText('question').notNull(),
|
||||||
|
questionEs: pgText('question_es'),
|
||||||
|
answer: pgText('answer').notNull(),
|
||||||
|
answerEs: pgText('answer_es'),
|
||||||
|
enabled: pgInteger('enabled').notNull().default(1),
|
||||||
|
showOnHomepage: pgInteger('show_on_homepage').notNull().default(0),
|
||||||
|
rank: pgInteger('rank').notNull().default(0),
|
||||||
|
createdAt: timestamp('created_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(),
|
||||||
@@ -595,8 +661,10 @@ 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;
|
||||||
|
|
||||||
// Type exports
|
// Type exports
|
||||||
export type User = typeof sqliteUsers.$inferSelect;
|
export type User = typeof sqliteUsers.$inferSelect;
|
||||||
@@ -627,3 +695,7 @@ export type SiteSettings = typeof sqliteSiteSettings.$inferSelect;
|
|||||||
export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert;
|
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 NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
|
||||||
|
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
|
||||||
|
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;
|
||||||
@@ -21,7 +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 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();
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ const openApiSpec = {
|
|||||||
{ name: 'Media', description: 'File uploads and media management' },
|
{ name: 'Media', description: 'File uploads and media management' },
|
||||||
{ name: 'Lightning', description: 'Lightning/Bitcoin payments via LNBits' },
|
{ name: 'Lightning', description: 'Lightning/Bitcoin payments via LNBits' },
|
||||||
{ name: 'Admin', description: 'Admin dashboard and analytics' },
|
{ name: 'Admin', description: 'Admin dashboard and analytics' },
|
||||||
|
{ name: 'FAQ', description: 'FAQ questions (public and admin)' },
|
||||||
],
|
],
|
||||||
paths: {
|
paths: {
|
||||||
// ==================== Auth Endpoints ====================
|
// ==================== Auth Endpoints ====================
|
||||||
@@ -1587,6 +1591,144 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ==================== FAQ Endpoints ====================
|
||||||
|
'/api/faq': {
|
||||||
|
get: {
|
||||||
|
tags: ['FAQ'],
|
||||||
|
summary: 'Get FAQ list (public)',
|
||||||
|
description: 'Returns enabled FAQ questions, ordered by rank. Use ?homepage=true to get only questions enabled for homepage.',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'homepage', in: 'query', schema: { type: 'boolean' }, description: 'If true, only return questions with showOnHomepage' },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'List of FAQ items (id, question, questionEs, answer, answerEs, rank)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/faq/admin/list': {
|
||||||
|
get: {
|
||||||
|
tags: ['FAQ'],
|
||||||
|
summary: 'Get all FAQ questions (admin)',
|
||||||
|
description: 'Returns all FAQ questions for management, ordered by rank.',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'List of all FAQ questions' },
|
||||||
|
401: { description: 'Unauthorized' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/faq/admin/:id': {
|
||||||
|
get: {
|
||||||
|
tags: ['FAQ'],
|
||||||
|
summary: 'Get FAQ by ID (admin)',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
parameters: [
|
||||||
|
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'FAQ details' },
|
||||||
|
404: { description: 'FAQ not found' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
put: {
|
||||||
|
tags: ['FAQ'],
|
||||||
|
summary: 'Update FAQ (admin)',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
parameters: [
|
||||||
|
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
question: { type: 'string' },
|
||||||
|
questionEs: { type: 'string' },
|
||||||
|
answer: { type: 'string' },
|
||||||
|
answerEs: { type: 'string' },
|
||||||
|
enabled: { type: 'boolean' },
|
||||||
|
showOnHomepage: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: 'FAQ updated' },
|
||||||
|
404: { description: 'FAQ not found' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
tags: ['FAQ'],
|
||||||
|
summary: 'Delete FAQ (admin)',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
parameters: [
|
||||||
|
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'FAQ deleted' },
|
||||||
|
404: { description: 'FAQ not found' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/faq/admin': {
|
||||||
|
post: {
|
||||||
|
tags: ['FAQ'],
|
||||||
|
summary: 'Create FAQ (admin)',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['question', 'answer'],
|
||||||
|
properties: {
|
||||||
|
question: { type: 'string' },
|
||||||
|
questionEs: { type: 'string' },
|
||||||
|
answer: { type: 'string' },
|
||||||
|
answerEs: { type: 'string' },
|
||||||
|
enabled: { type: 'boolean', default: true },
|
||||||
|
showOnHomepage: { type: 'boolean', default: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: { description: 'FAQ created' },
|
||||||
|
400: { description: 'Validation error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/faq/admin/reorder': {
|
||||||
|
post: {
|
||||||
|
tags: ['FAQ'],
|
||||||
|
summary: 'Reorder FAQ questions (admin)',
|
||||||
|
description: 'Set order by sending an ordered array of FAQ ids.',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['ids'],
|
||||||
|
properties: {
|
||||||
|
ids: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Order updated, returns full FAQ list' },
|
||||||
|
400: { description: 'ids array required' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
@@ -1716,6 +1858,8 @@ 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);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.notFound((c) => {
|
app.notFound((c) => {
|
||||||
@@ -1730,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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -1174,6 +1175,100 @@ export const emailService = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue emails for event attendees (non-blocking).
|
||||||
|
* Adds all matching recipients to the background email queue and returns immediately.
|
||||||
|
* Rate limiting and actual sending is handled by the email queue.
|
||||||
|
*/
|
||||||
|
async queueEventEmails(params: {
|
||||||
|
eventId: string;
|
||||||
|
templateSlug: string;
|
||||||
|
customVariables?: Record<string, any>;
|
||||||
|
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||||
|
sentBy: string;
|
||||||
|
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
|
||||||
|
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
||||||
|
|
||||||
|
// Validate event exists
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, eventId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return { success: false, queuedCount: 0, error: 'Event not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate template exists
|
||||||
|
const template = await this.getTemplate(templateSlug);
|
||||||
|
if (!template) {
|
||||||
|
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tickets based on filter
|
||||||
|
let ticketQuery = (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).eventId, eventId));
|
||||||
|
|
||||||
|
if (recipientFilter !== 'all') {
|
||||||
|
ticketQuery = ticketQuery.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
eq((tickets as any).status, recipientFilter)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTickets = await dbAll<any>(ticketQuery);
|
||||||
|
|
||||||
|
if (eventTickets.length === 0) {
|
||||||
|
return { success: true, queuedCount: 0, error: 'No recipients found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get site timezone for proper date/time formatting
|
||||||
|
const timezone = await this.getSiteTimezone();
|
||||||
|
|
||||||
|
// Build individual email jobs for the queue
|
||||||
|
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
|
||||||
|
const locale = ticket.preferredLanguage || 'en';
|
||||||
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||||
|
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateSlug,
|
||||||
|
to: ticket.attendeeEmail,
|
||||||
|
toName: fullName,
|
||||||
|
locale,
|
||||||
|
eventId: event.id,
|
||||||
|
sentBy,
|
||||||
|
variables: {
|
||||||
|
attendeeName: fullName,
|
||||||
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
|
ticketId: ticket.id,
|
||||||
|
eventTitle,
|
||||||
|
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
||||||
|
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
||||||
|
eventLocation: event.location,
|
||||||
|
eventLocationUrl: event.locationUrl || '',
|
||||||
|
...customVariables,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enqueue all emails for background processing
|
||||||
|
enqueueBulkEmails(jobs);
|
||||||
|
|
||||||
|
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
queuedCount: jobs.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a custom email (not from template)
|
* Send a custom email (not from template)
|
||||||
*/
|
*/
|
||||||
@@ -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
|
||||||
@@ -1245,6 +1342,61 @@ export const emailService = {
|
|||||||
error: result.error
|
error: result.error
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend an email from an existing log entry
|
||||||
|
*/
|
||||||
|
async resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const log = await dbGet<any>(
|
||||||
|
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!log) {
|
||||||
|
return { success: false, error: 'Email log not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
|
||||||
|
return { success: false, error: 'Email log missing required data to resend' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendEmail({
|
||||||
|
to: log.recipientEmail,
|
||||||
|
subject: log.subject,
|
||||||
|
html: log.bodyHtml,
|
||||||
|
text: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await (db as any)
|
||||||
|
.update(emailLogs)
|
||||||
|
.set({
|
||||||
|
status: 'sent',
|
||||||
|
sentAt: now,
|
||||||
|
errorMessage: null,
|
||||||
|
resendAttempts: currentResendAttempts,
|
||||||
|
lastResentAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((emailLogs as any).id, logId));
|
||||||
|
} else {
|
||||||
|
await (db as any)
|
||||||
|
.update(emailLogs)
|
||||||
|
.set({
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: result.error,
|
||||||
|
resendAttempts: currentResendAttempts,
|
||||||
|
lastResentAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((emailLogs as any).id, logId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export the main sendEmail function for direct use
|
// Export the main sendEmail function for direct use
|
||||||
|
|||||||
194
backend/src/lib/emailQueue.ts
Normal file
194
backend/src/lib/emailQueue.ts
Normal 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.`);
|
||||||
|
}
|
||||||
80
backend/src/lib/legal-placeholders.ts
Normal file
80
backend/src/lib/legal-placeholders.ts
Normal 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] ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
22
backend/src/lib/revalidate.ts
Normal file
22
backend/src/lib/revalidate.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -347,6 +349,23 @@ emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
return c.json({ log });
|
return c.json({ log });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resend email from log
|
||||||
|
emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
|
||||||
|
const result = await emailService.resendFromLog(id);
|
||||||
|
|
||||||
|
if (!result.success && result.error === 'Email log not found') {
|
||||||
|
return c.json({ error: 'Email log not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.success && result.error === 'Email log missing required data to resend') {
|
||||||
|
return c.json({ error: result.error }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: result.success, error: result.error });
|
||||||
|
});
|
||||||
|
|
||||||
// Get email stats
|
// Get email stats
|
||||||
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
@@ -411,4 +430,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;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { z } from 'zod';
|
|||||||
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js';
|
||||||
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 } 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;
|
||||||
@@ -74,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)
|
||||||
@@ -151,10 +152,11 @@ eventsRouter.get('/', async (c) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
|
const bookedCount = ticketCount?.count || 0;
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount: ticketCount?.count || 0,
|
bookedCount,
|
||||||
availableSeats: normalized.capacity - (ticketCount?.count || 0),
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -189,11 +191,12 @@ eventsRouter.get('/:id', async (c) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
|
const bookedCount = ticketCount?.count || 0;
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount: ticketCount?.count || 0,
|
bookedCount,
|
||||||
availableSeats: normalized.capacity - (ticketCount?.count || 0),
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -217,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>(
|
||||||
@@ -227,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()
|
||||||
@@ -236,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
|
||||||
@@ -277,7 +273,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount,
|
||||||
availableSeats: normalized.capacity - bookedCount,
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -308,7 +304,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount,
|
||||||
availableSeats: normalized.capacity - bookedCount,
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
isFeatured: false,
|
isFeatured: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -335,6 +331,9 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
|||||||
|
|
||||||
await (db as any).insert(events).values(newEvent);
|
await (db as any).insert(events).values(newEvent);
|
||||||
|
|
||||||
|
// Revalidate sitemap when a new event is created
|
||||||
|
revalidateFrontendCache();
|
||||||
|
|
||||||
// Return normalized event data
|
// Return normalized event data
|
||||||
return c.json({ event: normalizeEvent(newEvent) }, 201);
|
return c.json({ event: normalizeEvent(newEvent) }, 201);
|
||||||
});
|
});
|
||||||
@@ -371,6 +370,9 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
|||||||
(db as any).select().from(events).where(eq((events as any).id, id))
|
(db as any).select().from(events).where(eq((events as any).id, id))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
||||||
|
revalidateFrontendCache();
|
||||||
|
|
||||||
return c.json({ event: normalizeEvent(updated) });
|
return c.json({ event: normalizeEvent(updated) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,6 +429,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
|||||||
// Finally delete the event
|
// Finally delete the event
|
||||||
await (db as any).delete(events).where(eq((events as any).id, id));
|
await (db as any).delete(events).where(eq((events as any).id, id));
|
||||||
|
|
||||||
|
// Revalidate sitemap when an event is deleted
|
||||||
|
revalidateFrontendCache();
|
||||||
|
|
||||||
return c.json({ message: 'Event deleted successfully' });
|
return c.json({ message: 'Event deleted successfully' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
242
backend/src/routes/faq.ts
Normal file
242
backend/src/routes/faq.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { db, dbGet, dbAll, faqQuestions } from '../db/index.js';
|
||||||
|
import { eq, asc } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { getNow, generateId } from '../lib/utils.js';
|
||||||
|
|
||||||
|
const faqRouter = new Hono();
|
||||||
|
|
||||||
|
// ==================== Public Routes ====================
|
||||||
|
|
||||||
|
// Get FAQ list for public (only enabled; optional filter for homepage)
|
||||||
|
faqRouter.get('/', async (c) => {
|
||||||
|
const homepage = c.req.query('homepage') === 'true';
|
||||||
|
|
||||||
|
let query = (db as any)
|
||||||
|
.select()
|
||||||
|
.from(faqQuestions)
|
||||||
|
.where(eq((faqQuestions as any).enabled, 1))
|
||||||
|
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt));
|
||||||
|
|
||||||
|
const rows = await dbAll<any>(query);
|
||||||
|
|
||||||
|
let items = rows;
|
||||||
|
if (homepage) {
|
||||||
|
items = rows.filter((r: any) => r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
faqs: items.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
question: r.question,
|
||||||
|
questionEs: r.questionEs ?? r.question_es ?? null,
|
||||||
|
answer: r.answer,
|
||||||
|
answerEs: r.answerEs ?? r.answer_es ?? null,
|
||||||
|
rank: r.rank ?? 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Admin Routes ====================
|
||||||
|
|
||||||
|
// Get all FAQ questions for admin (all, ordered by rank)
|
||||||
|
faqRouter.get('/admin/list', requireAuth(['admin']), async (c) => {
|
||||||
|
const rows = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(faqQuestions)
|
||||||
|
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt))
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = rows.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
question: r.question,
|
||||||
|
questionEs: r.questionEs ?? r.question_es ?? null,
|
||||||
|
answer: r.answer,
|
||||||
|
answerEs: r.answerEs ?? r.answer_es ?? null,
|
||||||
|
enabled: r.enabled === true || r.enabled === 1,
|
||||||
|
showOnHomepage: r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1,
|
||||||
|
rank: r.rank ?? 0,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({ faqs: list });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get one FAQ by id (admin)
|
||||||
|
faqRouter.get('/admin/:id', requireAuth(['admin']), async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const row = await dbGet<any>(
|
||||||
|
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
|
||||||
|
);
|
||||||
|
if (!row) {
|
||||||
|
return c.json({ error: 'FAQ not found' }, 404);
|
||||||
|
}
|
||||||
|
return c.json({
|
||||||
|
faq: {
|
||||||
|
id: row.id,
|
||||||
|
question: row.question,
|
||||||
|
questionEs: row.questionEs ?? row.question_es ?? null,
|
||||||
|
answer: row.answer,
|
||||||
|
answerEs: row.answerEs ?? row.answer_es ?? null,
|
||||||
|
enabled: row.enabled === true || row.enabled === 1,
|
||||||
|
showOnHomepage: row.showOnHomepage === true || row.showOnHomepage === 1 || row.show_on_homepage === true || row.show_on_homepage === 1,
|
||||||
|
rank: row.rank ?? 0,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create FAQ (admin)
|
||||||
|
faqRouter.post('/admin', requireAuth(['admin']), async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { question, questionEs, answer, answerEs, enabled, showOnHomepage } = body;
|
||||||
|
|
||||||
|
if (!question || typeof question !== 'string' || !answer || typeof answer !== 'string') {
|
||||||
|
return c.json({ error: 'Question and answer (EN) are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const id = generateId();
|
||||||
|
|
||||||
|
const allForRank = await dbAll<any>(
|
||||||
|
(db as any).select({ rank: (faqQuestions as any).rank }).from(faqQuestions)
|
||||||
|
);
|
||||||
|
const maxRank = allForRank.length
|
||||||
|
? Math.max(...allForRank.map((r: any) => Number(r.rank ?? 0)))
|
||||||
|
: 0;
|
||||||
|
const nextRank = maxRank + 1;
|
||||||
|
|
||||||
|
await (db as any).insert(faqQuestions).values({
|
||||||
|
id,
|
||||||
|
question: String(question).trim(),
|
||||||
|
questionEs: questionEs != null ? String(questionEs).trim() : null,
|
||||||
|
answer: String(answer).trim(),
|
||||||
|
answerEs: answerEs != null ? String(answerEs).trim() : null,
|
||||||
|
enabled: enabled !== false ? 1 : 0,
|
||||||
|
showOnHomepage: showOnHomepage === true ? 1 : 0,
|
||||||
|
rank: nextRank,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await dbGet<any>(
|
||||||
|
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
|
||||||
|
);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
faq: {
|
||||||
|
id: created.id,
|
||||||
|
question: created.question,
|
||||||
|
questionEs: created.questionEs ?? created.question_es ?? null,
|
||||||
|
answer: created.answer,
|
||||||
|
answerEs: created.answerEs ?? created.answer_es ?? null,
|
||||||
|
enabled: created.enabled === true || created.enabled === 1,
|
||||||
|
showOnHomepage: created.showOnHomepage === true || created.showOnHomepage === 1 || created.show_on_homepage === true || created.show_on_homepage === 1,
|
||||||
|
rank: created.rank ?? 0,
|
||||||
|
createdAt: created.createdAt,
|
||||||
|
updatedAt: created.updatedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
201
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update FAQ (admin)
|
||||||
|
faqRouter.put('/admin/:id', requireAuth(['admin']), async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { question, questionEs, answer, answerEs, enabled, showOnHomepage } = body;
|
||||||
|
|
||||||
|
const existing = await dbGet<any>(
|
||||||
|
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'FAQ not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {
|
||||||
|
updatedAt: getNow(),
|
||||||
|
};
|
||||||
|
if (question !== undefined) updateData.question = String(question).trim();
|
||||||
|
if (questionEs !== undefined) updateData.questionEs = questionEs == null ? null : String(questionEs).trim();
|
||||||
|
if (answer !== undefined) updateData.answer = String(answer).trim();
|
||||||
|
if (answerEs !== undefined) updateData.answerEs = answerEs == null ? null : String(answerEs).trim();
|
||||||
|
if (typeof enabled === 'boolean') updateData.enabled = enabled ? 1 : 0;
|
||||||
|
if (typeof showOnHomepage === 'boolean') updateData.showOnHomepage = showOnHomepage ? 1 : 0;
|
||||||
|
|
||||||
|
await (db as any).update(faqQuestions).set(updateData).where(eq((faqQuestions as any).id, id));
|
||||||
|
|
||||||
|
const updated = await dbGet<any>(
|
||||||
|
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
|
||||||
|
);
|
||||||
|
return c.json({
|
||||||
|
faq: {
|
||||||
|
id: updated.id,
|
||||||
|
question: updated.question,
|
||||||
|
questionEs: updated.questionEs ?? updated.question_es ?? null,
|
||||||
|
answer: updated.answer,
|
||||||
|
answerEs: updated.answerEs ?? updated.answer_es ?? null,
|
||||||
|
enabled: updated.enabled === true || updated.enabled === 1,
|
||||||
|
showOnHomepage: updated.showOnHomepage === true || updated.showOnHomepage === 1 || updated.show_on_homepage === true || updated.show_on_homepage === 1,
|
||||||
|
rank: updated.rank ?? 0,
|
||||||
|
createdAt: updated.createdAt,
|
||||||
|
updatedAt: updated.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete FAQ (admin)
|
||||||
|
faqRouter.delete('/admin/:id', requireAuth(['admin']), async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const existing = await dbGet<any>(
|
||||||
|
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'FAQ not found' }, 404);
|
||||||
|
}
|
||||||
|
await (db as any).delete(faqQuestions).where(eq((faqQuestions as any).id, id));
|
||||||
|
return c.json({ message: 'FAQ deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reorder FAQs (admin) – body: { ids: string[] } (ordered list of ids)
|
||||||
|
faqRouter.post('/admin/reorder', requireAuth(['admin']), async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { ids } = body;
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
|
return c.json({ error: 'ids array is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
await (db as any)
|
||||||
|
.update(faqQuestions)
|
||||||
|
.set({ rank: i, updatedAt: now })
|
||||||
|
.where(eq((faqQuestions as any).id, ids[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(faqQuestions)
|
||||||
|
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt))
|
||||||
|
);
|
||||||
|
const list = rows.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
question: r.question,
|
||||||
|
questionEs: r.questionEs ?? r.question_es ?? null,
|
||||||
|
answer: r.answer,
|
||||||
|
answerEs: r.answerEs ?? r.answer_es ?? null,
|
||||||
|
enabled: r.enabled === true || r.enabled === 1,
|
||||||
|
showOnHomepage: r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1,
|
||||||
|
rank: r.rank ?? 0,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({ faqs: list });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default faqRouter;
|
||||||
@@ -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',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
146
backend/src/routes/legal-settings.ts
Normal file
146
backend/src/routes/legal-settings.ts
Normal 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;
|
||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +200,13 @@ 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
|
||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ 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, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow } 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';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,15 +87,16 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const availableSeats = event.capacity - (existingTicketCount?.count || 0);
|
const confirmedCount = existingTicketCount?.count || 0;
|
||||||
|
const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount);
|
||||||
|
|
||||||
if (availableSeats <= 0) {
|
if (isEventSoldOut(event.capacity, confirmedCount)) {
|
||||||
return c.json({ error: 'Event is sold out' }, 400);
|
return c.json({ error: 'Event is sold out' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticketCount > availableSeats) {
|
if (ticketCount > availableSeats) {
|
||||||
return c.json({
|
return c.json({
|
||||||
error: `Not enough seats available. Only ${availableSeats} spot(s) remaining.`
|
error: `Not enough seats available. Only ${availableSeats} spot(s) remaining.`,
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,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');
|
||||||
@@ -553,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(() => ({}));
|
||||||
@@ -969,22 +1148,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check capacity
|
// Admin create at door: bypass capacity check (allow over-capacity for walk-ins)
|
||||||
const ticketCount = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(tickets)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((tickets as any).eventId, data.eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((ticketCount?.count || 0) >= event.capacity) {
|
|
||||||
return c.json({ error: 'Event is at capacity' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
@@ -1097,7 +1261,140 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all tickets (admin)
|
// Admin create manual ticket (sends confirmation email + ticket to attendee)
|
||||||
|
ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
||||||
|
eventId: z.string(),
|
||||||
|
firstName: z.string().min(2),
|
||||||
|
lastName: z.string().optional().or(z.literal('')),
|
||||||
|
email: z.string().email('Valid email is required for manual tickets'),
|
||||||
|
phone: z.string().optional().or(z.literal('')),
|
||||||
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
|
adminNote: z.string().max(1000).optional(),
|
||||||
|
})), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
// Get event
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
||||||
|
);
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets)
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const attendeeEmail = data.email.trim();
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
let user = await dbGet<any>(
|
||||||
|
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
||||||
|
);
|
||||||
|
|
||||||
|
const fullName = data.lastName && data.lastName.trim()
|
||||||
|
? `${data.firstName} ${data.lastName}`.trim()
|
||||||
|
: data.firstName;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const userId = generateId();
|
||||||
|
user = {
|
||||||
|
id: userId,
|
||||||
|
email: attendeeEmail,
|
||||||
|
password: '',
|
||||||
|
name: fullName,
|
||||||
|
phone: data.phone || null,
|
||||||
|
role: 'user',
|
||||||
|
languagePreference: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await (db as any).insert(users).values(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing active ticket for this user and event
|
||||||
|
const existingTicket = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).userId, user.id),
|
||||||
|
eq((tickets as any).eventId, data.eventId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||||
|
return c.json({ error: 'This person already has a ticket for this event' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ticket as confirmed
|
||||||
|
const ticketId = generateId();
|
||||||
|
const qrCode = generateTicketCode();
|
||||||
|
|
||||||
|
const newTicket = {
|
||||||
|
id: ticketId,
|
||||||
|
userId: user.id,
|
||||||
|
eventId: data.eventId,
|
||||||
|
attendeeFirstName: data.firstName,
|
||||||
|
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||||
|
attendeeEmail: attendeeEmail,
|
||||||
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
|
status: 'confirmed',
|
||||||
|
qrCode,
|
||||||
|
checkinAt: null,
|
||||||
|
adminNote: data.adminNote || null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(tickets).values(newTicket);
|
||||||
|
|
||||||
|
// Create payment record (marked as paid - manual entry)
|
||||||
|
const paymentId = generateId();
|
||||||
|
const adminUser = (c as any).get('user');
|
||||||
|
const newPayment = {
|
||||||
|
id: paymentId,
|
||||||
|
ticketId,
|
||||||
|
provider: 'cash',
|
||||||
|
amount: event.price,
|
||||||
|
currency: event.currency,
|
||||||
|
status: 'paid',
|
||||||
|
reference: 'Manual ticket',
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: adminUser?.id || null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(payments).values(newPayment);
|
||||||
|
|
||||||
|
// Send booking confirmation email + ticket (asynchronously)
|
||||||
|
emailService.sendBookingConfirmation(ticketId).then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[Email] Exception sending booking confirmation for manual ticket:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ticket: {
|
||||||
|
...newTicket,
|
||||||
|
event: {
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payment: newPayment,
|
||||||
|
message: 'Manual ticket created and confirmation email sent',
|
||||||
|
}, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all tickets (admin) - includes payment for each ticket
|
||||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
@@ -1116,9 +1413,25 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
query = query.where(and(...conditions));
|
query = query.where(and(...conditions));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll(query);
|
const ticketsList = await dbAll(query);
|
||||||
|
const ticketIds = ticketsList.map((t: any) => t.id);
|
||||||
|
|
||||||
return c.json({ tickets: result });
|
let paymentByTicketId: Record<string, any> = {};
|
||||||
|
if (ticketIds.length > 0) {
|
||||||
|
const paymentsList = await dbAll(
|
||||||
|
(db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))
|
||||||
|
);
|
||||||
|
for (const p of paymentsList as any[]) {
|
||||||
|
paymentByTicketId[p.ticketId] = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketsWithPayment = ticketsList.map((t: any) => ({
|
||||||
|
...t,
|
||||||
|
payment: paymentByTicketId[t.id] || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({ tickets: ticketsWithPayment });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ticketsRouter;
|
export default ticketsRouter;
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ const usersRouter = new Hono<{ Variables: { user: UserContext } }>();
|
|||||||
|
|
||||||
const updateUserSchema = z.object({
|
const updateUserSchema = z.object({
|
||||||
name: z.string().min(2).optional(),
|
name: z.string().min(2).optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
|
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
|
||||||
languagePreference: z.enum(['en', 'es']).optional(),
|
languagePreference: z.enum(['en', 'es']).optional(),
|
||||||
|
accountStatus: z.enum(['active', 'unclaimed', 'suspended']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all users (admin only)
|
// Get all users (admin only)
|
||||||
@@ -33,6 +35,9 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
phone: (users as any).phone,
|
phone: (users as any).phone,
|
||||||
role: (users as any).role,
|
role: (users as any).role,
|
||||||
languagePreference: (users as any).languagePreference,
|
languagePreference: (users as any).languagePreference,
|
||||||
|
isClaimed: (users as any).isClaimed,
|
||||||
|
rucNumber: (users as any).rucNumber,
|
||||||
|
accountStatus: (users as any).accountStatus,
|
||||||
createdAt: (users as any).createdAt,
|
createdAt: (users as any).createdAt,
|
||||||
}).from(users);
|
}).from(users);
|
||||||
|
|
||||||
@@ -64,6 +69,9 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
|||||||
phone: (users as any).phone,
|
phone: (users as any).phone,
|
||||||
role: (users as any).role,
|
role: (users as any).role,
|
||||||
languagePreference: (users as any).languagePreference,
|
languagePreference: (users as any).languagePreference,
|
||||||
|
isClaimed: (users as any).isClaimed,
|
||||||
|
rucNumber: (users as any).rucNumber,
|
||||||
|
accountStatus: (users as any).accountStatus,
|
||||||
createdAt: (users as any).createdAt,
|
createdAt: (users as any).createdAt,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -88,10 +96,16 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
|||||||
return c.json({ error: 'Forbidden' }, 403);
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only admin can change roles
|
// Only admin can change roles, email, and account status
|
||||||
if (data.role && currentUser.role !== 'admin') {
|
if (data.role && currentUser.role !== 'admin') {
|
||||||
delete data.role;
|
delete data.role;
|
||||||
}
|
}
|
||||||
|
if (data.email && currentUser.role !== 'admin') {
|
||||||
|
delete data.email;
|
||||||
|
}
|
||||||
|
if (data.accountStatus && currentUser.role !== 'admin') {
|
||||||
|
delete data.accountStatus;
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await dbGet(
|
const existing = await dbGet(
|
||||||
(db as any).select().from(users).where(eq((users as any).id, id))
|
(db as any).select().from(users).where(eq((users as any).id, id))
|
||||||
@@ -114,6 +128,10 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
|||||||
phone: (users as any).phone,
|
phone: (users as any).phone,
|
||||||
role: (users as any).role,
|
role: (users as any).role,
|
||||||
languagePreference: (users as any).languagePreference,
|
languagePreference: (users as any).languagePreference,
|
||||||
|
isClaimed: (users as any).isClaimed,
|
||||||
|
rucNumber: (users as any).rucNumber,
|
||||||
|
accountStatus: (users as any).accountStatus,
|
||||||
|
createdAt: (users as any).createdAt,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq((users as any).id, id))
|
.where(eq((users as any).id, id))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ NEXT_PUBLIC_EMAIL=hola@spanglish.com.py
|
|||||||
NEXT_PUBLIC_TELEGRAM=spanglish_py
|
NEXT_PUBLIC_TELEGRAM=spanglish_py
|
||||||
NEXT_PUBLIC_TIKTOK=spanglishsocialpy
|
NEXT_PUBLIC_TIKTOK=spanglishsocialpy
|
||||||
|
|
||||||
|
# Revalidation secret (shared between frontend and backend for on-demand cache revalidation)
|
||||||
|
# Must match the REVALIDATE_SECRET in backend/.env
|
||||||
|
REVALIDATE_SECRET=change-me-to-a-random-secret
|
||||||
|
|
||||||
|
# Next event cache revalidation (seconds) - homepage metadata/social preview refresh interval. Default: 3600
|
||||||
|
NEXT_EVENT_REVALIDATE_SECONDS=3600
|
||||||
|
|
||||||
# Plausible Analytics (optional - leave empty to disable tracking)
|
# 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
|
||||||
|
|||||||
BIN
frontend/public/images/spanglish-icon.png
Normal file
BIN
frontend/public/images/spanglish-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -6,7 +6,7 @@ import Link from 'next/link';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import { formatPrice } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
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';
|
||||||
@@ -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;
|
||||||
@@ -157,7 +157,25 @@ export default function BookingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bookedCount = eventRes.event.bookedCount ?? 0;
|
||||||
|
const capacity = eventRes.event.capacity ?? 0;
|
||||||
|
const soldOut = bookedCount >= capacity;
|
||||||
|
if (soldOut) {
|
||||||
|
toast.error(t('events.details.soldOut'));
|
||||||
|
router.push(`/events/${eventRes.event.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotsLeft = Math.max(0, capacity - bookedCount);
|
||||||
setEvent(eventRes.event);
|
setEvent(eventRes.event);
|
||||||
|
// Cap quantity by available spots (never allow requesting more than spotsLeft)
|
||||||
|
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
|
||||||
|
setAttendees((prev) => {
|
||||||
|
const newQty = Math.min(initialQuantity, Math.max(1, spotsLeft));
|
||||||
|
const need = Math.max(0, newQty - 1);
|
||||||
|
if (need === prev.length) return prev;
|
||||||
|
return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' });
|
||||||
|
});
|
||||||
setPaymentConfig(paymentRes.paymentOptions);
|
setPaymentConfig(paymentRes.paymentOptions);
|
||||||
|
|
||||||
// Set default payment method based on what's enabled
|
// Set default payment method based on what's enabled
|
||||||
@@ -199,21 +217,8 @@ export default function BookingPage() {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: Partial<Record<keyof BookingFormData, string>> = {};
|
const newErrors: Partial<Record<keyof BookingFormData, string>> = {};
|
||||||
@@ -513,7 +518,8 @@ export default function BookingPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSoldOut = event.availableSeats === 0;
|
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
||||||
|
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
||||||
|
|
||||||
// Get title and description based on payment method
|
// Get title and description based on payment method
|
||||||
const getSuccessContent = () => {
|
const getSuccessContent = () => {
|
||||||
@@ -860,7 +866,7 @@ export default function BookingPage() {
|
|||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
|
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
|
||||||
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
|
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
|
||||||
<p><strong>{t('booking.success.time')}:</strong> {event && formatTime(event.startDatetime)}</p>
|
<p><strong>{t('booking.success.time')}:</strong> {event && fmtTime(event.startDatetime)}</p>
|
||||||
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
|
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -936,7 +942,7 @@ export default function BookingPage() {
|
|||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
|
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
|
||||||
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
|
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
|
||||||
<p><strong>{t('booking.success.time')}:</strong> {formatTime(event.startDatetime)}</p>
|
<p><strong>{t('booking.success.time')}:</strong> {fmtTime(event.startDatetime)}</p>
|
||||||
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
|
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1026,7 +1032,7 @@ export default function BookingPage() {
|
|||||||
<div className="p-4 space-y-2 text-sm">
|
<div className="p-4 space-y-2 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
<span>{formatDate(event.startDatetime)} • {formatTime(event.startDatetime)}</span>
|
<span>{formatDate(event.startDatetime)} • {fmtTime(event.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
@@ -1035,7 +1041,7 @@ export default function BookingPage() {
|
|||||||
{!event.externalBookingEnabled && (
|
{!event.externalBookingEnabled && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
<span>{event.availableSeats} {t('events.details.spotsLeft')}</span>
|
<span>{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import { formatPrice } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
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 {
|
import {
|
||||||
@@ -152,21 +152,8 @@ export default function BookingPaymentPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (step === 'loading') {
|
if (step === 'loading') {
|
||||||
@@ -237,7 +224,7 @@ export default function BookingPaymentPage() {
|
|||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -286,7 +273,7 @@ export default function BookingPaymentPage() {
|
|||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -333,7 +320,7 @@ export default function BookingPaymentPage() {
|
|||||||
<div className="p-4 space-y-2 text-sm">
|
<div className="p-4 space-y-2 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
<span>{formatDate(ticket.event.startDatetime)} - {formatTime(ticket.event.startDatetime)}</span>
|
<span>{formatDate(ticket.event.startDatetime)} - {fmtTime(ticket.event.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, Ticket } from '@/lib/api';
|
import { ticketsApi, Ticket } from '@/lib/api';
|
||||||
|
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||||
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 {
|
import {
|
||||||
@@ -69,21 +70,8 @@ export default function BookingSuccessPage() {
|
|||||||
};
|
};
|
||||||
}, [ticketId]);
|
}, [ticketId]);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -191,7 +179,7 @@ export default function BookingSuccessPage() {
|
|||||||
<>
|
<>
|
||||||
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
|
||||||
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
82
frontend/src/app/(public)/components/HomepageFaqSection.tsx
Normal file
82
frontend/src/app/(public)/components/HomepageFaqSection.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { faqApi, FaqItem } from '@/lib/api';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function HomepageFaqSection() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [faqs, setFaqs] = useState<FaqItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
faqApi.getList(true).then((res) => {
|
||||||
|
if (!cancelled) setFaqs(res.faqs);
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading || faqs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section-padding bg-secondary-gray" aria-labelledby="homepage-faq-title">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<h2 id="homepage-faq-title" className="text-2xl md:text-3xl font-bold text-primary-dark text-center mb-8">
|
||||||
|
{t('home.faq.title')}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{faqs.map((faq, index) => (
|
||||||
|
<div
|
||||||
|
key={faq.id}
|
||||||
|
className="bg-white rounded-btn border border-gray-200 overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenIndex(openIndex === index ? null : index)}
|
||||||
|
className="w-full px-5 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-primary-dark pr-4 text-sm md:text-base">
|
||||||
|
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={clsx(
|
||||||
|
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
|
||||||
|
openIndex === index && 'transform rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'overflow-hidden transition-all duration-200',
|
||||||
|
openIndex === index ? 'max-h-80' : 'max-h-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="px-5 pb-4 text-gray-600 text-sm md:text-base">
|
||||||
|
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<Link
|
||||||
|
href="/faq"
|
||||||
|
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||||
|
>
|
||||||
|
{t('home.faq.seeFull')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,38 +4,51 @@ import { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
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 } 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';
|
|
||||||
|
|
||||||
export default function NextEventSection() {
|
interface NextEventSectionProps {
|
||||||
|
initialEvent?: Event | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NextEventSection({ initialEvent }: NextEventSectionProps) {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const [nextEvent, setNextEvent] = useState<Event | null>(null);
|
const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(initialEvent === undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialEvent !== undefined) {
|
||||||
|
if (initialEvent) {
|
||||||
|
const endTime = initialEvent.endDatetime || initialEvent.startDatetime;
|
||||||
|
if (new Date(endTime).getTime() <= Date.now()) {
|
||||||
|
setNextEvent(null);
|
||||||
|
setLoading(true);
|
||||||
eventsApi.getNextUpcoming()
|
eventsApi.getNextUpcoming()
|
||||||
.then(({ event }) => setNextEvent(event))
|
.then(({ event }) => setNextEvent(event))
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eventsApi.getNextUpcoming()
|
||||||
|
.then(({ event }) => setNextEvent(event))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [initialEvent]);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
const title = nextEvent
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
? (locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title)
|
||||||
hour: '2-digit',
|
: '';
|
||||||
minute: '2-digit',
|
const description = nextEvent
|
||||||
});
|
? (locale === 'es'
|
||||||
};
|
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
|
||||||
|
: (nextEvent.shortDescription || nextEvent.description))
|
||||||
|
: '';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -56,56 +69,72 @@ export default function NextEventSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
) : (
|
||||||
|
<div className="w-full md:w-2/5 flex-shrink-0 h-48 md:h-auto bg-gradient-to-br from-primary-yellow/20 to-secondary-gray flex items-center justify-center">
|
||||||
|
<CalendarIcon className="w-16 h-16 text-gray-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 space-y-3">
|
{/* Info */}
|
||||||
<div className="flex items-center gap-3 text-gray-700">
|
<div className="flex-1 p-5 md:p-8 flex flex-col justify-between">
|
||||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
<div>
|
||||||
|
<h3 className="text-xl md:text-2xl font-bold text-primary-dark group-hover:text-brand-navy transition-colors">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 text-sm md:text-base text-gray-600 line-clamp-2">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 md:mt-5 space-y-2">
|
||||||
|
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||||
|
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
<span>{formatDate(nextEvent.startDatetime)}</span>
|
<span>{formatDate(nextEvent.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-gray-700">
|
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||||
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
|
<ClockIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
⏰
|
<span>{fmtTime(nextEvent.startDatetime)}</span>
|
||||||
</span>
|
|
||||||
<span>{formatTime(nextEvent.startDatetime)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-gray-700">
|
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
<span>{nextEvent.location}</span>
|
<span>{nextEvent.location}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col justify-between items-start md:items-end">
|
<div className="mt-5 md:mt-6 flex items-center justify-between gap-4">
|
||||||
<div className="text-right">
|
<div>
|
||||||
<span className="text-3xl font-bold text-primary-dark">
|
<span className="text-2xl md:text-3xl font-bold text-primary-dark">
|
||||||
{nextEvent.price === 0
|
{nextEvent.price === 0
|
||||||
? t('events.details.free')
|
? t('events.details.free')
|
||||||
: formatPrice(nextEvent.price, nextEvent.currency)}
|
: formatPrice(nextEvent.price, nextEvent.currency)}
|
||||||
</span>
|
</span>
|
||||||
{!nextEvent.externalBookingEnabled && (
|
{!nextEvent.externalBookingEnabled && nextEvent.availableSeats != null && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button size="lg" className="mt-6">
|
<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')}
|
{t('common.moreInfo')}
|
||||||
</Button>
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import NextEventSection from './NextEventSection';
|
import NextEventSection from './NextEventSection';
|
||||||
|
import { Event } from '@/lib/api';
|
||||||
|
|
||||||
export default function NextEventSectionWrapper() {
|
interface NextEventSectionWrapperProps {
|
||||||
|
initialEvent?: Event | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NextEventSectionWrapper({ initialEvent }: NextEventSectionWrapperProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -12,8 +17,8 @@ export default function NextEventSectionWrapper() {
|
|||||||
<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 />
|
<NextEventSection initialEvent={initialEvent} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
|
|||||||
{profile?.memberSince
|
{profile?.memberSince
|
||||||
? new Date(profile.memberSince).toLocaleDateString(
|
? new Date(profile.memberSince).toLocaleDateString(
|
||||||
language === 'es' ? 'es-ES' : 'en-US',
|
language === 'es' ? 'es-ES' : 'en-US',
|
||||||
{ year: 'numeric', month: 'long', day: 'numeric' }
|
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
||||||
)
|
)
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export default function SecurityTab() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAuth } from '@/context/AuthContext';
|
|||||||
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 { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
|
import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
|
||||||
|
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
@@ -85,21 +86,8 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(language === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding min-h-[70vh]">
|
<div className="section-padding min-h-[70vh]">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
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 } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
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 ShareButtons from '@/components/ShareButtons';
|
import ShareButtons from '@/components/ShareButtons';
|
||||||
@@ -41,8 +41,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
// Max tickets is remaining capacity
|
// Spots left: never negative; sold out when confirmed >= capacity
|
||||||
const maxTickets = Math.max(1, event.availableSeats || 1);
|
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
||||||
|
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
||||||
|
const maxTickets = isSoldOut ? 0 : Math.max(1, spotsLeft);
|
||||||
|
|
||||||
const decreaseQuantity = () => {
|
const decreaseQuantity = () => {
|
||||||
setTicketQuantity(prev => Math.max(1, prev - 1));
|
setTicketQuantity(prev => Math.max(1, prev - 1));
|
||||||
@@ -52,27 +54,13 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
setTicketQuantity(prev => Math.min(maxTickets, prev + 1));
|
setTicketQuantity(prev => Math.min(maxTickets, prev + 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSoldOut = event.availableSeats === 0;
|
|
||||||
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 = () => (
|
||||||
@@ -154,7 +142,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
|
|
||||||
{!event.externalBookingEnabled && (
|
{!event.externalBookingEnabled && (
|
||||||
<p className="mt-4 text-center text-sm text-gray-500">
|
<p className="mt-4 text-center text-sm text-gray-500">
|
||||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -227,8 +215,8 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">{t('events.details.time')}</p>
|
<p className="font-medium text-sm">{t('events.details.time')}</p>
|
||||||
<p className="text-gray-600" suppressHydrationWarning>
|
<p className="text-gray-600" suppressHydrationWarning>
|
||||||
{formatTime(event.startDatetime)}
|
{fmtTime(event.startDatetime)}
|
||||||
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
|
{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,7 +245,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
|
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -97,8 +97,6 @@ function generateEventJsonLd(event: Event) {
|
|||||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||||
eventStatus: isCancelled
|
eventStatus: isCancelled
|
||||||
? 'https://schema.org/EventCancelled'
|
? 'https://schema.org/EventCancelled'
|
||||||
: isPastEvent
|
|
||||||
? 'https://schema.org/EventPostponed'
|
|
||||||
: 'https://schema.org/EventScheduled',
|
: 'https://schema.org/EventScheduled',
|
||||||
location: {
|
location: {
|
||||||
'@type': 'Place',
|
'@type': 'Place',
|
||||||
@@ -118,7 +116,7 @@ function generateEventJsonLd(event: Event) {
|
|||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
price: event.price,
|
price: event.price,
|
||||||
priceCurrency: event.currency,
|
priceCurrency: event.currency,
|
||||||
availability: event.availableSeats && event.availableSeats > 0
|
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
|
||||||
? 'https://schema.org/InStock'
|
? 'https://schema.org/InStock'
|
||||||
: 'https://schema.org/SoldOut',
|
: 'https://schema.org/SoldOut',
|
||||||
url: `${siteUrl}/events/${event.id}`,
|
url: `${siteUrl}/events/${event.id}`,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
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 } from '@/lib/utils';
|
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
||||||
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 { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||||
@@ -33,20 +33,8 @@ export default function EventsPage() {
|
|||||||
|
|
||||||
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (event: Event) => {
|
const getStatusBadge = (event: Event) => {
|
||||||
if (event.status === 'cancelled') {
|
if (event.status === 'cancelled') {
|
||||||
@@ -130,7 +118,7 @@ export default function EventsPage() {
|
|||||||
<div className="mt-4 space-y-2 text-sm text-gray-600">
|
<div className="mt-4 space-y-2 text-sm text-gray-600">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarIcon className="w-4 h-4" />
|
<CalendarIcon className="w-4 h-4" />
|
||||||
<span>{formatDate(event.startDatetime)} - {formatTime(event.startDatetime)}</span>
|
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPinIcon className="w-4 h-4" />
|
<MapPinIcon className="w-4 h-4" />
|
||||||
@@ -140,7 +128,7 @@ export default function EventsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserGroupIcon className="w-4 h-4" />
|
<UserGroupIcon className="w-4 h-4" />
|
||||||
<span>
|
<span>
|
||||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,44 +1,21 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
// FAQ Page structured data
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
const faqSchema = {
|
|
||||||
'@context': 'https://schema.org',
|
async function getFaqForSchema(): Promise<{ question: string; answer: string }[]> {
|
||||||
'@type': 'FAQPage',
|
try {
|
||||||
mainEntity: [
|
const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } });
|
||||||
{
|
if (!res.ok) return [];
|
||||||
'@type': 'Question',
|
const data = await res.json();
|
||||||
name: 'What is Spanglish?',
|
const faqs = data.faqs || [];
|
||||||
acceptedAnswer: {
|
return faqs.map((f: { question: string; questionEs?: string | null; answer: string; answerEs?: string | null }) => ({
|
||||||
'@type': 'Answer',
|
question: f.question,
|
||||||
text: 'Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.',
|
answer: f.answer || '',
|
||||||
},
|
}));
|
||||||
},
|
} catch {
|
||||||
{
|
return [];
|
||||||
'@type': 'Question',
|
}
|
||||||
name: 'Who can attend Spanglish events?',
|
}
|
||||||
acceptedAnswer: {
|
|
||||||
'@type': 'Answer',
|
|
||||||
text: 'Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'@type': 'Question',
|
|
||||||
name: 'How do language exchange events work?',
|
|
||||||
acceptedAnswer: {
|
|
||||||
'@type': 'Answer',
|
|
||||||
text: 'Our events typically last 2-3 hours. You will be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'@type': 'Question',
|
|
||||||
name: 'Do I need to speak the language already?',
|
|
||||||
acceptedAnswer: {
|
|
||||||
'@type': 'Answer',
|
|
||||||
text: 'Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Frequently Asked Questions',
|
title: 'Frequently Asked Questions',
|
||||||
@@ -49,11 +26,25 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FAQLayout({
|
export default async function FAQLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const faqList = await getFaqForSchema();
|
||||||
|
const faqSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: faqList.map(({ question, answer }) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: question,
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: answer,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
<script
|
||||||
|
|||||||
@@ -1,89 +1,44 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { faqApi, FaqItem } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
interface FAQItem {
|
|
||||||
question: string;
|
|
||||||
questionEs: string;
|
|
||||||
answer: string;
|
|
||||||
answerEs: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const faqs: FAQItem[] = [
|
|
||||||
{
|
|
||||||
question: "What is Spanglish?",
|
|
||||||
questionEs: "¿Qué es Spanglish?",
|
|
||||||
answer: "Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.",
|
|
||||||
answerEs: "Spanglish es una comunidad de intercambio de idiomas en Asunción, Paraguay. Organizamos eventos mensuales donde hablantes de español e inglés se reúnen para practicar idiomas, conocer gente nueva y divertirse en un ambiente social relajado."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Who can attend Spanglish events?",
|
|
||||||
questionEs: "¿Quién puede asistir a los eventos de Spanglish?",
|
|
||||||
answer: "Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.",
|
|
||||||
answerEs: "¡Cualquier persona interesada en practicar inglés o español es bienvenida! Aceptamos todos los niveles - desde principiantes hasta hablantes nativos. Nuestros eventos están diseñados para ser inclusivos y acogedores para todos."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "How do events work?",
|
|
||||||
questionEs: "¿Cómo funcionan los eventos?",
|
|
||||||
answer: "Our events typically last 2-3 hours. You'll be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.",
|
|
||||||
answerEs: "Nuestros eventos suelen durar 2-3 horas. Serás emparejado con personas que hablan el idioma que quieres practicar. Rotamos parejas durante la noche para que puedas conocer a varias personas. También hay actividades grupales y tiempo de conversación libre."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "How much does it cost to attend?",
|
|
||||||
questionEs: "¿Cuánto cuesta asistir?",
|
|
||||||
answer: "Event prices vary but are always kept affordable. The price covers venue costs and event organization. Check each event page for specific pricing. Some special events may be free!",
|
|
||||||
answerEs: "Los precios de los eventos varían pero siempre se mantienen accesibles. El precio cubre los costos del local y la organización del evento. Consulta la página de cada evento para precios específicos. ¡Algunos eventos especiales pueden ser gratis!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "What payment methods do you accept?",
|
|
||||||
questionEs: "¿Qué métodos de pago aceptan?",
|
|
||||||
answer: "We accept multiple payment methods: credit/debit cards through Bancard, Bitcoin Lightning for crypto enthusiasts, and cash payment at the event. You can choose your preferred method when booking.",
|
|
||||||
answerEs: "Aceptamos múltiples métodos de pago: tarjetas de crédito/débito a través de Bancard, Bitcoin Lightning para entusiastas de cripto, y pago en efectivo en el evento. Puedes elegir tu método preferido al reservar."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Do I need to speak the language already?",
|
|
||||||
questionEs: "¿Necesito ya hablar el idioma?",
|
|
||||||
answer: "Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice. It's a judgment-free zone for learning.",
|
|
||||||
answerEs: "¡Para nada! Damos la bienvenida a principiantes absolutos. Nuestros eventos están estructurados para apoyar todos los niveles. Los hablantes nativos son pacientes y felices de ayudar a los principiantes a practicar. Es una zona libre de juicios para aprender."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Can I come alone?",
|
|
||||||
questionEs: "¿Puedo ir solo/a?",
|
|
||||||
answer: "Absolutely! Most people come alone and that's totally fine. In fact, it's a great way to meet new people. Our events are designed to be social, so you'll quickly find conversation partners.",
|
|
||||||
answerEs: "¡Absolutamente! La mayoría de las personas vienen solas y eso está totalmente bien. De hecho, es una excelente manera de conocer gente nueva. Nuestros eventos están diseñados para ser sociales, así que encontrarás compañeros de conversación rápidamente."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "What if I can't make it after booking?",
|
|
||||||
questionEs: "¿Qué pasa si no puedo asistir después de reservar?",
|
|
||||||
answer: "If you can't attend, please let us know as soon as possible so we can offer your spot to someone on the waitlist. Contact us through the website or WhatsApp group to cancel your booking.",
|
|
||||||
answerEs: "Si no puedes asistir, por favor avísanos lo antes posible para poder ofrecer tu lugar a alguien en la lista de espera. Contáctanos a través del sitio web o el grupo de WhatsApp para cancelar tu reserva."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "How can I stay updated about events?",
|
|
||||||
questionEs: "¿Cómo puedo mantenerme actualizado sobre los eventos?",
|
|
||||||
answer: "Join our WhatsApp group for instant updates, follow us on Instagram for announcements and photos, or subscribe to our newsletter on the website. We typically announce events 2-3 weeks in advance.",
|
|
||||||
answerEs: "Únete a nuestro grupo de WhatsApp para actualizaciones instantáneas, síguenos en Instagram para anuncios y fotos, o suscríbete a nuestro boletín en el sitio web. Normalmente anunciamos eventos con 2-3 semanas de anticipación."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Can I volunteer or help organize events?",
|
|
||||||
questionEs: "¿Puedo ser voluntario o ayudar a organizar eventos?",
|
|
||||||
answer: "Yes! We're always looking for enthusiastic volunteers. Volunteers help with setup, greeting newcomers, facilitating activities, and more. Contact us through the website if you're interested in getting involved.",
|
|
||||||
answerEs: "¡Sí! Siempre estamos buscando voluntarios entusiastas. Los voluntarios ayudan con la preparación, saludar a los recién llegados, facilitar actividades y más. Contáctanos a través del sitio web si estás interesado en participar."
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function FAQPage() {
|
export default function FAQPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { locale } = useLanguage();
|
||||||
|
const [faqs, setFaqs] = useState<FaqItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
faqApi.getList().then((res) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setFaqs(res.faqs);
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleFAQ = (index: number) => {
|
const toggleFAQ = (index: number) => {
|
||||||
setOpenIndex(openIndex === index ? null : index);
|
setOpenIndex(openIndex === index ? null : index);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page max-w-3xl flex justify-center py-20">
|
||||||
|
<div className="animate-spin w-10 h-10 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding">
|
<div className="section-padding">
|
||||||
<div className="container-page max-w-3xl">
|
<div className="container-page max-w-3xl">
|
||||||
@@ -98,15 +53,24 @@ export default function FAQPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{faqs.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'No hay preguntas frecuentes publicadas en este momento.'
|
||||||
|
: 'No FAQ questions are published at the moment.'}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{faqs.map((faq, index) => (
|
{faqs.map((faq, index) => (
|
||||||
<Card key={index} className="overflow-hidden">
|
<Card key={faq.id} className="overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleFAQ(index)}
|
onClick={() => toggleFAQ(index)}
|
||||||
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-primary-dark pr-4">
|
<span className="font-semibold text-primary-dark pr-4">
|
||||||
{locale === 'es' ? faq.questionEs : faq.question}
|
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -122,12 +86,13 @@ export default function FAQPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-6 pb-4 text-gray-600">
|
<div className="px-6 pb-4 text-gray-600">
|
||||||
{locale === 'es' ? faq.answerEs : faq.answer}
|
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
|
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
|
||||||
<h2 className="text-xl font-semibold text-primary-dark mb-2">
|
<h2 className="text-xl font-semibold text-primary-dark mb-2">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const metadata: Metadata = {
|
|||||||
const organizationSchema = {
|
const organizationSchema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'Spanglish',
|
name: 'Spanglish Community',
|
||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
logo: `${siteUrl}/images/logo.png`,
|
logo: `${siteUrl}/images/logo.png`,
|
||||||
description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.',
|
description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.',
|
||||||
@@ -30,7 +30,7 @@ const organizationSchema = {
|
|||||||
addressCountry: 'PY',
|
addressCountry: 'PY',
|
||||||
},
|
},
|
||||||
sameAs: [
|
sameAs: [
|
||||||
process.env.NEXT_PUBLIC_INSTAGRAM_URL,
|
'https://instagram.com/spanglishsocialpy',
|
||||||
process.env.NEXT_PUBLIC_WHATSAPP_URL,
|
process.env.NEXT_PUBLIC_WHATSAPP_URL,
|
||||||
process.env.NEXT_PUBLIC_TELEGRAM_URL,
|
process.env.NEXT_PUBLIC_TELEGRAM_URL,
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
|
|||||||
@@ -1,20 +1,170 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import HeroSection from './components/HeroSection';
|
import HeroSection from './components/HeroSection';
|
||||||
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
||||||
import AboutSection from './components/AboutSection';
|
import AboutSection from './components/AboutSection';
|
||||||
import MediaCarouselSection from './components/MediaCarouselSection';
|
import MediaCarouselSection from './components/MediaCarouselSection';
|
||||||
import NewsletterSection from './components/NewsletterSection';
|
import NewsletterSection from './components/NewsletterSection';
|
||||||
|
import HomepageFaqSection from './components/HomepageFaqSection';
|
||||||
import { getCarouselImages } from '@/lib/carouselImages';
|
import { getCarouselImages } from '@/lib/carouselImages';
|
||||||
|
|
||||||
export default function HomePage() {
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
interface NextEvent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
titleEs?: string;
|
||||||
|
description: string;
|
||||||
|
descriptionEs?: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
shortDescriptionEs?: string;
|
||||||
|
startDatetime: string;
|
||||||
|
endDatetime?: string;
|
||||||
|
location: string;
|
||||||
|
locationUrl?: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
capacity: number;
|
||||||
|
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
||||||
|
bannerUrl?: string;
|
||||||
|
externalBookingEnabled?: boolean;
|
||||||
|
externalBookingUrl?: string;
|
||||||
|
availableSeats?: number;
|
||||||
|
bookedCount?: number;
|
||||||
|
isFeatured?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNextUpcomingEvent(): Promise<NextEvent | null> {
|
||||||
|
try {
|
||||||
|
const revalidateSeconds =
|
||||||
|
parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600;
|
||||||
|
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
||||||
|
next: { tags: ['next-event'], revalidate: revalidateSeconds },
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = await response.json();
|
||||||
|
return data.event || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic metadata with next event date for AI crawlers and SEO
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const event = await getNextUpcomingEvent();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return {
|
||||||
|
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||||
|
description:
|
||||||
|
'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
|
});
|
||||||
|
|
||||||
|
const description = `Next event: ${eventDate} – ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||||
|
description,
|
||||||
|
url: siteUrl,
|
||||||
|
siteName: 'Spanglish',
|
||||||
|
type: 'website',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: event.bannerUrl
|
||||||
|
? event.bannerUrl.startsWith('http')
|
||||||
|
? event.bannerUrl
|
||||||
|
: `${siteUrl}${event.bannerUrl}`
|
||||||
|
: `${siteUrl}/images/og-image.jpg`,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: `Spanglish – ${event.title}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNextEventJsonLd(event: NextEvent) {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Event',
|
||||||
|
name: event.title,
|
||||||
|
description: event.shortDescription || event.description,
|
||||||
|
startDate: event.startDatetime,
|
||||||
|
endDate: event.endDatetime || event.startDatetime,
|
||||||
|
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||||
|
eventStatus:
|
||||||
|
event.status === 'cancelled'
|
||||||
|
? 'https://schema.org/EventCancelled'
|
||||||
|
: 'https://schema.org/EventScheduled',
|
||||||
|
location: {
|
||||||
|
'@type': 'Place',
|
||||||
|
name: event.location,
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
addressLocality: 'Asunción',
|
||||||
|
addressCountry: 'PY',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
organizer: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'Spanglish',
|
||||||
|
url: siteUrl,
|
||||||
|
},
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: event.price,
|
||||||
|
priceCurrency: event.currency,
|
||||||
|
availability:
|
||||||
|
(event.availableSeats ?? 0) > 0
|
||||||
|
? 'https://schema.org/InStock'
|
||||||
|
: 'https://schema.org/SoldOut',
|
||||||
|
url: `${siteUrl}/events/${event.id}`,
|
||||||
|
},
|
||||||
|
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
||||||
|
url: `${siteUrl}/events/${event.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
const carouselImages = getCarouselImages();
|
const carouselImages = getCarouselImages();
|
||||||
|
const nextEvent = await getNextUpcomingEvent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{nextEvent && (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(generateNextEventJsonLd(nextEvent)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
<NextEventSectionWrapper />
|
<NextEventSectionWrapper initialEvent={nextEvent} />
|
||||||
<AboutSection />
|
<AboutSection />
|
||||||
<MediaCarouselSection images={carouselImages} />
|
<MediaCarouselSection images={carouselImages} />
|
||||||
<NewsletterSection />
|
<NewsletterSection />
|
||||||
|
<HomepageFaqSection />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +60,12 @@ export default function AdminBookingsPage() {
|
|||||||
eventsApi.getAll(),
|
eventsApi.getAll(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fetch full ticket details with payment info
|
const ticketsWithEvent = ticketsRes.tickets.map((ticket) => ({
|
||||||
const ticketsWithDetails = await Promise.all(
|
...ticket,
|
||||||
ticketsRes.tickets.map(async (ticket) => {
|
event: eventsRes.events.find((e) => e.id === ticket.eventId),
|
||||||
try {
|
}));
|
||||||
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id);
|
|
||||||
return fullTicket;
|
|
||||||
} catch {
|
|
||||||
return ticket;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setTickets(ticketsWithDetails);
|
setTickets(ticketsWithEvent);
|
||||||
setEvents(eventsRes.events);
|
setEvents(eventsRes.events);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load bookings');
|
toast.error('Failed to load bookings');
|
||||||
@@ -125,67 +122,68 @@ export default function AdminBookingsPage() {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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 | null) => {
|
||||||
switch (provider) {
|
if (provider == null) return '—';
|
||||||
case 'bancard':
|
const labels: Record<string, string> = {
|
||||||
return 'TPago / Card';
|
cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
|
||||||
case 'lightning':
|
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
|
||||||
return 'Bitcoin Lightning';
|
lightning: 'Lightning',
|
||||||
case 'cash':
|
tpago: 'TPago',
|
||||||
return 'Cash at Event';
|
bancard: 'Bancard',
|
||||||
default:
|
};
|
||||||
return provider;
|
return labels[provider] || provider;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const getDisplayProvider = (ticket: TicketWithDetails): string | null => {
|
||||||
|
if (ticket.payment?.provider) return ticket.payment.provider;
|
||||||
|
if (ticket.bookingId) {
|
||||||
|
const sibling = tickets.find((t) => t.bookingId === ticket.bookingId && t.payment?.provider);
|
||||||
|
return sibling?.payment?.provider ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter tickets
|
|
||||||
const filteredTickets = tickets.filter((ticket) => {
|
const filteredTickets = tickets.filter((ticket) => {
|
||||||
if (selectedEvent && ticket.eventId !== selectedEvent) return false;
|
if (selectedEvent && ticket.eventId !== selectedEvent) return false;
|
||||||
if (selectedStatus && ticket.status !== selectedStatus) return false;
|
if (selectedStatus && ticket.status !== selectedStatus) return false;
|
||||||
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
|
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
const name = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.toLowerCase();
|
||||||
|
return name.includes(q) || (ticket.attendeeEmail?.toLowerCase().includes(q) || false);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by created date (newest first)
|
|
||||||
const sortedTickets = [...filteredTickets].sort(
|
const sortedTickets = [...filteredTickets].sort(
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stats
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: tickets.length,
|
total: tickets.length,
|
||||||
pending: tickets.filter(t => t.status === 'pending').length,
|
pending: tickets.filter(t => t.status === 'pending').length,
|
||||||
@@ -195,23 +193,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">
|
||||||
@@ -223,51 +234,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>
|
||||||
@@ -276,11 +297,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>
|
||||||
@@ -290,12 +308,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>
|
||||||
@@ -303,26 +318,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,118 +386,64 @@ export default function AdminBookingsPage() {
|
|||||||
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>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
||||||
<EnvelopeIcon className="w-4 h-4" />
|
|
||||||
<span>{ticket.attendeeEmail || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
||||||
<PhoneIcon className="w-4 h-4" />
|
|
||||||
<span>{ticket.attendeePhone || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm truncate max-w-[150px] block">
|
||||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div className="space-y-1">
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||||
<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-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
||||||
{ticket.status}
|
{ticket.status.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
{ticket.qrCode && (
|
|
||||||
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
|
|
||||||
)}
|
|
||||||
{ticket.bookingId && (
|
{ticket.bookingId && (
|
||||||
<p className="text-xs text-purple-600 mt-1" title="Part of multi-ticket booking">
|
<p className="text-[10px] text-purple-600 mt-0.5">Group Booking</p>
|
||||||
📦 Group Booking
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</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(ticket.createdAt)}
|
{formatDate(ticket.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{/* Mark as Paid (for pending payments) */}
|
|
||||||
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
|
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
|
||||||
<Button
|
<Button size="sm" variant="outline" onClick={() => handleMarkPaid(ticket.id)}
|
||||||
size="sm"
|
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
|
||||||
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
|
Mark Paid
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Check-in (for confirmed tickets) */}
|
|
||||||
{ticket.status === 'confirmed' && (
|
{ticket.status === 'confirmed' && (
|
||||||
<Button
|
<Button size="sm" onClick={() => handleCheckin(ticket.id)}
|
||||||
size="sm"
|
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleCheckin(ticket.id)}
|
|
||||||
isLoading={processing === ticket.id}
|
|
||||||
className="text-blue-600 hover:bg-blue-50"
|
|
||||||
>
|
|
||||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
|
||||||
Check In
|
Check In
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cancel (for pending/confirmed) */}
|
|
||||||
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
||||||
<Button
|
<MoreMenu>
|
||||||
size="sm"
|
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
||||||
variant="ghost"
|
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel
|
||||||
onClick={() => handleCancel(ticket.id)}
|
</DropdownItem>
|
||||||
isLoading={processing === ticket.id}
|
</MoreMenu>
|
||||||
className="text-red-600 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<XCircleIcon className="w-4 h-4 mr-1" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ticket.status === 'checked_in' && (
|
{ticket.status === 'checked_in' && (
|
||||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
<span className="text-xs text-green-600 flex items-center gap-1">
|
||||||
<CheckCircleIcon className="w-4 h-4" />
|
<CheckCircleIcon className="w-4 h-4" /> Attended
|
||||||
Attended
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ticket.status === 'cancelled' && (
|
{ticket.status === 'cancelled' && (
|
||||||
<span className="text-sm text-gray-400">Cancelled</span>
|
<span className="text-xs text-gray-400">Cancelled</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -454,6 +455,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function AdminContactsPage() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 @@ import {
|
|||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -50,6 +53,8 @@ export default function AdminEmailsPage() {
|
|||||||
const [logs, setLogs] = useState<EmailLog[]>([]);
|
const [logs, setLogs] = useState<EmailLog[]>([]);
|
||||||
const [logsOffset, setLogsOffset] = useState(0);
|
const [logsOffset, setLogsOffset] = useState(0);
|
||||||
const [logsTotal, setLogsTotal] = useState(0);
|
const [logsTotal, setLogsTotal] = useState(0);
|
||||||
|
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
|
||||||
|
const [resendingLogId, setResendingLogId] = useState<string | null>(null);
|
||||||
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
||||||
|
|
||||||
// Stats state
|
// Stats state
|
||||||
@@ -189,7 +194,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 +201,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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,7 +217,7 @@ export default function AdminEmailsPage() {
|
|||||||
if (activeTab === 'logs') {
|
if (activeTab === 'logs') {
|
||||||
loadLogs();
|
loadLogs();
|
||||||
}
|
}
|
||||||
}, [activeTab, logsOffset]);
|
}, [activeTab, logsOffset, logsSubTab]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -237,7 +236,11 @@ export default function AdminEmailsPage() {
|
|||||||
|
|
||||||
const loadLogs = async () => {
|
const loadLogs = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset });
|
const res = await emailsApi.getLogs({
|
||||||
|
limit: 20,
|
||||||
|
offset: logsOffset,
|
||||||
|
...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
|
||||||
|
});
|
||||||
setLogs(res.logs);
|
setLogs(res.logs);
|
||||||
setLogsTotal(res.pagination.total);
|
setLogsTotal(res.pagination.total);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -245,6 +248,27 @@ export default function AdminEmailsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResend = async (log: EmailLog) => {
|
||||||
|
setResendingLogId(log.id);
|
||||||
|
try {
|
||||||
|
const res = await emailsApi.resendLog(log.id);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success('Email re-sent successfully');
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || 'Failed to re-send email');
|
||||||
|
}
|
||||||
|
await loadLogs();
|
||||||
|
if (selectedLog?.id === log.id) {
|
||||||
|
const { log: updatedLog } = await emailsApi.getLog(log.id);
|
||||||
|
setSelectedLog(updatedLog);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to re-send email');
|
||||||
|
} finally {
|
||||||
|
setResendingLogId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resetTemplateForm = () => {
|
const resetTemplateForm = () => {
|
||||||
setTemplateForm({
|
setTemplateForm({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -373,6 +397,7 @@ export default function AdminEmailsPage() {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -387,7 +412,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 */}
|
||||||
@@ -441,18 +466,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'}
|
||||||
@@ -504,31 +526,36 @@ 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>
|
||||||
|
<div className="hidden md:block">
|
||||||
{!template.isSystem && (
|
{!template.isSystem && (
|
||||||
<button
|
<button onClick={() => handleDeleteTemplate(template.id)}
|
||||||
onClick={() => handleDeleteTemplate(template.id)}
|
className="p-2 hover:bg-red-100 text-red-600 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" title="Delete">
|
||||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<XCircleIcon className="w-5 h-5" />
|
<XCircleIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -545,7 +572,7 @@ export default function AdminEmailsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasDraft && (
|
{hasDraft && (
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString() : ''}
|
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={saveDraft}>
|
<Button variant="outline" size="sm" onClick={saveDraft}>
|
||||||
@@ -569,9 +596,9 @@ 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()}
|
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -640,13 +667,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">
|
||||||
@@ -680,14 +711,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>
|
||||||
@@ -700,51 +727,79 @@ export default function AdminEmailsPage() {
|
|||||||
{/* Logs Tab */}
|
{/* Logs Tab */}
|
||||||
{activeTab === 'logs' && (
|
{activeTab === 'logs' && (
|
||||||
<div>
|
<div>
|
||||||
<Card className="overflow-hidden">
|
{/* Sub-tabs: All | Failed */}
|
||||||
|
<div className="border-b border-secondary-light-gray mb-4">
|
||||||
|
<nav className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => { setLogsSubTab('all'); setLogsOffset(0); }}
|
||||||
|
className={clsx(
|
||||||
|
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||||
|
logsSubTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setLogsSubTab('failed'); setLogsOffset(0); }}
|
||||||
|
className={clsx(
|
||||||
|
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||||
|
logsSubTab === 'failed' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Failed
|
||||||
|
{stats && stats.failed > 0 && (
|
||||||
|
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700">
|
||||||
|
{stats.failed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Table */}
|
||||||
|
<Card className="overflow-hidden hidden md:block">
|
||||||
<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">{logsSubTab === 'failed' ? 'No failed emails' : '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 flex-col gap-1">
|
||||||
{getStatusIcon(log.status)}
|
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div>
|
||||||
<span className="capitalize text-sm">{log.status}</span>
|
{(log.resendAttempts ?? 0) > 0 && (
|
||||||
|
<span className="text-xs text-gray-500">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-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 gap-1">
|
||||||
{formatDate(log.sentAt || log.createdAt)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedLog(log)}
|
onClick={() => handleResend(log)}
|
||||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
disabled={resendingLogId === log.id}
|
||||||
title="View Email"
|
className="p-2 hover:bg-gray-100 rounded-btn disabled:opacity-50"
|
||||||
|
title="Re-send"
|
||||||
>
|
>
|
||||||
|
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === log.id && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setSelectedLog(log)} className="p-2 hover:bg-gray-100 rounded-btn" title="View">
|
||||||
<EyeIcon className="w-4 h-4" />
|
<EyeIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -755,46 +810,80 @@ 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">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(log.status)}</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-sm truncate">{log.subject}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} <{log.recipientEmail}></p>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-1">{formatDate(log.sentAt || log.createdAt)}</p>
|
||||||
|
{(log.resendAttempts ?? 0) > 0 && (
|
||||||
|
<p className="text-[10px] text-gray-500 mt-0.5">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleResend(log); }}
|
||||||
|
disabled={resendingLogId === log.id}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn flex-shrink-0 disabled:opacity-50"
|
||||||
|
title="Re-send"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === log.id && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{logsTotal > 20 && (
|
||||||
|
<div className="flex items-center justify-between py-3">
|
||||||
|
<p className="text-xs text-gray-500">{logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={logsOffset === 0} onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))} className="min-h-[44px]">
|
||||||
|
<ChevronLeftIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={logsOffset + 20 >= logsTotal} onClick={() => setLogsOffset(logsOffset + 20)} className="min-h-[44px]">
|
||||||
|
<ChevronRightIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Template Form Modal */}
|
{/* Template Form Modal */}
|
||||||
{showTemplateForm && (
|
{showTemplateForm && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-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"
|
||||||
@@ -878,14 +967,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>
|
||||||
@@ -896,16 +981,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
|
||||||
@@ -919,23 +1005,40 @@ 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>
|
||||||
|
)}
|
||||||
|
{(selectedLog.resendAttempts ?? 0) > 0 && (
|
||||||
|
<span className="text-xs text-gray-500">Re-sent {selectedLog.resendAttempts} time{(selectedLog.resendAttempts ?? 0) !== 1 ? 's' : ''}{selectedLog.lastResentAt ? ` (${formatDate(selectedLog.lastResentAt)})` : ''}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => setSelectedLog(null)}>
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
Close
|
<button
|
||||||
</Button>
|
onClick={() => handleResend(selectedLog)}
|
||||||
|
disabled={resendingLogId === selectedLog.id}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn disabled:opacity-50 flex items-center gap-1.5 text-sm"
|
||||||
|
title="Re-send"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === selectedLog.id && 'animate-spin')} />
|
||||||
|
Re-send
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setSelectedLog(null)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</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} <{selectedLog.recipientEmail}></p>
|
<p><strong>To:</strong> {selectedLog.recipientName} <{selectedLog.recipientEmail}></p>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,23 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import MediaPicker from '@/components/MediaPicker';
|
import MediaPicker from '@/components/MediaPicker';
|
||||||
import { 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 router = useRouter();
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@@ -37,7 +41,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 +70,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 +94,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 +113,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 +134,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 +152,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 +163,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 +182,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 +194,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,22 +213,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',
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>;
|
||||||
};
|
};
|
||||||
@@ -285,8 +263,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>
|
||||||
@@ -294,221 +272,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">
|
||||||
|
<h2 className="text-lg md:text-xl font-bold">
|
||||||
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
||||||
</h2>
|
</h2>
|
||||||
|
<button onClick={() => { setShowForm(false); resetForm(); }}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="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
|
|
||||||
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'
|
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
|
|
||||||
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'
|
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
|
||||||
}`}
|
}`} />
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{featuredEventId && featuredEventId !== editingEvent.id && (
|
{featuredEventId && featuredEventId !== editingEvent.id && (
|
||||||
@@ -520,14 +425,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>
|
||||||
@@ -536,17 +437,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">
|
||||||
@@ -558,110 +459,95 @@ export default function AdminEventsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
events.map((event) => (
|
events.map((event) => (
|
||||||
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
|
<tr
|
||||||
<td className="px-6 py-4">
|
key={event.id}
|
||||||
|
onClick={() => router.push(`/admin/events/${event.id}`)}
|
||||||
|
className={clsx("hover:bg-gray-50 cursor-pointer", featuredEventId === event.id && "bg-amber-50")}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
<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>
|
<td className="px-4 py-3">
|
||||||
<td className="px-6 py-4 text-sm">
|
<div className="flex items-center gap-1.5">
|
||||||
{event.bookedCount || 0} / {event.capacity}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{getStatusBadge(event.status)}
|
{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">
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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 !== 'archived' && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleArchive(event)}
|
|
||||||
className="p-2 hover:bg-gray-100 text-gray-600 rounded-btn"
|
|
||||||
title="Archive"
|
|
||||||
>
|
|
||||||
<ArchiveBoxIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<button
|
{event.status === 'unlisted' && (
|
||||||
onClick={() => handleDelete(event.id)}
|
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
||||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
Make Public
|
||||||
title="Delete"
|
</DropdownItem>
|
||||||
>
|
)}
|
||||||
<TrashIcon className="w-4 h-4" />
|
{(event.status === 'published' || event.status === 'unlisted') && (
|
||||||
</button>
|
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
|
||||||
|
Unpublish
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
<DropdownItem onClick={() => handleDuplicate(event)}>
|
||||||
|
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
|
||||||
|
</DropdownItem>
|
||||||
|
{event.status !== 'archived' && (
|
||||||
|
<DropdownItem onClick={() => handleArchive(event)}>
|
||||||
|
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
|
||||||
|
<TrashIcon className="w-4 h-4 mr-2" /> Delete
|
||||||
|
</DropdownItem>
|
||||||
|
</MoreMenu>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -671,6 +557,113 @@ export default function AdminEventsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile: Card List */}
|
||||||
|
<div className="md:hidden space-y-2">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500 text-sm">
|
||||||
|
No events found. Create your first event!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
events.map((event) => (
|
||||||
|
<Card
|
||||||
|
key={event.id}
|
||||||
|
className={clsx("p-3 cursor-pointer", featuredEventId === event.id && "ring-2 ring-amber-300")}
|
||||||
|
onClick={() => router.push(`/admin/events/${event.id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{event.bannerUrl ? (
|
||||||
|
<img src={event.bannerUrl} alt={event.title}
|
||||||
|
className="w-14 h-14 rounded-lg object-cover flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<div className="w-14 h-14 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
||||||
|
<PhotoIcon className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate">{event.title}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
||||||
|
<p className="text-xs text-gray-400 truncate">{event.location}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
|
||||||
|
{getStatusBadge(event.status)}
|
||||||
|
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
|
||||||
|
)}
|
||||||
|
{featuredEventId === event.id && (
|
||||||
|
<StarIconSolid className="w-4 h-4 text-amber-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
|
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
|
||||||
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Link href={`/admin/events/${event.id}`}
|
||||||
|
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
|
||||||
|
<EyeIcon className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<MoreMenu>
|
||||||
|
<DropdownItem onClick={() => handleEdit(event)}>
|
||||||
|
<PencilIcon className="w-4 h-4 mr-2" /> Edit
|
||||||
|
</DropdownItem>
|
||||||
|
{event.status === 'draft' && (
|
||||||
|
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
||||||
|
Publish
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{(event.status === 'draft' || event.status === 'published') && (
|
||||||
|
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
|
||||||
|
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{event.status === 'unlisted' && (
|
||||||
|
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
||||||
|
Make Public
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{(event.status === 'published' || event.status === 'unlisted') && (
|
||||||
|
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
|
||||||
|
Unpublish
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{event.status === 'published' && (
|
||||||
|
<DropdownItem onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}>
|
||||||
|
<StarIcon className="w-4 h-4 mr-2" />
|
||||||
|
{featuredEventId === event.id ? 'Unfeature' : 'Set Featured'}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
<DropdownItem onClick={() => handleDuplicate(event)}>
|
||||||
|
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
|
||||||
|
</DropdownItem>
|
||||||
|
{event.status !== 'archived' && (
|
||||||
|
<DropdownItem onClick={() => handleArchive(event)}>
|
||||||
|
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
|
||||||
|
<TrashIcon className="w-4 h-4 mr-2" /> Delete
|
||||||
|
</DropdownItem>
|
||||||
|
</MoreMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile FAB */}
|
||||||
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||||
|
<button onClick={() => { resetForm(); setShowForm(true); }}
|
||||||
|
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
|
||||||
|
<PlusIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminMobileStyles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
393
frontend/src/app/admin/faq/page.tsx
Normal file
393
frontend/src/app/admin/faq/page.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { faqApi, FaqItemAdmin } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
PlusIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
TrashIcon,
|
||||||
|
Bars3Icon,
|
||||||
|
XMarkIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean };
|
||||||
|
|
||||||
|
const emptyForm: FormState = {
|
||||||
|
id: null, question: '', questionEs: '', answer: '', answerEs: '', enabled: true, showOnHomepage: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminFaqPage() {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const [faqs, setFaqs] = useState<FaqItemAdmin[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { loadFaqs(); }, []);
|
||||||
|
|
||||||
|
const loadFaqs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await faqApi.getAdminList();
|
||||||
|
setFaqs(res.faqs);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error(locale === 'es' ? 'Error al cargar FAQs' : 'Failed to load FAQs');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => { setForm(emptyForm); setShowForm(true); };
|
||||||
|
|
||||||
|
const handleEdit = (faq: FaqItemAdmin) => {
|
||||||
|
setForm({
|
||||||
|
id: faq.id, question: faq.question, questionEs: faq.questionEs ?? '',
|
||||||
|
answer: faq.answer, answerEs: faq.answerEs ?? '', enabled: faq.enabled, showOnHomepage: faq.showOnHomepage,
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.question.trim() || !form.answer.trim()) {
|
||||||
|
toast.error(locale === 'es' ? 'Pregunta y respuesta (EN) son obligatorios' : 'Question and answer (EN) are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
if (form.id) {
|
||||||
|
await faqApi.update(form.id, {
|
||||||
|
question: form.question.trim(), questionEs: form.questionEs.trim() || null,
|
||||||
|
answer: form.answer.trim(), answerEs: form.answerEs.trim() || null,
|
||||||
|
enabled: form.enabled, showOnHomepage: form.showOnHomepage,
|
||||||
|
});
|
||||||
|
toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated');
|
||||||
|
} else {
|
||||||
|
await faqApi.create({
|
||||||
|
question: form.question.trim(), questionEs: form.questionEs.trim() || undefined,
|
||||||
|
answer: form.answer.trim(), answerEs: form.answerEs.trim() || undefined,
|
||||||
|
enabled: form.enabled, showOnHomepage: form.showOnHomepage,
|
||||||
|
});
|
||||||
|
toast.success(locale === 'es' ? 'FAQ creado' : 'FAQ created');
|
||||||
|
}
|
||||||
|
setForm(emptyForm);
|
||||||
|
setShowForm(false);
|
||||||
|
await loadFaqs();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || (locale === 'es' ? 'Error al guardar' : 'Failed to save'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm(locale === 'es' ? '¿Eliminar esta pregunta?' : 'Delete this question?')) return;
|
||||||
|
try {
|
||||||
|
await faqApi.delete(id);
|
||||||
|
toast.success(locale === 'es' ? 'FAQ eliminado' : 'FAQ deleted');
|
||||||
|
if (form.id === id) { setForm(emptyForm); setShowForm(false); }
|
||||||
|
await loadFaqs();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || (locale === 'es' ? 'Error al eliminar' : 'Failed to delete'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (faq: FaqItemAdmin) => {
|
||||||
|
try {
|
||||||
|
await faqApi.update(faq.id, { enabled: !faq.enabled });
|
||||||
|
setFaqs(prev => prev.map(f => f.id === faq.id ? { ...f, enabled: !f.enabled } : f));
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || (locale === 'es' ? 'Error al actualizar' : 'Failed to update'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleShowOnHomepage = async (faq: FaqItemAdmin) => {
|
||||||
|
try {
|
||||||
|
await faqApi.update(faq.id, { showOnHomepage: !faq.showOnHomepage });
|
||||||
|
setFaqs(prev => prev.map(f => f.id === faq.id ? { ...f, showOnHomepage: !f.showOnHomepage } : f));
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || (locale === 'es' ? 'Error al actualizar' : 'Failed to update'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveUp = async (index: number) => {
|
||||||
|
if (index === 0) return;
|
||||||
|
const newOrder = [...faqs];
|
||||||
|
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
|
||||||
|
const ids = newOrder.map(f => f.id);
|
||||||
|
try {
|
||||||
|
const res = await faqApi.reorder(ids);
|
||||||
|
setFaqs(res.faqs);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveDown = async (index: number) => {
|
||||||
|
if (index >= faqs.length - 1) return;
|
||||||
|
const newOrder = [...faqs];
|
||||||
|
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
|
||||||
|
const ids = newOrder.map(f => f.id);
|
||||||
|
try {
|
||||||
|
const res = await faqApi.reorder(ids);
|
||||||
|
setFaqs(res.faqs);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Desktop drag handlers
|
||||||
|
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||||
|
setDraggedId(id);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', id);
|
||||||
|
};
|
||||||
|
const handleDragOver = (e: React.DragEvent, id: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
setDragOverId(id);
|
||||||
|
};
|
||||||
|
const handleDragLeave = () => { setDragOverId(null); };
|
||||||
|
const handleDrop = async (e: React.DragEvent, targetId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverId(null);
|
||||||
|
setDraggedId(null);
|
||||||
|
const sourceId = e.dataTransfer.getData('text/plain');
|
||||||
|
if (!sourceId || sourceId === targetId) return;
|
||||||
|
const idx = faqs.findIndex(f => f.id === sourceId);
|
||||||
|
const targetIdx = faqs.findIndex(f => f.id === targetId);
|
||||||
|
if (idx === -1 || targetIdx === -1) return;
|
||||||
|
const newOrder = [...faqs];
|
||||||
|
const [removed] = newOrder.splice(idx, 1);
|
||||||
|
newOrder.splice(targetIdx, 0, removed);
|
||||||
|
const ids = newOrder.map(f => f.id);
|
||||||
|
try {
|
||||||
|
const res = await faqApi.reorder(ids);
|
||||||
|
setFaqs(res.faqs);
|
||||||
|
toast.success(locale === 'es' ? 'Orden actualizado' : 'Order updated');
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDragEnd = () => { setDraggedId(null); setDragOverId(null); };
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl md:text-2xl font-bold font-heading">FAQ</h1>
|
||||||
|
<p className="text-gray-500 text-xs md:text-sm mt-1 hidden md:block">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.'
|
||||||
|
: 'Create and edit FAQ questions. Drag to change order.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate} className="hidden md:flex">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-2" />
|
||||||
|
{locale === 'es' ? 'Nueva pregunta' : 'Add question'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Modal - bottom sheet on mobile */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||||
|
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||||
|
<h2 className="text-base font-semibold">
|
||||||
|
{form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')}
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => { setForm(emptyForm); setShowForm(false); }}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Question (EN) *</label>
|
||||||
|
<Input value={form.question} onChange={e => setForm(f => ({ ...f, question: e.target.value }))} placeholder="Question in English" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Pregunta (ES)</label>
|
||||||
|
<Input value={form.questionEs} onChange={e => setForm(f => ({ ...f, questionEs: e.target.value }))} placeholder="Pregunta en español" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Answer (EN) *</label>
|
||||||
|
<textarea className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
|
||||||
|
value={form.answer} onChange={e => setForm(f => ({ ...f, answer: e.target.value }))} placeholder="Answer in English" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Respuesta (ES)</label>
|
||||||
|
<textarea className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
|
||||||
|
value={form.answerEs} onChange={e => setForm(f => ({ ...f, answerEs: e.target.value }))} placeholder="Respuesta en español" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
|
||||||
|
<input type="checkbox" checked={form.enabled} onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} className="rounded border-gray-300 w-4 h-4" />
|
||||||
|
<span className="text-sm">{locale === 'es' ? 'Mostrar en el sitio' : 'Show on site'}</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
|
||||||
|
<input type="checkbox" checked={form.showOnHomepage} onChange={e => setForm(f => ({ ...f, showOnHomepage: e.target.checked }))} className="rounded border-gray-300 w-4 h-4" />
|
||||||
|
<span className="text-sm">{locale === 'es' ? 'Mostrar en inicio' : 'Show on homepage'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button onClick={handleSave} isLoading={saving} className="flex-1 min-h-[44px]">
|
||||||
|
<CheckIcon className="w-4 h-4 mr-1" /> {locale === 'es' ? 'Guardar' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => { setForm(emptyForm); setShowForm(false); }} disabled={saving} className="flex-1 min-h-[44px]">
|
||||||
|
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop: Table */}
|
||||||
|
<Card className="hidden md:block">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 px-4 py-3" />
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase">{locale === 'es' ? 'Pregunta' : 'Question'}</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase w-24">{locale === 'es' ? 'En sitio' : 'On site'}</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase w-28">{locale === 'es' ? 'En inicio' : 'Homepage'}</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-500 uppercase w-32">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{faqs.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}</td></tr>
|
||||||
|
) : (
|
||||||
|
faqs.map((faq) => (
|
||||||
|
<tr key={faq.id} draggable onDragStart={e => handleDragStart(e, faq.id)}
|
||||||
|
onDragOver={e => handleDragOver(e, faq.id)} onDragLeave={handleDragLeave}
|
||||||
|
onDrop={e => handleDrop(e, faq.id)} onDragEnd={handleDragEnd}
|
||||||
|
className={clsx('hover:bg-gray-50', draggedId === faq.id && 'opacity-50', dragOverId === faq.id && 'bg-primary-yellow/10')}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600" title={locale === 'es' ? 'Arrastrar para reordenar' : 'Drag to reorder'}>
|
||||||
|
<Bars3Icon className="w-5 h-5" />
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="font-medium text-primary-dark text-sm line-clamp-1">{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button onClick={() => handleToggleEnabled(faq)}
|
||||||
|
className={clsx('inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium min-h-[32px]',
|
||||||
|
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||||
|
{faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : 'No'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button onClick={() => handleToggleShowOnHomepage(faq)}
|
||||||
|
className={clsx('inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium min-h-[32px]',
|
||||||
|
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||||
|
{faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : 'No'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleEdit(faq)}>
|
||||||
|
<PencilSquareIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleDelete(faq.id)} className="text-red-600 hover:bg-red-50">
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile: Card List */}
|
||||||
|
<div className="md:hidden space-y-2">
|
||||||
|
{faqs.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}</div>
|
||||||
|
) : (
|
||||||
|
faqs.map((faq, index) => (
|
||||||
|
<Card key={faq.id} className="p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex flex-col gap-0.5 flex-shrink-0 pt-0.5">
|
||||||
|
<button onClick={() => handleMoveUp(index)} disabled={index === 0}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 min-h-[28px] min-w-[28px] flex items-center justify-center">
|
||||||
|
<ChevronUpIcon className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleMoveDown(index)} disabled={index >= faqs.length - 1}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 min-h-[28px] min-w-[28px] flex items-center justify-center">
|
||||||
|
<ChevronDownIcon className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-sm text-primary-dark line-clamp-2">
|
||||||
|
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<button onClick={() => handleToggleEnabled(faq)}
|
||||||
|
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium min-h-[28px]',
|
||||||
|
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||||
|
{faq.enabled ? (locale === 'es' ? 'Sitio: Sí' : 'Site: Yes') : (locale === 'es' ? 'Sitio: No' : 'Site: No')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleToggleShowOnHomepage(faq)}
|
||||||
|
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium min-h-[28px]',
|
||||||
|
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||||
|
{faq.showOnHomepage ? (locale === 'es' ? 'Inicio: Sí' : 'Home: Yes') : (locale === 'es' ? 'Inicio: No' : 'Home: No')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MoreMenu>
|
||||||
|
<DropdownItem onClick={() => handleEdit(faq)}>
|
||||||
|
<PencilSquareIcon className="w-4 h-4 mr-2" /> {locale === 'es' ? 'Editar' : 'Edit'}
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={() => handleDelete(faq.id)} className="text-red-600">
|
||||||
|
<TrashIcon className="w-4 h-4 mr-2" /> {locale === 'es' ? 'Eliminar' : 'Delete'}
|
||||||
|
</DropdownItem>
|
||||||
|
</MoreMenu>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile FAB */}
|
||||||
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||||
|
<button onClick={handleCreate}
|
||||||
|
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
|
||||||
|
<PlusIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminMobileStyles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,6 +112,7 @@ export default function AdminGalleryPage() {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
QrCodeIcon,
|
QrCodeIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
|
QuestionMarkCircleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -36,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 (
|
||||||
@@ -53,30 +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: 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 */}
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export default function AdminLegalPagesPage() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return dateStr;
|
return dateStr;
|
||||||
@@ -420,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>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default function AdminDashboardPage() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,12 +114,12 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Low capacity warnings */}
|
{/* Low capacity warnings */}
|
||||||
{data?.upcomingEvents
|
{data?.upcomingEvents
|
||||||
.filter(event => {
|
.filter(event => {
|
||||||
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
|
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
||||||
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
|
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
|
||||||
return percentFull >= 80 && availableSeats > 0;
|
return percentFull >= 80 && spotsLeft > 0;
|
||||||
})
|
})
|
||||||
.map(event => {
|
.map(event => {
|
||||||
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
|
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
||||||
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
|
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -130,7 +131,7 @@ export default function AdminDashboardPage() {
|
|||||||
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium">{event.title}</span>
|
<span className="text-sm font-medium">{event.title}</span>
|
||||||
<p className="text-xs text-gray-500">Only {availableSeats} spots left ({percentFull}% full)</p>
|
<p className="text-xs text-gray-500">Only {spotsLeft} spots left ({percentFull}% full)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge badge-warning">Low capacity</span>
|
<span className="badge badge-warning">Low capacity</span>
|
||||||
@@ -140,7 +141,7 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
{/* Sold out events */}
|
{/* Sold out events */}
|
||||||
{data?.upcomingEvents
|
{data?.upcomingEvents
|
||||||
.filter(event => (event.availableSeats ?? (event.capacity - (event.bookedCount || 0))) === 0)
|
.filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
|
||||||
.map(event => (
|
.map(event => (
|
||||||
<Link
|
<Link
|
||||||
key={event.id}
|
key={event.id}
|
||||||
|
|||||||
@@ -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(),
|
||||||
]);
|
]);
|
||||||
@@ -199,6 +208,7 @@ export default function AdminPaymentsPage() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -328,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>
|
||||||
|
|
||||||
@@ -339,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">
|
||||||
|
<h2 className="text-base font-bold">
|
||||||
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
||||||
</h2>
|
</h2>
|
||||||
|
<button onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
<div className="space-y-4 mb-6">
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
@@ -441,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>
|
||||||
);
|
);
|
||||||
@@ -485,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">
|
||||||
@@ -521,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>
|
||||||
@@ -584,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>
|
||||||
)}
|
)}
|
||||||
@@ -656,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>
|
||||||
@@ -747,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>
|
||||||
@@ -760,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>
|
||||||
@@ -782,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>
|
||||||
)}
|
)}
|
||||||
@@ -908,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
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { siteSettingsApi, eventsApi, SiteSettings, TimezoneOption, Event } from '@/lib/api';
|
import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } 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';
|
||||||
@@ -15,13 +15,18 @@ import {
|
|||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
|
ScaleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
type SettingsTab = 'general' | 'legal';
|
||||||
|
|
||||||
export default function AdminSettingsPage() {
|
export default function AdminSettingsPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [savingLegal, setSavingLegal] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
||||||
const [timezones, setTimezones] = useState<TimezoneOption[]>([]);
|
const [timezones, setTimezones] = useState<TimezoneOption[]>([]);
|
||||||
const [featuredEvent, setFeaturedEvent] = useState<Event | null>(null);
|
const [featuredEvent, setFeaturedEvent] = useState<Event | null>(null);
|
||||||
const [clearingFeatured, setClearingFeatured] = useState(false);
|
const [clearingFeatured, setClearingFeatured] = useState(false);
|
||||||
@@ -43,18 +48,35 @@ export default function AdminSettingsPage() {
|
|||||||
maintenanceMessageEs: null,
|
maintenanceMessageEs: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [legalSettings, setLegalSettings] = useState<LegalSettingsData>({
|
||||||
|
companyName: null,
|
||||||
|
legalEntityName: null,
|
||||||
|
rucNumber: null,
|
||||||
|
companyAddress: null,
|
||||||
|
companyCity: null,
|
||||||
|
companyCountry: null,
|
||||||
|
supportEmail: null,
|
||||||
|
legalEmail: null,
|
||||||
|
governingLaw: null,
|
||||||
|
jurisdictionCity: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [legalErrors, setLegalErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [settingsRes, timezonesRes] = await Promise.all([
|
const [settingsRes, timezonesRes, legalRes] = await Promise.all([
|
||||||
siteSettingsApi.get(),
|
siteSettingsApi.get(),
|
||||||
siteSettingsApi.getTimezones(),
|
siteSettingsApi.getTimezones(),
|
||||||
|
legalSettingsApi.get().catch(() => ({ settings: {} as LegalSettingsData })),
|
||||||
]);
|
]);
|
||||||
setSettings(settingsRes.settings);
|
setSettings(settingsRes.settings);
|
||||||
setTimezones(timezonesRes.timezones);
|
setTimezones(timezonesRes.timezones);
|
||||||
|
setLegalSettings(legalRes.settings);
|
||||||
|
|
||||||
// Load featured event details if one is set
|
// Load featured event details if one is set
|
||||||
if (settingsRes.settings.featuredEventId) {
|
if (settingsRes.settings.featuredEventId) {
|
||||||
@@ -100,10 +122,53 @@ export default function AdminSettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateLegalSettings = (): boolean => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Validate email formats if provided
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (legalSettings.supportEmail && !emailRegex.test(legalSettings.supportEmail)) {
|
||||||
|
errors.supportEmail = locale === 'es' ? 'Email no válido' : 'Invalid email address';
|
||||||
|
}
|
||||||
|
if (legalSettings.legalEmail && !emailRegex.test(legalSettings.legalEmail)) {
|
||||||
|
errors.legalEmail = locale === 'es' ? 'Email no válido' : 'Invalid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
setLegalErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveLegal = async () => {
|
||||||
|
if (!validateLegalSettings()) return;
|
||||||
|
|
||||||
|
setSavingLegal(true);
|
||||||
|
try {
|
||||||
|
const response = await legalSettingsApi.update(legalSettings);
|
||||||
|
setLegalSettings(response.settings);
|
||||||
|
toast.success(locale === 'es' ? 'Configuración legal guardada' : 'Legal settings saved');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to save legal settings');
|
||||||
|
} finally {
|
||||||
|
setSavingLegal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateSetting = <K extends keyof SiteSettings>(key: K, value: SiteSettings[K]) => {
|
const updateSetting = <K extends keyof SiteSettings>(key: K, value: SiteSettings[K]) => {
|
||||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateLegalSetting = <K extends keyof LegalSettingsData>(key: K, value: LegalSettingsData[K]) => {
|
||||||
|
setLegalSettings((prev) => ({ ...prev, [key]: value }));
|
||||||
|
// Clear error for this field when user types
|
||||||
|
if (legalErrors[key]) {
|
||||||
|
setLegalErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
@@ -112,6 +177,21 @@ export default function AdminSettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabs: { id: SettingsTab; label: string; labelEs: string; icon: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
label: 'General Settings',
|
||||||
|
labelEs: 'Configuración General',
|
||||||
|
icon: <Cog6ToothIcon className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legal',
|
||||||
|
label: 'Legal Settings',
|
||||||
|
labelEs: 'Configuración Legal',
|
||||||
|
icon: <ScaleIcon className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -126,12 +206,42 @@ export default function AdminSettingsPage() {
|
|||||||
: 'Configure general website settings'}
|
: 'Configure general website settings'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{activeTab === 'general' && (
|
||||||
<Button onClick={handleSave} isLoading={saving}>
|
<Button onClick={handleSave} isLoading={saving}>
|
||||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||||
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{activeTab === 'legal' && (
|
||||||
|
<Button onClick={handleSaveLegal} isLoading={savingLegal}>
|
||||||
|
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-secondary-light-gray mb-6">
|
||||||
|
<nav className="flex space-x-0" aria-label="Tabs">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-primary-yellow text-primary-dark'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{locale === 'es' ? tab.labelEs : tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* General Settings Tab */}
|
||||||
|
{activeTab === 'general' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Timezone Settings */}
|
{/* Timezone Settings */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -213,6 +323,7 @@ export default function AdminSettingsPage() {
|
|||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-amber-600 mt-1">
|
<p className="text-xs text-amber-600 mt-1">
|
||||||
@@ -488,6 +599,196 @@ export default function AdminSettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legal Settings Tab */}
|
||||||
|
{activeTab === 'legal' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Info Banner */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<p className="text-blue-800 text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
{locale === 'es' ? 'Nota:' : 'Note:'}
|
||||||
|
</span>{' '}
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Estos valores se usan como marcadores de posición en las páginas legales. Los marcadores como {{COMPANY_NAME}} se reemplazan automáticamente con los valores configurados aquí.'
|
||||||
|
: 'These values are used as placeholders in legal pages. Placeholders like {{COMPANY_NAME}} are automatically replaced with the values configured here.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company Information */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||||
|
<ScaleIcon className="w-5 h-5 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'Información de la Empresa' : 'Company Information'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Datos legales de la empresa que aparecerán en las páginas legales'
|
||||||
|
: 'Legal company details that will appear in legal pages'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Nombre de la Empresa' : 'Company Name'}
|
||||||
|
value={legalSettings.companyName || ''}
|
||||||
|
onChange={(e) => updateLegalSetting('companyName', e.target.value || null)}
|
||||||
|
placeholder={locale === 'es' ? 'Ej: Spanglish S.A.' : 'e.g. Spanglish S.A.'}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Nombre de la Entidad Legal' : 'Legal Entity Name'}
|
||||||
|
value={legalSettings.legalEntityName || ''}
|
||||||
|
onChange={(e) => updateLegalSetting('legalEntityName', e.target.value || null)}
|
||||||
|
placeholder={locale === 'es' ? 'Ej: Spanglish S.A.' : 'e.g. Spanglish S.A.'}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Número de RUC' : 'RUC Number'}
|
||||||
|
value={legalSettings.rucNumber || ''}
|
||||||
|
onChange={(e) => updateLegalSetting('rucNumber', e.target.value || null)}
|
||||||
|
placeholder={locale === 'es' ? 'Ej: 80012345-6' : 'e.g. 80012345-6'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Address & Location */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-teal-100 rounded-full flex items-center justify-center">
|
||||||
|
<GlobeAltIcon className="w-5 h-5 text-teal-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'Dirección y Ubicación' : 'Address & Location'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Dirección física y jurisdicción legal'
|
||||||
|
: 'Physical address and legal jurisdiction'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Dirección de la Empresa' : 'Company Address'}
|
||||||
|
value={legalSettings.companyAddress || ''}
|
||||||
|
onChange={(e) => updateLegalSetting('companyAddress', e.target.value || null)}
|
||||||
|
placeholder={locale === 'es' ? 'Ej: Av. Mariscal López 1234' : 'e.g. 1234 Main Street'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Ciudad' : 'City'}
|
||||||
|
value={legalSettings.companyCity || ''}
|
||||||
|
onChange={(e) => updateLegalSetting('companyCity', e.target.value || null)}
|
||||||
|
placeholder={locale === 'es' ? 'Ej: Asunción' : 'e.g. Asunción'}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'País' : 'Country'}
|
||||||
|
value={legalSettings.companyCountry || ''}
|
||||||
|
onChange={(e) => updateLegalSetting('companyCountry', e.target.value || null)}
|
||||||
|
placeholder={locale === 'es' ? 'Ej: Paraguay' : 'e.g. Paraguay'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contact Emails */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-rose-100 rounded-full flex items-center justify-center">
|
||||||
|
<EnvelopeIcon className="w-5 h-5 text-rose-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'Emails Legales' : 'Legal Emails'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Direcciones de email para asuntos legales y soporte'
|
||||||
|
: 'Email addresses for legal matters and support'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Email de Soporte' : 'Support Email'}
|
||||||
|
type="email"
|
||||||
|
value={legalSettings.supportEmail || ''}
|
||||||
|
onChange={(e) => updateLegalSetting('supportEmail', e.target.value || null)}
|
||||||
|
placeholder="support@example.com"
|
||||||
|
error={legalErrors.supportEmail}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Email Legal' : 'Legal Email'}
|
||||||
|
type="email"
|
||||||
|
value={legalSettings.legalEmail || ''}
|
||||||
|
onChange={(e) => updateLegalSetting('legalEmail', e.target.value || null)}
|
||||||
|
placeholder="legal@example.com"
|
||||||
|
error={legalErrors.legalEmail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Legal Jurisdiction */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
|
||||||
|
<ScaleIcon className="w-5 h-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'Jurisdicción Legal' : 'Legal Jurisdiction'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Ley aplicable y jurisdicción para las páginas legales'
|
||||||
|
: 'Governing law and jurisdiction for legal pages'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Ley Aplicable' : 'Governing Law'}
|
||||||
|
value={legalSettings.governingLaw || ''}
|
||||||
|
onChange={(e) => updateLegalSetting('governingLaw', e.target.value || null)}
|
||||||
|
placeholder={locale === 'es' ? 'Ej: las leyes de la República del Paraguay' : 'e.g. the laws of the Republic of Paraguay'}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Ciudad de Jurisdicción' : 'Jurisdiction City'}
|
||||||
|
value={legalSettings.jurisdictionCity || ''}
|
||||||
|
onChange={(e) => updateLegalSetting('jurisdictionCity', e.target.value || null)}
|
||||||
|
placeholder={locale === 'es' ? 'Ej: Asunción' : 'e.g. Asunción'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Save Button at Bottom */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSaveLegal} isLoading={savingLegal} size="lg">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Guardar Configuración Legal' : 'Save Legal Settings'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 +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',
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
||||||
@@ -175,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>
|
||||||
@@ -311,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>
|
||||||
@@ -326,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-xs text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
|
<p className="text-[10px] text-gray-400">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>
|
||||||
)}
|
)}
|
||||||
@@ -402,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,29 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { usersApi, User } from '@/lib/api';
|
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 { TrashIcon } from '@heroicons/react/24/outline';
|
import Input from '@/components/ui/Input';
|
||||||
|
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
|
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import 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();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [roleFilter, setRoleFilter] = useState<string>('');
|
const [roleFilter, setRoleFilter] = useState<string>('');
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
role: '' as User['role'],
|
||||||
|
languagePreference: '' as string,
|
||||||
|
accountStatus: '' as string,
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
@@ -41,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');
|
||||||
@@ -51,21 +65,54 @@ export default function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditModal = (user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setEditForm({
|
||||||
|
name: user.name, email: user.email, phone: user.phone || '',
|
||||||
|
role: user.role, languagePreference: user.languagePreference || '',
|
||||||
|
accountStatus: user.accountStatus || 'active',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingUser) return;
|
||||||
|
if (!editForm.name.trim() || editForm.name.trim().length < 2) {
|
||||||
|
toast.error('Name must be at least 2 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editForm.email.trim()) {
|
||||||
|
toast.error('Email is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await usersApi.update(editingUser.id, {
|
||||||
|
name: editForm.name.trim(), email: editForm.email.trim(),
|
||||||
|
phone: editForm.phone.trim() || undefined, role: editForm.role,
|
||||||
|
languagePreference: editForm.languagePreference || undefined,
|
||||||
|
accountStatus: editForm.accountStatus || undefined,
|
||||||
|
} as Partial<User>);
|
||||||
|
toast.success('User updated successfully');
|
||||||
|
setEditingUser(null);
|
||||||
|
loadUsers();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to update user');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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',
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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>;
|
||||||
};
|
};
|
||||||
@@ -81,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>
|
||||||
@@ -105,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>
|
||||||
@@ -157,16 +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
|
<PencilSquareIcon className="w-4 h-4" />
|
||||||
onClick={() => handleDelete(user.id)}
|
</button>
|
||||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
<button onClick={() => handleDelete(user.id)}
|
||||||
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>
|
||||||
@@ -178,6 +228,128 @@ export default function AdminUsersPage() {
|
|||||||
</table>
|
</table>
|
||||||
</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 */}
|
||||||
|
{editingUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||||
|
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||||
|
<h2 className="text-base font-bold">Edit User</h2>
|
||||||
|
<button onClick={() => setEditingUser(null)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleEditSubmit} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
<Input label="Name" value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required minLength={2} />
|
||||||
|
<Input label="Email" type="email" value={editForm.email}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })} required />
|
||||||
|
<Input label="Phone" value={editForm.phone}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })} placeholder="Optional" />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label>
|
||||||
|
<select value={editForm.role} onChange={(e) => setEditForm({ ...editForm, role: e.target.value as User['role'] })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]">
|
||||||
|
<option value="user">{t('admin.users.roles.user')}</option>
|
||||||
|
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||||
|
<option value="marketing">{t('admin.users.roles.marketing')}</option>
|
||||||
|
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||||
|
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label>
|
||||||
|
<select value={editForm.languagePreference}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, languagePreference: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]">
|
||||||
|
<option value="">Not set</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label>
|
||||||
|
<select value={editForm.accountStatus}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, accountStatus: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]">
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="unclaimed">Unclaimed</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setEditingUser(null)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||||
|
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">Save Changes</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdminMobileStyles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
31
frontend/src/app/api/revalidate/route.ts
Normal file
31
frontend/src/app/api/revalidate/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { revalidateTag } from 'next/cache';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { secret, tag } = body;
|
||||||
|
|
||||||
|
// Validate the revalidation secret
|
||||||
|
const revalidateSecret = process.env.REVALIDATE_SECRET;
|
||||||
|
if (!revalidateSecret || secret !== revalidateSecret) {
|
||||||
|
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate tag(s) - supports single tag or array of tags
|
||||||
|
const allowedTags = ['events-sitemap', 'next-event'];
|
||||||
|
const tags: string[] = Array.isArray(tag) ? tag : [tag];
|
||||||
|
const invalidTags = tags.filter((t: string) => !allowedTags.includes(t));
|
||||||
|
if (tags.length === 0 || invalidTags.length > 0) {
|
||||||
|
return NextResponse.json({ error: 'Invalid tag' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of tags) {
|
||||||
|
revalidateTag(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ revalidated: true, tags, now: Date.now() });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } 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,25 +24,22 @@ 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));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle both full URLs and handles
|
// Handle both full URLs and handles
|
||||||
const instagramUrl = instagramHandle
|
const instagramUrl = instagramHandle
|
||||||
@@ -62,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>
|
||||||
@@ -80,7 +77,7 @@ export default function LinktreePage() {
|
|||||||
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
) : nextEvent ? (
|
) : nextEvent ? (
|
||||||
<Link href={`/book/${nextEvent.id}`} className="block group">
|
<Link href={`/events/${nextEvent.id}`} className="block group">
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 transition-all duration-300 hover:bg-white/15 hover:scale-[1.02] hover:shadow-xl">
|
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 transition-all duration-300 hover:bg-white/15 hover:scale-[1.02] hover:shadow-xl">
|
||||||
<h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors">
|
<h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors">
|
||||||
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
||||||
@@ -89,7 +86,7 @@ export default function LinktreePage() {
|
|||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||||
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
<span>{formatDate(nextEvent.startDatetime)} • {formatTime(nextEvent.startDatetime)}</span>
|
<span>{formatDate(nextEvent.startDatetime)} • {fmtTime(nextEvent.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||||
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
@@ -111,7 +108,7 @@ export default function LinktreePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 bg-primary-yellow text-primary-dark font-semibold py-3 px-4 rounded-xl text-center transition-all duration-200 group-hover:bg-yellow-400">
|
<div className="mt-4 bg-primary-yellow text-primary-dark font-semibold py-3 px-4 rounded-xl text-center transition-all duration-200 group-hover:bg-yellow-400">
|
||||||
{t('linktree.bookNow')}
|
{t('linktree.moreInfo')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
272
frontend/src/app/llms.txt/route.ts
Normal file
272
frontend/src/app/llms.txt/route.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
interface LlmsFaq {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LlmsEvent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
titleEs?: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
shortDescriptionEs?: string;
|
||||||
|
description: string;
|
||||||
|
descriptionEs?: string;
|
||||||
|
startDatetime: string;
|
||||||
|
endDatetime?: string;
|
||||||
|
location: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
availableSeats?: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = await response.json();
|
||||||
|
return data.event || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = await response.json();
|
||||||
|
return data.events || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event times are always shown in Paraguay time (America/Asuncion) so llms.txt
|
||||||
|
// matches what users see on the website, regardless of server timezone.
|
||||||
|
const EVENT_TIMEZONE = 'America/Asuncion';
|
||||||
|
|
||||||
|
function formatEventDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventTime(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number, currency: string): string {
|
||||||
|
if (price === 0) return 'Free';
|
||||||
|
return `${price.toLocaleString()} ${currency}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatISODate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-CA', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatISOTime(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTodayISO(): string {
|
||||||
|
return new Date().toLocaleDateString('en-CA', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventStatus(event: LlmsEvent): string {
|
||||||
|
if (event.availableSeats !== undefined && event.availableSeats === 0) return 'Sold Out';
|
||||||
|
if (event.status === 'published') return 'Available';
|
||||||
|
return event.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = await response.json();
|
||||||
|
return (data.faqs || []).map((f: any) => ({
|
||||||
|
question: f.question,
|
||||||
|
answer: f.answer,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
|
||||||
|
getNextUpcomingEvent(),
|
||||||
|
getUpcomingEvents(),
|
||||||
|
getHomepageFaqs(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.push('# Spanglish Community');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('## Metadata');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('- Type: Event Community');
|
||||||
|
lines.push('- Primary Language: English, Spanish');
|
||||||
|
lines.push('- Location: Asunción, Paraguay');
|
||||||
|
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
|
||||||
|
lines.push(`- Last Updated: ${getTodayISO()}`);
|
||||||
|
lines.push(`- Canonical URL: ${siteUrl}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('> English-Spanish language exchange community organizing social events and meetups in Asunción, Paraguay.');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`- Website: ${siteUrl}`);
|
||||||
|
lines.push(`- Events page: ${siteUrl}/events`);
|
||||||
|
|
||||||
|
// Social links
|
||||||
|
const instagram = process.env.NEXT_PUBLIC_INSTAGRAM;
|
||||||
|
const whatsapp = process.env.NEXT_PUBLIC_WHATSAPP;
|
||||||
|
const telegram = process.env.NEXT_PUBLIC_TELEGRAM;
|
||||||
|
const email = process.env.NEXT_PUBLIC_EMAIL;
|
||||||
|
|
||||||
|
if (instagram) lines.push(`- Instagram: ${instagram}`);
|
||||||
|
if (telegram) lines.push(`- Telegram: ${telegram}`);
|
||||||
|
if (email) lines.push(`- Email: ${email}`);
|
||||||
|
if (whatsapp) lines.push(`- WhatsApp: ${whatsapp}`);
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Next Event (most important section for AI)
|
||||||
|
lines.push('## Next Event');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
if (nextEvent) {
|
||||||
|
const status = getEventStatus(nextEvent);
|
||||||
|
lines.push(`- Event Name: ${nextEvent.title}`);
|
||||||
|
lines.push(`- Event ID: ${nextEvent.id}`);
|
||||||
|
lines.push(`- Status: ${status}`);
|
||||||
|
lines.push(`- Date: ${formatISODate(nextEvent.startDatetime)}`);
|
||||||
|
lines.push(`- Start Time: ${formatISOTime(nextEvent.startDatetime)}`);
|
||||||
|
if (nextEvent.endDatetime) {
|
||||||
|
lines.push(`- End Time: ${formatISOTime(nextEvent.endDatetime)}`);
|
||||||
|
}
|
||||||
|
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
|
||||||
|
lines.push(`- Venue: ${nextEvent.location}`);
|
||||||
|
lines.push('- City: Asunción');
|
||||||
|
lines.push('- Country: Paraguay');
|
||||||
|
lines.push(`- Price: ${nextEvent.price === 0 ? 'Free' : nextEvent.price}`);
|
||||||
|
lines.push(`- Currency: ${nextEvent.currency}`);
|
||||||
|
if (nextEvent.availableSeats !== undefined) {
|
||||||
|
lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`);
|
||||||
|
}
|
||||||
|
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.id}`);
|
||||||
|
if (nextEvent.shortDescription) {
|
||||||
|
lines.push(`- Description: ${nextEvent.shortDescription}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('No upcoming events currently scheduled. Check back soon or follow us on social media for announcements.');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// All upcoming events
|
||||||
|
if (upcomingEvents.length > 1) {
|
||||||
|
lines.push('## All Upcoming Events');
|
||||||
|
lines.push('');
|
||||||
|
for (const event of upcomingEvents) {
|
||||||
|
const status = getEventStatus(event);
|
||||||
|
lines.push(`### ${event.title}`);
|
||||||
|
lines.push(`- Event ID: ${event.id}`);
|
||||||
|
lines.push(`- Status: ${status}`);
|
||||||
|
lines.push(`- Date: ${formatISODate(event.startDatetime)}`);
|
||||||
|
lines.push(`- Start Time: ${formatISOTime(event.startDatetime)}`);
|
||||||
|
if (event.endDatetime) {
|
||||||
|
lines.push(`- End Time: ${formatISOTime(event.endDatetime)}`);
|
||||||
|
}
|
||||||
|
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
|
||||||
|
lines.push(`- Venue: ${event.location}`);
|
||||||
|
lines.push('- City: Asunción');
|
||||||
|
lines.push('- Country: Paraguay');
|
||||||
|
lines.push(`- Price: ${event.price === 0 ? 'Free' : event.price}`);
|
||||||
|
lines.push(`- Currency: ${event.currency}`);
|
||||||
|
if (event.availableSeats !== undefined) {
|
||||||
|
lines.push(`- Capacity Remaining: ${event.availableSeats}`);
|
||||||
|
}
|
||||||
|
lines.push(`- Tickets URL: ${siteUrl}/events/${event.id}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// About section
|
||||||
|
lines.push('## About Spanglish');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Spanglish is a language exchange community based in Asunción, Paraguay. We organize regular social events where people can practice English and Spanish in a relaxed, friendly environment. Our events bring together locals and internationals for conversation, cultural exchange, and fun.');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('## Frequently Asked Questions');
|
||||||
|
lines.push('');
|
||||||
|
if (faqs.length > 0) {
|
||||||
|
for (const faq of faqs) {
|
||||||
|
lines.push(`- **${faq.question}** ${faq.answer}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('- **What is Spanglish?** A language exchange community that hosts social events to practice English and Spanish.');
|
||||||
|
lines.push('- **Where are events held?** In Asunción, Paraguay. Specific venues are listed on each event page.');
|
||||||
|
lines.push('- **How do I attend an event?** Visit the events page to see upcoming events and book tickets.');
|
||||||
|
}
|
||||||
|
lines.push(`- More FAQ: ${siteUrl}/faq`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Update Policy
|
||||||
|
lines.push('## Update Policy');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Event information is updated whenever new events are published or ticket availability changes.');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// AI Summary
|
||||||
|
lines.push('## AI Summary');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Spanglish Community organizes English-Spanish language exchange events in Asunción, Paraguay. Events require registration via the website.');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
const content = lines.join('\n');
|
||||||
|
|
||||||
|
return new NextResponse(content, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,29 +7,16 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
userAgent: '*',
|
userAgent: '*',
|
||||||
allow: [
|
allow: '/',
|
||||||
'/',
|
|
||||||
'/events',
|
|
||||||
'/events/*',
|
|
||||||
'/community',
|
|
||||||
'/contact',
|
|
||||||
'/faq',
|
|
||||||
'/legal/*',
|
|
||||||
],
|
|
||||||
disallow: [
|
disallow: [
|
||||||
'/admin',
|
'/admin/',
|
||||||
'/admin/*',
|
'/dashboard/',
|
||||||
'/dashboard',
|
'/api/',
|
||||||
'/dashboard/*',
|
'/book/',
|
||||||
'/api',
|
'/booking/',
|
||||||
'/api/*',
|
|
||||||
'/book',
|
|
||||||
'/book/*',
|
|
||||||
'/booking',
|
|
||||||
'/booking/*',
|
|
||||||
'/login',
|
'/login',
|
||||||
'/register',
|
'/register',
|
||||||
'/auth/*',
|
'/auth/',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,89 +3,109 @@ import { MetadataRoute } from 'next';
|
|||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
interface Event {
|
interface SitemapEvent {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
startDatetime: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPublishedEvents(): Promise<Event[]> {
|
/**
|
||||||
|
* Fetch all indexable events: published, completed, and cancelled.
|
||||||
|
* Sold-out / past events stay in the index (marked as expired, not removed).
|
||||||
|
* Only draft and archived events are excluded.
|
||||||
|
*/
|
||||||
|
async function getIndexableEvents(): Promise<SitemapEvent[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiUrl}/api/events?status=published`, {
|
const [publishedRes, completedRes] = await Promise.all([
|
||||||
next: { revalidate: 3600 }, // Cache for 1 hour
|
fetch(`${apiUrl}/api/events?status=published`, {
|
||||||
});
|
next: { tags: ['events-sitemap'] },
|
||||||
if (!response.ok) return [];
|
}),
|
||||||
const data = await response.json();
|
fetch(`${apiUrl}/api/events?status=completed`, {
|
||||||
return data.events || [];
|
next: { tags: ['events-sitemap'] },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const published = publishedRes.ok
|
||||||
|
? ((await publishedRes.json()).events as SitemapEvent[]) || []
|
||||||
|
: [];
|
||||||
|
const completed = completedRes.ok
|
||||||
|
? ((await completedRes.json()).events as SitemapEvent[]) || []
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [...published, ...completed];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
// Fetch published events for dynamic event pages
|
const events = await getIndexableEvents();
|
||||||
const events = await getPublishedEvents();
|
const now = new Date();
|
||||||
|
|
||||||
// Static pages
|
// Static pages
|
||||||
const staticPages: MetadataRoute.Sitemap = [
|
const staticPages: MetadataRoute.Sitemap = [
|
||||||
{
|
{
|
||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'weekly',
|
changeFrequency: 'weekly',
|
||||||
priority: 1,
|
priority: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/events`,
|
url: `${siteUrl}/events`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'daily',
|
changeFrequency: 'daily',
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/community`,
|
url: `${siteUrl}/community`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/contact`,
|
url: `${siteUrl}/contact`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/faq`,
|
url: `${siteUrl}/faq`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
},
|
},
|
||||||
// Legal pages
|
// Legal pages
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/legal/terms-policy`,
|
url: `${siteUrl}/legal/terms-policy`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 0.3,
|
priority: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/legal/privacy-policy`,
|
url: `${siteUrl}/legal/privacy-policy`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 0.3,
|
priority: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/legal/refund-cancelation-policy`,
|
url: `${siteUrl}/legal/refund-cancelation-policy`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 0.3,
|
priority: 0.3,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Dynamic event pages
|
// Dynamic event pages — upcoming events get higher priority
|
||||||
const eventPages: MetadataRoute.Sitemap = events.map((event) => ({
|
const eventPages: MetadataRoute.Sitemap = events.map((event) => {
|
||||||
|
const isUpcoming = new Date(event.startDatetime) > now;
|
||||||
|
return {
|
||||||
url: `${siteUrl}/events/${event.id}`,
|
url: `${siteUrl}/events/${event.id}`,
|
||||||
lastModified: new Date(event.updatedAt),
|
lastModified: new Date(event.updatedAt),
|
||||||
changeFrequency: 'weekly' as const,
|
changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const),
|
||||||
priority: 0.8,
|
priority: isUpcoming ? 0.8 : 0.5,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return [...staticPages, ...eventPages];
|
return [...staticPages, ...eventPages];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import {
|
import {
|
||||||
ShareIcon,
|
ShareIcon,
|
||||||
@@ -18,6 +18,12 @@ interface ShareButtonsProps {
|
|||||||
export default function ShareButtons({ title, url, description }: ShareButtonsProps) {
|
export default function ShareButtons({ title, url, description }: ShareButtonsProps) {
|
||||||
const { locale } = useLanguage();
|
const { locale } = useLanguage();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [supportsNativeShare, setSupportsNativeShare] = useState(false);
|
||||||
|
|
||||||
|
// Check for native share support only after mount to avoid hydration mismatch
|
||||||
|
useEffect(() => {
|
||||||
|
setSupportsNativeShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function');
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Use provided URL or current page URL
|
// Use provided URL or current page URL
|
||||||
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||||
@@ -133,7 +139,7 @@ export default function ShareButtons({ title, url, description }: ShareButtonsPr
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Native Share (mobile) */}
|
{/* Native Share (mobile) */}
|
||||||
{typeof navigator !== 'undefined' && typeof navigator.share === 'function' && (
|
{supportsNativeShare && (
|
||||||
<button
|
<button
|
||||||
onClick={handleNativeShare}
|
onClick={handleNativeShare}
|
||||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-yellow text-primary-dark hover:bg-primary-yellow/90 transition-colors"
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-yellow text-primary-dark hover:bg-primary-yellow/90 transition-colors"
|
||||||
|
|||||||
183
frontend/src/components/admin/MobileComponents.tsx
Normal file
183
frontend/src/components/admin/MobileComponents.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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')}
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ export default function RichTextEditor({
|
|||||||
const lastContentRef = useRef(content);
|
const lastContentRef = useRef(content);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
immediatelyRender: false,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: {
|
heading: {
|
||||||
@@ -393,6 +394,7 @@ export function RichTextPreview({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
immediatelyRender: false,
|
||||||
extensions: [StarterKit],
|
extensions: [StarterKit],
|
||||||
content: markdownToHtml(content),
|
content: markdownToHtml(content),
|
||||||
editable: false,
|
editable: false,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
41
frontend/src/hooks/useStatsPrivacy.ts
Normal file
41
frontend/src/hooks/useStatsPrivacy.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
|
||||||
|
|
||||||
|
export function useStatsPrivacy() {
|
||||||
|
const [showStats, setShowStatsState] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored !== null) {
|
||||||
|
setShowStatsState(stored !== 'true');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setShowStats = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
|
||||||
|
setShowStatsState((prev) => {
|
||||||
|
const next = typeof value === 'function' ? value(prev) : value;
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(!next));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleStats = useCallback(() => {
|
||||||
|
setShowStats((prev) => !prev);
|
||||||
|
}, [setShowStats]);
|
||||||
|
|
||||||
|
return [showStats, setShowStats, toggleStats] as const;
|
||||||
|
}
|
||||||
@@ -67,6 +67,10 @@
|
|||||||
"button": "Subscribe",
|
"button": "Subscribe",
|
||||||
"success": "Thanks for subscribing!",
|
"success": "Thanks for subscribing!",
|
||||||
"error": "Subscription failed. Please try again."
|
"error": "Subscription failed. Please try again."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Frequently Asked Questions",
|
||||||
|
"seeFull": "See full FAQ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
@@ -317,7 +321,7 @@
|
|||||||
"tagline": "Language Exchange Community",
|
"tagline": "Language Exchange Community",
|
||||||
"nextEvent": "Next Event",
|
"nextEvent": "Next Event",
|
||||||
"noEvents": "No upcoming events",
|
"noEvents": "No upcoming events",
|
||||||
"bookNow": "Book Now",
|
"moreInfo": "More info",
|
||||||
"joinCommunity": "Join Our Community",
|
"joinCommunity": "Join Our Community",
|
||||||
"visitWebsite": "Visit Our Website",
|
"visitWebsite": "Visit Our Website",
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
|
|||||||
@@ -67,6 +67,10 @@
|
|||||||
"button": "Suscribirse",
|
"button": "Suscribirse",
|
||||||
"success": "¡Gracias por suscribirte!",
|
"success": "¡Gracias por suscribirte!",
|
||||||
"error": "Error al suscribirse. Por favor intenta de nuevo."
|
"error": "Error al suscribirse. Por favor intenta de nuevo."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Preguntas Frecuentes",
|
||||||
|
"seeFull": "Ver FAQ completo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
@@ -317,7 +321,7 @@
|
|||||||
"tagline": "Comunidad de Intercambio de Idiomas",
|
"tagline": "Comunidad de Intercambio de Idiomas",
|
||||||
"nextEvent": "Próximo Evento",
|
"nextEvent": "Próximo Evento",
|
||||||
"noEvents": "No hay eventos próximos",
|
"noEvents": "No hay eventos próximos",
|
||||||
"bookNow": "Reservar Ahora",
|
"moreInfo": "Más información",
|
||||||
"joinCommunity": "Únete a Nuestra Comunidad",
|
"joinCommunity": "Únete a Nuestra Comunidad",
|
||||||
"visitWebsite": "Visitar Nuestro Sitio",
|
"visitWebsite": "Visitar Nuestro Sitio",
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
|
|||||||
@@ -93,6 +93,27 @@ export const ticketsApi = {
|
|||||||
body: JSON.stringify({ code, eventId }),
|
body: JSON.stringify({ code, eventId }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Search tickets by name/email (for scanner manual search)
|
||||||
|
search: (query: string, eventId?: string) =>
|
||||||
|
fetchApi<{ tickets: TicketSearchResult[] }>('/api/tickets/search', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ query, eventId }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get event check-in stats (for scanner header counter)
|
||||||
|
getCheckinStats: (eventId: string) =>
|
||||||
|
fetchApi<{ eventId: string; capacity: number; checkedIn: number; totalActive: number }>(
|
||||||
|
`/api/tickets/stats/checkin?eventId=${eventId}`
|
||||||
|
),
|
||||||
|
|
||||||
|
// Live search tickets (GET - for scanner live search with debounce)
|
||||||
|
searchLive: (q: string, eventId?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('q', q);
|
||||||
|
if (eventId) params.set('eventId', eventId);
|
||||||
|
return fetchApi<{ tickets: LiveSearchResult[] }>(`/api/tickets/search?${params}`);
|
||||||
|
},
|
||||||
|
|
||||||
checkin: (id: string) =>
|
checkin: (id: string) =>
|
||||||
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -145,6 +166,20 @@ export const ticketsApi = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
manualCreate: (data: {
|
||||||
|
eventId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName?: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
preferredLanguage?: 'en' | 'es';
|
||||||
|
adminNote?: string;
|
||||||
|
}) =>
|
||||||
|
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/manual', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
checkPaymentStatus: (ticketId: string) =>
|
checkPaymentStatus: (ticketId: string) =>
|
||||||
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
||||||
`/api/lnbits/status/${ticketId}`
|
`/api/lnbits/status/${ticketId}`
|
||||||
@@ -201,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}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -337,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
|
||||||
@@ -370,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',
|
||||||
@@ -413,6 +493,11 @@ export const emailsApi = {
|
|||||||
|
|
||||||
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
|
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
|
||||||
|
|
||||||
|
resendLog: (id: string) =>
|
||||||
|
fetchApi<{ success: boolean; error?: string }>(`/api/emails/logs/${id}/resend`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
getStats: (eventId?: string) => {
|
getStats: (eventId?: string) => {
|
||||||
const query = eventId ? `?eventId=${eventId}` : '';
|
const query = eventId ? `?eventId=${eventId}` : '';
|
||||||
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
|
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
|
||||||
@@ -438,7 +523,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;
|
||||||
@@ -494,6 +579,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;
|
||||||
@@ -679,6 +797,8 @@ export interface EmailLog {
|
|||||||
sentAt?: string;
|
sentAt?: string;
|
||||||
sentBy?: string;
|
sentBy?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
resendAttempts?: number;
|
||||||
|
lastResentAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailStats {
|
export interface EmailStats {
|
||||||
@@ -994,6 +1114,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 {
|
||||||
@@ -1064,3 +1212,71 @@ export const legalPagesApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== FAQ Types ====================
|
||||||
|
|
||||||
|
export interface FaqItem {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
questionEs?: string | null;
|
||||||
|
answer: string;
|
||||||
|
answerEs?: string | null;
|
||||||
|
rank?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaqItemAdmin extends FaqItem {
|
||||||
|
enabled: boolean;
|
||||||
|
showOnHomepage: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== FAQ API ====================
|
||||||
|
|
||||||
|
export const faqApi = {
|
||||||
|
// Public
|
||||||
|
getList: (homepageOnly?: boolean) =>
|
||||||
|
fetchApi<{ faqs: FaqItem[] }>(`/api/faq${homepageOnly ? '?homepage=true' : ''}`),
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
getAdminList: () =>
|
||||||
|
fetchApi<{ faqs: FaqItemAdmin[] }>('/api/faq/admin/list'),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
fetchApi<{ faq: FaqItemAdmin }>(`/api/faq/admin/${id}`),
|
||||||
|
|
||||||
|
create: (data: {
|
||||||
|
question: string;
|
||||||
|
questionEs?: string;
|
||||||
|
answer: string;
|
||||||
|
answerEs?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
showOnHomepage?: boolean;
|
||||||
|
}) =>
|
||||||
|
fetchApi<{ faq: FaqItemAdmin }>('/api/faq/admin', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: (id: string, data: {
|
||||||
|
question?: string;
|
||||||
|
questionEs?: string | null;
|
||||||
|
answer?: string;
|
||||||
|
answerEs?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
showOnHomepage?: boolean;
|
||||||
|
}) =>
|
||||||
|
fetchApi<{ faq: FaqItemAdmin }>(`/api/faq/admin/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/faq/admin/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
reorder: (ids: string[]) =>
|
||||||
|
fetchApi<{ faqs: FaqItemAdmin[] }>('/api/faq/admin/reorder', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,3 +1,111 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Date / time formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// All helpers pin the timezone to America/Asuncion so the output is identical
|
||||||
|
// on the server (often UTC) and the client (user's local TZ). This prevents
|
||||||
|
// React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EVENT_TIMEZONE = 'America/Asuncion';
|
||||||
|
|
||||||
|
type Locale = 'en' | 'es';
|
||||||
|
|
||||||
|
function pickLocale(locale: Locale): string {
|
||||||
|
return locale === 'es' ? 'es-ES' : 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sat, Feb 14" / "sáb, 14 feb"
|
||||||
|
*/
|
||||||
|
export function formatDateShort(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026"
|
||||||
|
*/
|
||||||
|
export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "February 14, 2026" / "14 de febrero de 2026" (no weekday)
|
||||||
|
*/
|
||||||
|
export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Feb 14, 2026" / "14 feb 2026"
|
||||||
|
*/
|
||||||
|
export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString(pickLocale(locale), {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "04:30 PM" / "16:30"
|
||||||
|
*/
|
||||||
|
export function formatTime(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleTimeString(pickLocale(locale), {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Feb 14, 2026, 04:30 PM" — compact date + time combined
|
||||||
|
*/
|
||||||
|
export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleString(pickLocale(locale), {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sat, Feb 14, 04:30 PM" — short date + time combined
|
||||||
|
*/
|
||||||
|
export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string {
|
||||||
|
return new Date(dateStr).toLocaleString(pickLocale(locale), {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Price formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format price - shows decimals only if needed
|
* Format price - shows decimals only if needed
|
||||||
* Uses space as thousands separator (common in Paraguay)
|
* Uses space as thousands separator (common in Paraguay)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user