Compare commits
13 Commits
dev
...
b33c68feb0
| Author | SHA1 | Date | |
|---|---|---|---|
| b33c68feb0 | |||
| 15655e3987 | |||
| d8b3864411 | |||
| 194cbd6ca8 | |||
| d5445c2282 | |||
| dcfefc8371 | |||
| b5f14335c4 | |||
| d44ac949b5 | |||
| a5e939221d | |||
| 833e3e5a9c | |||
| ba1975dd6d | |||
| 3025ef3d21 | |||
| 8564f8af83 |
21
README.md
21
README.md
@@ -64,8 +64,6 @@ npm run start
|
|||||||
npm run db:generate
|
npm run db:generate
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
npm run db:studio
|
npm run db:studio
|
||||||
npm run db:export # Backup database
|
|
||||||
npm run db:import # Restore from backup
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also run per workspace:
|
You can also run per workspace:
|
||||||
@@ -119,25 +117,6 @@ Then run:
|
|||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backups (export / import)
|
|
||||||
|
|
||||||
Create backups and restore if needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Export (creates timestamped file in backend/data/backups/)
|
|
||||||
npm run db:export
|
|
||||||
|
|
||||||
# Export to custom path
|
|
||||||
npm run db:export -- -o ./my-backup.db # SQLite
|
|
||||||
npm run db:export -- -o ./my-backup.sql # PostgreSQL
|
|
||||||
|
|
||||||
# Import (stop the backend server first)
|
|
||||||
npm run db:import -- ./data/backups/spanglish-2025-03-07-143022.db
|
|
||||||
npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip confirmation
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Stop the backend before importing so the database file is not locked.
|
|
||||||
|
|
||||||
## Production deployment (nginx + systemd)
|
## Production deployment (nginx + systemd)
|
||||||
|
|
||||||
This repo includes example configs in `deploy/`:
|
This repo includes example configs in `deploy/`:
|
||||||
|
|||||||
@@ -8,9 +8,7 @@
|
|||||||
"start": "NODE_ENV=production node dist/index.js",
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx src/db/migrate.ts",
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio"
|
||||||
"db:export": "tsx src/db/export.ts",
|
|
||||||
"db:import": "tsx src/db/import.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.11.4",
|
"@hono/node-server": "^1.11.4",
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import 'dotenv/config';
|
|
||||||
import { closeSync, existsSync, mkdirSync, openSync } from 'fs';
|
|
||||||
import { dirname, resolve } from 'path';
|
|
||||||
import { spawnSync } from 'child_process';
|
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
|
|
||||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
|
||||||
const dbPath = process.env.DATABASE_URL || './data/spanglish.db';
|
|
||||||
const BACKUP_DIR = resolve(process.cwd(), 'data', 'backups');
|
|
||||||
|
|
||||||
function parseArgs(): { output?: string } {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const result: { output?: string } = {};
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
if (args[i] === '-o' || args[i] === '--output') {
|
|
||||||
result.output = args[i + 1];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimestamp(): string {
|
|
||||||
const now = new Date();
|
|
||||||
const y = now.getFullYear();
|
|
||||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
||||||
const d = String(now.getDate()).padStart(2, '0');
|
|
||||||
const h = String(now.getHours()).padStart(2, '0');
|
|
||||||
const min = String(now.getMinutes()).padStart(2, '0');
|
|
||||||
const s = String(now.getSeconds()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${d}-${h}${min}${s}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportSqlite(outputPath: string): void {
|
|
||||||
const db = new Database(resolve(process.cwd(), dbPath), { readonly: true });
|
|
||||||
try {
|
|
||||||
db.backup(outputPath);
|
|
||||||
console.log(`Exported to ${outputPath}`);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportPostgres(outputPath: string): void {
|
|
||||||
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
|
|
||||||
const outFd = openSync(outputPath, 'w');
|
|
||||||
try {
|
|
||||||
const result = spawnSync(
|
|
||||||
'pg_dump',
|
|
||||||
['--clean', '--if-exists', connString],
|
|
||||||
{
|
|
||||||
stdio: ['ignore', outFd, 'pipe'],
|
|
||||||
encoding: 'utf-8',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
console.error('pg_dump failed. Ensure pg_dump is installed and in PATH.');
|
|
||||||
console.error(result.error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
console.error('pg_dump failed:', result.stderr);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Exported to ${outputPath}`);
|
|
||||||
} finally {
|
|
||||||
closeSync(outFd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const { output } = parseArgs();
|
|
||||||
const ext = dbType === 'postgres' ? '.sql' : '.db';
|
|
||||||
const defaultName = `spanglish-${getTimestamp()}${ext}`;
|
|
||||||
|
|
||||||
const outputPath = output
|
|
||||||
? resolve(process.cwd(), output)
|
|
||||||
: resolve(BACKUP_DIR, defaultName);
|
|
||||||
|
|
||||||
const dir = dirname(outputPath);
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Database type: ${dbType}`);
|
|
||||||
if (dbType === 'sqlite') {
|
|
||||||
exportSqlite(outputPath);
|
|
||||||
} else {
|
|
||||||
exportPostgres(outputPath);
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error('Export failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import 'dotenv/config';
|
|
||||||
import { copyFileSync, existsSync, readFileSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { spawnSync } from 'child_process';
|
|
||||||
|
|
||||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
|
||||||
const dbPath = process.env.DATABASE_URL || './data/spanglish.db';
|
|
||||||
|
|
||||||
function parseArgs(): { file?: string; yes?: boolean } {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const result: { file?: string; yes?: boolean } = {};
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
if (args[i] === '-y' || args[i] === '--yes') {
|
|
||||||
result.yes = true;
|
|
||||||
} else if (!args[i].startsWith('-')) {
|
|
||||||
result.file = args[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function importSqlite(backupPath: string): void {
|
|
||||||
const targetPath = resolve(process.cwd(), dbPath);
|
|
||||||
copyFileSync(backupPath, targetPath);
|
|
||||||
console.log(`Restored from ${backupPath} to ${targetPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function importPostgres(backupPath: string): void {
|
|
||||||
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
|
|
||||||
const sql = readFileSync(backupPath, 'utf-8');
|
|
||||||
|
|
||||||
const result = spawnSync(
|
|
||||||
'psql',
|
|
||||||
[connString],
|
|
||||||
{
|
|
||||||
stdio: ['pipe', 'inherit', 'inherit'],
|
|
||||||
input: sql,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
console.error('psql failed. Ensure psql is installed and in PATH.');
|
|
||||||
console.error(result.error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Restored from ${backupPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const { file, yes } = parseArgs();
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
console.error('Usage: npm run db:import -- <backup-file> [--yes]');
|
|
||||||
console.error('Example: npm run db:import -- ./data/backups/spanglish-2025-03-07.db');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const backupPath = resolve(process.cwd(), file);
|
|
||||||
if (!existsSync(backupPath)) {
|
|
||||||
console.error(`Backup file not found: ${backupPath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!yes) {
|
|
||||||
console.log('WARNING: Import will overwrite the current database.');
|
|
||||||
console.log('Stop the backend server before importing.');
|
|
||||||
console.log('Press Ctrl+C to cancel, or run with --yes to skip this warning.');
|
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Database type: ${dbType}`);
|
|
||||||
if (dbType === 'sqlite') {
|
|
||||||
importSqlite(backupPath);
|
|
||||||
} else if (dbType === 'postgres') {
|
|
||||||
importPostgres(backupPath);
|
|
||||||
} else {
|
|
||||||
console.error('Unknown DB_TYPE. Use sqlite or postgres.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error('Import failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -368,13 +368,6 @@ async function migrate() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TEXT`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
|
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS email_settings (
|
CREATE TABLE IF NOT EXISTS email_settings (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -779,13 +772,6 @@ async function migrate() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TIMESTAMP`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS email_settings (
|
CREATE TABLE IF NOT EXISTS email_settings (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
|
|||||||
@@ -243,8 +243,6 @@ export const sqliteEmailLogs = sqliteTable('email_logs', {
|
|||||||
sentAt: text('sent_at'),
|
sentAt: text('sent_at'),
|
||||||
sentBy: text('sent_by').references(() => sqliteUsers.id),
|
sentBy: text('sent_by').references(() => sqliteUsers.id),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
resendAttempts: integer('resend_attempts').notNull().default(0),
|
|
||||||
lastResentAt: text('last_resent_at'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sqliteEmailSettings = sqliteTable('email_settings', {
|
export const sqliteEmailSettings = sqliteTable('email_settings', {
|
||||||
@@ -559,8 +557,6 @@ export const pgEmailLogs = pgTable('email_logs', {
|
|||||||
sentAt: timestamp('sent_at'),
|
sentAt: timestamp('sent_at'),
|
||||||
sentBy: uuid('sent_by').references(() => pgUsers.id),
|
sentBy: uuid('sent_by').references(() => pgUsers.id),
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
resendAttempts: pgInteger('resend_attempts').notNull().default(0),
|
|
||||||
lastResentAt: timestamp('last_resent_at'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pgEmailSettings = pgTable('email_settings', {
|
export const pgEmailSettings = pgTable('email_settings', {
|
||||||
|
|||||||
@@ -1342,61 +1342,6 @@ export const emailService = {
|
|||||||
error: result.error
|
error: result.error
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Resend an email from an existing log entry
|
|
||||||
*/
|
|
||||||
async resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const log = await dbGet<any>(
|
|
||||||
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!log) {
|
|
||||||
return { success: false, error: 'Email log not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
|
|
||||||
return { success: false, error: 'Email log missing required data to resend' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await sendEmail({
|
|
||||||
to: log.recipientEmail,
|
|
||||||
subject: log.subject,
|
|
||||||
html: log.bodyHtml,
|
|
||||||
text: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = getNow();
|
|
||||||
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailLogs)
|
|
||||||
.set({
|
|
||||||
status: 'sent',
|
|
||||||
sentAt: now,
|
|
||||||
errorMessage: null,
|
|
||||||
resendAttempts: currentResendAttempts,
|
|
||||||
lastResentAt: now,
|
|
||||||
})
|
|
||||||
.where(eq((emailLogs as any).id, logId));
|
|
||||||
} else {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailLogs)
|
|
||||||
.set({
|
|
||||||
status: 'failed',
|
|
||||||
errorMessage: result.error,
|
|
||||||
resendAttempts: currentResendAttempts,
|
|
||||||
lastResentAt: now,
|
|
||||||
})
|
|
||||||
.where(eq((emailLogs as any).id, logId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: result.success,
|
|
||||||
error: result.error,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export the main sendEmail function for direct use
|
// Export the main sendEmail function for direct use
|
||||||
|
|||||||
@@ -349,23 +349,6 @@ emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
return c.json({ log });
|
return c.json({ log });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resend email from log
|
|
||||||
emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async (c) => {
|
|
||||||
const { id } = c.req.param();
|
|
||||||
|
|
||||||
const result = await emailService.resendFromLog(id);
|
|
||||||
|
|
||||||
if (!result.success && result.error === 'Email log not found') {
|
|
||||||
return c.json({ error: 'Email log not found' }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.success && result.error === 'Email log missing required data to resend') {
|
|
||||||
return c.json({ error: result.error }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ success: result.success, error: result.error });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get email stats
|
// Get email stats
|
||||||
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const provider = c.req.query('provider');
|
const provider = c.req.query('provider');
|
||||||
const pendingApproval = c.req.query('pendingApproval');
|
const pendingApproval = c.req.query('pendingApproval');
|
||||||
const eventId = c.req.query('eventId');
|
|
||||||
const eventIds = c.req.query('eventIds');
|
|
||||||
|
|
||||||
// Get all payments with their associated tickets
|
// Get all payments with their associated tickets
|
||||||
let allPayments = await dbAll<any>(
|
let allPayments = await dbAll<any>(
|
||||||
@@ -57,7 +55,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enrich with ticket and event data
|
// Enrich with ticket and event data
|
||||||
let enrichedPayments = await Promise.all(
|
const enrichedPayments = await Promise.all(
|
||||||
allPayments.map(async (payment: any) => {
|
allPayments.map(async (payment: any) => {
|
||||||
const ticket = await dbGet<any>(
|
const ticket = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -96,16 +94,6 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter by event(s)
|
|
||||||
if (eventId) {
|
|
||||||
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
|
|
||||||
} else if (eventIds) {
|
|
||||||
const ids = eventIds.split(',').map((s: string) => s.trim()).filter(Boolean);
|
|
||||||
if (ids.length > 0) {
|
|
||||||
enrichedPayments = enrichedPayments.filter((p: any) => p.event && ids.includes(p.event.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ payments: enrichedPayments });
|
return c.json({ payments: enrichedPayments });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -200,13 +200,6 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
|
|||||||
if (event.status !== 'published') {
|
if (event.status !== 'published') {
|
||||||
return c.json({ error: 'Event must be published to be featured' }, 400);
|
return c.json({ error: 'Event must be published to be featured' }, 400);
|
||||||
}
|
}
|
||||||
const eventEndTime = event.endDatetime || event.startDatetime;
|
|
||||||
if (new Date(eventEndTime).getTime() <= Date.now()) {
|
|
||||||
return c.json(
|
|
||||||
{ error: 'Cannot feature an event that has already ended' },
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create settings
|
// Get or create settings
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
||||||
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
import { eq, and, or, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||||
@@ -1394,7 +1394,7 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all tickets (admin) - includes payment for each ticket
|
// Get all tickets (admin)
|
||||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
@@ -1413,25 +1413,9 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
query = query.where(and(...conditions));
|
query = query.where(and(...conditions));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticketsList = await dbAll(query);
|
const result = await dbAll(query);
|
||||||
const ticketIds = ticketsList.map((t: any) => t.id);
|
|
||||||
|
|
||||||
let paymentByTicketId: Record<string, any> = {};
|
return c.json({ tickets: result });
|
||||||
if (ticketIds.length > 0) {
|
|
||||||
const paymentsList = await dbAll(
|
|
||||||
(db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))
|
|
||||||
);
|
|
||||||
for (const p of paymentsList as any[]) {
|
|
||||||
paymentByTicketId[p.ticketId] = p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticketsWithPayment = ticketsList.map((t: any) => ({
|
|
||||||
...t,
|
|
||||||
payment: paymentByTicketId[t.id] || null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return c.json({ tickets: ticketsWithPayment });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ticketsRouter;
|
export default ticketsRouter;
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ NEXT_PUBLIC_TIKTOK=spanglishsocialpy
|
|||||||
# Must match the REVALIDATE_SECRET in backend/.env
|
# Must match the REVALIDATE_SECRET in backend/.env
|
||||||
REVALIDATE_SECRET=change-me-to-a-random-secret
|
REVALIDATE_SECRET=change-me-to-a-random-secret
|
||||||
|
|
||||||
# Next event cache revalidation (seconds) - homepage metadata/social preview refresh interval. Default: 3600
|
|
||||||
NEXT_EVENT_REVALIDATE_SECONDS=3600
|
|
||||||
|
|
||||||
# Plausible Analytics (optional - leave empty to disable tracking)
|
# Plausible Analytics (optional - leave empty to disable tracking)
|
||||||
NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net
|
NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net
|
||||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com
|
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
@@ -38,10 +38,8 @@ interface NextEvent {
|
|||||||
|
|
||||||
async function getNextUpcomingEvent(): Promise<NextEvent | null> {
|
async function getNextUpcomingEvent(): Promise<NextEvent | null> {
|
||||||
try {
|
try {
|
||||||
const revalidateSeconds =
|
|
||||||
parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600;
|
|
||||||
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
||||||
next: { tags: ['next-event'], revalidate: revalidateSeconds },
|
next: { tags: ['next-event'] },
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
@@ -60,12 +60,18 @@ export default function AdminBookingsPage() {
|
|||||||
eventsApi.getAll(),
|
eventsApi.getAll(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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(ticketsWithEvent);
|
setTickets(ticketsWithDetails);
|
||||||
setEvents(eventsRes.events);
|
setEvents(eventsRes.events);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load bookings');
|
toast.error('Failed to load bookings');
|
||||||
@@ -147,25 +153,13 @@ export default function AdminBookingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPaymentMethodLabel = (provider: string | null) => {
|
const getPaymentMethodLabel = (provider: string) => {
|
||||||
if (provider == null) return '—';
|
switch (provider) {
|
||||||
const labels: Record<string, string> = {
|
case 'bancard': return 'TPago / Card';
|
||||||
cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
|
case 'lightning': return 'Bitcoin Lightning';
|
||||||
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
|
case 'cash': return 'Cash at Event';
|
||||||
lightning: 'Lightning',
|
default: return provider;
|
||||||
tpago: 'TPago',
|
|
||||||
bancard: 'Bancard',
|
|
||||||
};
|
|
||||||
return labels[provider] || provider;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDisplayProvider = (ticket: TicketWithDetails): string | null => {
|
|
||||||
if (ticket.payment?.provider) return ticket.payment.provider;
|
|
||||||
if (ticket.bookingId) {
|
|
||||||
const sibling = tickets.find((t) => t.bookingId === ticket.bookingId && t.payment?.provider);
|
|
||||||
return sibling?.payment?.provider ?? null;
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTickets = tickets.filter((ticket) => {
|
const filteredTickets = tickets.filter((ticket) => {
|
||||||
@@ -400,7 +394,7 @@ export default function AdminBookingsPage() {
|
|||||||
<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-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||||
{ticket.payment?.status || 'pending'}
|
{ticket.payment?.status || 'pending'}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(getDisplayProvider(ticket))}</p>
|
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}</p>
|
||||||
{ticket.payment && (
|
{ticket.payment && (
|
||||||
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
|
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
ArrowPathIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -53,8 +52,6 @@ export default function AdminEmailsPage() {
|
|||||||
const [logs, setLogs] = useState<EmailLog[]>([]);
|
const [logs, setLogs] = useState<EmailLog[]>([]);
|
||||||
const [logsOffset, setLogsOffset] = useState(0);
|
const [logsOffset, setLogsOffset] = useState(0);
|
||||||
const [logsTotal, setLogsTotal] = useState(0);
|
const [logsTotal, setLogsTotal] = useState(0);
|
||||||
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
|
|
||||||
const [resendingLogId, setResendingLogId] = useState<string | null>(null);
|
|
||||||
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
||||||
|
|
||||||
// Stats state
|
// Stats state
|
||||||
@@ -217,7 +214,7 @@ export default function AdminEmailsPage() {
|
|||||||
if (activeTab === 'logs') {
|
if (activeTab === 'logs') {
|
||||||
loadLogs();
|
loadLogs();
|
||||||
}
|
}
|
||||||
}, [activeTab, logsOffset, logsSubTab]);
|
}, [activeTab, logsOffset]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -236,11 +233,7 @@ export default function AdminEmailsPage() {
|
|||||||
|
|
||||||
const loadLogs = async () => {
|
const loadLogs = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await emailsApi.getLogs({
|
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset });
|
||||||
limit: 20,
|
|
||||||
offset: logsOffset,
|
|
||||||
...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
|
|
||||||
});
|
|
||||||
setLogs(res.logs);
|
setLogs(res.logs);
|
||||||
setLogsTotal(res.pagination.total);
|
setLogsTotal(res.pagination.total);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -248,27 +241,6 @@ export default function AdminEmailsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResend = async (log: EmailLog) => {
|
|
||||||
setResendingLogId(log.id);
|
|
||||||
try {
|
|
||||||
const res = await emailsApi.resendLog(log.id);
|
|
||||||
if (res.success) {
|
|
||||||
toast.success('Email re-sent successfully');
|
|
||||||
} else {
|
|
||||||
toast.error(res.error || 'Failed to re-send email');
|
|
||||||
}
|
|
||||||
await loadLogs();
|
|
||||||
if (selectedLog?.id === log.id) {
|
|
||||||
const { log: updatedLog } = await emailsApi.getLog(log.id);
|
|
||||||
setSelectedLog(updatedLog);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to re-send email');
|
|
||||||
} finally {
|
|
||||||
setResendingLogId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetTemplateForm = () => {
|
const resetTemplateForm = () => {
|
||||||
setTemplateForm({
|
setTemplateForm({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -727,35 +699,6 @@ export default function AdminEmailsPage() {
|
|||||||
{/* Logs Tab */}
|
{/* Logs Tab */}
|
||||||
{activeTab === 'logs' && (
|
{activeTab === 'logs' && (
|
||||||
<div>
|
<div>
|
||||||
{/* Sub-tabs: All | Failed */}
|
|
||||||
<div className="border-b border-secondary-light-gray mb-4">
|
|
||||||
<nav className="flex gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => { setLogsSubTab('all'); setLogsOffset(0); }}
|
|
||||||
className={clsx(
|
|
||||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
|
||||||
logsSubTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setLogsSubTab('failed'); setLogsOffset(0); }}
|
|
||||||
className={clsx(
|
|
||||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
|
||||||
logsSubTab === 'failed' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Failed
|
|
||||||
{stats && stats.failed > 0 && (
|
|
||||||
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700">
|
|
||||||
{stats.failed}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: Table */}
|
{/* Desktop: Table */}
|
||||||
<Card className="overflow-hidden hidden md:block">
|
<Card className="overflow-hidden hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -771,17 +714,12 @@ export default function AdminEmailsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-light-gray">
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No emails sent yet</td></tr>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<tr key={log.id} className="hover:bg-gray-50">
|
<tr key={log.id} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div>
|
||||||
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div>
|
|
||||||
{(log.resendAttempts ?? 0) > 0 && (
|
|
||||||
<span className="text-xs text-gray-500">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<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>
|
||||||
@@ -790,15 +728,7 @@ export default function AdminEmailsPage() {
|
|||||||
<td className="px-4 py-3 max-w-xs"><p className="text-sm truncate">{log.subject}</p></td>
|
<td className="px-4 py-3 max-w-xs"><p className="text-sm truncate">{log.subject}</p></td>
|
||||||
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td>
|
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end">
|
||||||
<button
|
|
||||||
onClick={() => handleResend(log)}
|
|
||||||
disabled={resendingLogId === log.id}
|
|
||||||
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">
|
<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>
|
||||||
@@ -828,7 +758,7 @@ export default function AdminEmailsPage() {
|
|||||||
{/* Mobile: Card List */}
|
{/* Mobile: Card List */}
|
||||||
<div className="md:hidden space-y-2">
|
<div className="md:hidden space-y-2">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="text-center py-10 text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
|
<div className="text-center py-10 text-gray-500 text-sm">No emails sent yet</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
|
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
|
||||||
@@ -838,18 +768,7 @@ export default function AdminEmailsPage() {
|
|||||||
<p className="font-medium text-sm truncate">{log.subject}</p>
|
<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-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>
|
<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>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
@@ -1019,26 +938,12 @@ export default function AdminEmailsPage() {
|
|||||||
{selectedLog.errorMessage && (
|
{selectedLog.errorMessage && (
|
||||||
<span className="text-xs 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>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<button onClick={() => setSelectedLog(null)}
|
||||||
<button
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center flex-shrink-0">
|
||||||
onClick={() => handleResend(selectedLog)}
|
<XMarkIcon className="w-5 h-5" />
|
||||||
disabled={resendingLogId === selectedLog.id}
|
</button>
|
||||||
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>
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
} 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';
|
||||||
import { useStatsPrivacy } from '@/hooks/useStatsPrivacy';
|
|
||||||
|
|
||||||
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
||||||
|
|
||||||
@@ -69,7 +68,7 @@ export default function AdminEventDetailPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
||||||
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
||||||
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
||||||
const [showStats, setShowStats, toggleStats] = useStatsPrivacy();
|
const [showStats, setShowStats] = useState(true);
|
||||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||||
const [noteText, setNoteText] = useState('');
|
const [noteText, setNoteText] = useState('');
|
||||||
@@ -577,10 +576,6 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Desktop header actions */}
|
{/* Desktop header actions */}
|
||||||
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
||||||
<Button variant="outline" size="sm" onClick={toggleStats} title={showStats ? 'Hide stats' : 'Show stats'}>
|
|
||||||
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
|
|
||||||
{showStats ? 'Hide Stats' : 'Show Stats'}
|
|
||||||
</Button>
|
|
||||||
<Link href={`/events/${event.id}`} target="_blank">
|
<Link href={`/events/${event.id}`} target="_blank">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||||||
@@ -611,7 +606,7 @@ export default function AdminEventDetailPage() {
|
|||||||
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
|
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
|
||||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem onClick={() => { toggleStats(); setMobileHeaderMenuOpen(false); }}>
|
<DropdownItem onClick={() => { setShowStats(v => !v); setMobileHeaderMenuOpen(false); }}>
|
||||||
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
|
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
|
||||||
{showStats ? 'Hide Stats' : 'Show Stats'}
|
{showStats ? 'Hide Stats' : 'Show Stats'}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
@@ -633,12 +628,10 @@ export default function AdminEventDetailPage() {
|
|||||||
<CurrencyDollarIcon className="w-3.5 h-3.5" />
|
<CurrencyDollarIcon className="w-3.5 h-3.5" />
|
||||||
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
||||||
</span>
|
</span>
|
||||||
{showStats && (
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
<UsersIcon className="w-3.5 h-3.5" />
|
||||||
<UsersIcon className="w-3.5 h-3.5" />
|
{confirmedCount + checkedInCount}/{event.capacity}
|
||||||
{confirmedCount + checkedInCount}/{event.capacity}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ============= STATS ROW ============= */}
|
{/* ============= STATS ROW ============= */}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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 { 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';
|
||||||
@@ -16,7 +16,6 @@ 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 searchParams = useSearchParams();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
@@ -459,11 +458,7 @@ export default function AdminEventsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
events.map((event) => (
|
events.map((event) => (
|
||||||
<tr
|
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
|
||||||
key={event.id}
|
|
||||||
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">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{event.bannerUrl ? (
|
{event.bannerUrl ? (
|
||||||
@@ -497,7 +492,7 @@ export default function AdminEventsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
<td className="px-4 py-3">
|
||||||
<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 size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
|
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
|
||||||
@@ -566,11 +561,7 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
events.map((event) => (
|
events.map((event) => (
|
||||||
<Card
|
<Card key={event.id} className={clsx("p-3", featuredEventId === event.id && "ring-2 ring-amber-300")}>
|
||||||
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">
|
<div className="flex items-start gap-3">
|
||||||
{event.bannerUrl ? (
|
{event.bannerUrl ? (
|
||||||
<img src={event.bannerUrl} alt={event.title}
|
<img src={event.bannerUrl} alt={event.title}
|
||||||
@@ -599,7 +590,7 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
<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>
|
<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()}>
|
<div className="flex items-center gap-1">
|
||||||
<Link href={`/admin/events/${event.id}`}
|
<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">
|
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" />
|
<EyeIcon className="w-4 h-4" />
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
FunnelIcon,
|
FunnelIcon,
|
||||||
MagnifyingGlassIcon,
|
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
@@ -39,8 +38,6 @@ export default function AdminPaymentsPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
const [providerFilter, setProviderFilter] = useState<string>('');
|
const [providerFilter, setProviderFilter] = useState<string>('');
|
||||||
const [eventFilter, setEventFilter] = useState<string[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
@@ -62,7 +59,7 @@ export default function AdminPaymentsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [statusFilter, providerFilter, eventFilter]);
|
}, [statusFilter, providerFilter]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -71,8 +68,7 @@ export default function AdminPaymentsPage() {
|
|||||||
paymentsApi.getPendingApproval(),
|
paymentsApi.getPendingApproval(),
|
||||||
paymentsApi.getAll({
|
paymentsApi.getAll({
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
provider: providerFilter || undefined,
|
provider: providerFilter || undefined
|
||||||
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
|
|
||||||
}),
|
}),
|
||||||
eventsApi.getAll(),
|
eventsApi.getAll(),
|
||||||
]);
|
]);
|
||||||
@@ -755,40 +751,11 @@ export default function AdminPaymentsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* All Payments Tab */}
|
{/* All Payments Tab */}
|
||||||
{activeTab === 'all' && (() => {
|
{activeTab === 'all' && (
|
||||||
const q = searchQuery.trim().toLowerCase();
|
|
||||||
const filteredPayments = q
|
|
||||||
? payments.filter((p) => {
|
|
||||||
const name = `${p.ticket?.attendeeFirstName || ''} ${p.ticket?.attendeeLastName || ''}`.trim().toLowerCase();
|
|
||||||
const email = (p.ticket?.attendeeEmail || '').toLowerCase();
|
|
||||||
const phone = (p.ticket?.attendeePhone || '').toLowerCase();
|
|
||||||
const eventTitle = (p.event?.title || '').toLowerCase();
|
|
||||||
const payerName = (p.payerName || '').toLowerCase();
|
|
||||||
const reference = (p.reference || '').toLowerCase();
|
|
||||||
const id = (p.id || '').toLowerCase();
|
|
||||||
return name.includes(q) || email.includes(q) || phone.includes(q) ||
|
|
||||||
eventTitle.includes(q) || payerName.includes(q) || reference.includes(q) || id.includes(q);
|
|
||||||
})
|
|
||||||
: payments;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
{/* Desktop Filters */}
|
{/* Desktop Filters */}
|
||||||
<Card className="p-4 mb-6 hidden md:block">
|
<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 value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
@@ -812,65 +779,20 @@ export default function AdminPaymentsPage() {
|
|||||||
<option value="tpago">TPago</option>
|
<option value="tpago">TPago</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-[200px]">
|
|
||||||
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Evento(s)' : 'Event(s)'}</label>
|
|
||||||
<select
|
|
||||||
value=""
|
|
||||||
onChange={(e) => {
|
|
||||||
const id = e.target.value;
|
|
||||||
if (id && !eventFilter.includes(id)) setEventFilter([...eventFilter, id]);
|
|
||||||
e.target.value = '';
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray w-full text-sm"
|
|
||||||
>
|
|
||||||
<option value="">{locale === 'es' ? 'Agregar evento...' : 'Add event...'}</option>
|
|
||||||
{events.filter(e => !eventFilter.includes(e.id)).map((event) => (
|
|
||||||
<option key={event.id} value={event.id}>{event.title}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{eventFilter.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
||||||
{eventFilter.map((id) => {
|
|
||||||
const ev = events.find(e => e.id === id);
|
|
||||||
return (
|
|
||||||
<span key={id} className="inline-flex items-center gap-1 px-2 py-0.5 bg-primary-yellow/20 rounded text-xs">
|
|
||||||
{ev?.title || id}
|
|
||||||
<button type="button" onClick={() => setEventFilter(eventFilter.filter(x => x !== id))} className="hover:text-red-600">×</button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<button type="button" onClick={() => setEventFilter([])} className="text-xs text-gray-500 hover:text-primary-dark">
|
|
||||||
{locale === 'es' ? 'Limpiar' : 'Clear'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Mobile Search & Filter Toolbar */}
|
{/* Mobile Filter Toolbar */}
|
||||||
<div className="md:hidden mb-4 space-y-2">
|
<div className="md:hidden mb-4 flex items-center gap-2">
|
||||||
<div className="relative">
|
<button onClick={() => setMobileFilterOpen(true)}
|
||||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
className={clsx('flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||||||
<input
|
(statusFilter || providerFilter) ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600')}>
|
||||||
type="text"
|
<FunnelIcon className="w-4 h-4" /> Filters
|
||||||
placeholder={locale === 'es' ? 'Nombre, email, evento...' : 'Name, email, event...'}
|
</button>
|
||||||
value={searchQuery}
|
{(statusFilter || providerFilter) && (
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
<button onClick={() => { setStatusFilter(''); setProviderFilter(''); }}
|
||||||
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"
|
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
|
||||||
/>
|
)}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: Table */}
|
{/* Desktop: Table */}
|
||||||
@@ -888,10 +810,10 @@ export default function AdminPaymentsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-light-gray">
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
{filteredPayments.length === 0 ? (
|
{payments.length === 0 ? (
|
||||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr>
|
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr>
|
||||||
) : (
|
) : (
|
||||||
filteredPayments.map((payment) => {
|
payments.map((payment) => {
|
||||||
const bookingInfo = getBookingInfo(payment);
|
const bookingInfo = getBookingInfo(payment);
|
||||||
return (
|
return (
|
||||||
<tr key={payment.id} className="hover:bg-gray-50">
|
<tr key={payment.id} className="hover:bg-gray-50">
|
||||||
@@ -936,18 +858,13 @@ 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 */}
|
{/* Mobile: Card List */}
|
||||||
<div className="md:hidden space-y-2">
|
<div className="md:hidden space-y-2">
|
||||||
{filteredPayments.length === 0 ? (
|
{payments.length === 0 ? (
|
||||||
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</div>
|
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</div>
|
||||||
) : (
|
) : (
|
||||||
filteredPayments.map((payment) => {
|
payments.map((payment) => {
|
||||||
const bookingInfo = getBookingInfo(payment);
|
const bookingInfo = getBookingInfo(payment);
|
||||||
return (
|
return (
|
||||||
<Card key={payment.id} className="p-3">
|
<Card key={payment.id} className="p-3">
|
||||||
@@ -994,25 +911,6 @@ export default function AdminPaymentsPage() {
|
|||||||
{/* Mobile Filter BottomSheet */}
|
{/* Mobile Filter BottomSheet */}
|
||||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title={locale === 'es' ? 'Filtros' : 'Filters'}>
|
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title={locale === 'es' ? 'Filtros' : 'Filters'}>
|
||||||
<div className="space-y-4">
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
@@ -1037,14 +935,13 @@ export default function AdminPaymentsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-2">
|
<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 variant="outline" onClick={() => { setStatusFilter(''); setProviderFilter(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
|
||||||
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
|
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
</>
|
</>
|
||||||
);
|
)}
|
||||||
})()}
|
|
||||||
|
|
||||||
<AdminMobileStyles />
|
<AdminMobileStyles />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
|
ChatBubbleLeftRightIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
export default function LinktreePage() {
|
export default function LinktreePage() {
|
||||||
@@ -59,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 rounded-full overflow-hidden flex items-center justify-center mb-4 shadow-lg bg-white">
|
<div className="w-24 h-24 mx-auto bg-primary-yellow rounded-full flex items-center justify-center mb-4 shadow-lg">
|
||||||
<Image src="/images/spanglish-icon.png" alt="Spanglish" width={96} height={96} className="object-contain" />
|
<ChatBubbleLeftRightIcon className="w-12 h-12 text-primary-dark" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-white">Spanglish</h1>
|
<h1 className="text-2xl font-bold text-white">Spanglish</h1>
|
||||||
<p className="text-gray-400 mt-1">{t('linktree.tagline')}</p>
|
<p className="text-gray-400 mt-1">{t('linktree.tagline')}</p>
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
|
|
||||||
|
|
||||||
export function useStatsPrivacy() {
|
|
||||||
const [showStats, setShowStatsState] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (stored !== null) {
|
|
||||||
setShowStatsState(stored !== 'true');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setShowStats = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
|
|
||||||
setShowStatsState((prev) => {
|
|
||||||
const next = typeof value === 'function' ? value(prev) : value;
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem(STORAGE_KEY, String(!next));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleStats = useCallback(() => {
|
|
||||||
setShowStats((prev) => !prev);
|
|
||||||
}, [setShowStats]);
|
|
||||||
|
|
||||||
return [showStats, setShowStats, toggleStats] as const;
|
|
||||||
}
|
|
||||||
@@ -236,13 +236,11 @@ export const usersApi = {
|
|||||||
|
|
||||||
// Payments API
|
// Payments API
|
||||||
export const paymentsApi = {
|
export const paymentsApi = {
|
||||||
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
|
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean }) => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.status) query.set('status', params.status);
|
if (params?.status) query.set('status', params.status);
|
||||||
if (params?.provider) query.set('provider', params.provider);
|
if (params?.provider) query.set('provider', params.provider);
|
||||||
if (params?.pendingApproval) query.set('pendingApproval', 'true');
|
if (params?.pendingApproval) query.set('pendingApproval', 'true');
|
||||||
if (params?.eventId) query.set('eventId', params.eventId);
|
|
||||||
if (params?.eventIds && params.eventIds.length > 0) query.set('eventIds', params.eventIds.join(','));
|
|
||||||
return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`);
|
return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -493,11 +491,6 @@ export const emailsApi = {
|
|||||||
|
|
||||||
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
|
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
|
||||||
|
|
||||||
resendLog: (id: string) =>
|
|
||||||
fetchApi<{ success: boolean; error?: string }>(`/api/emails/logs/${id}/resend`, {
|
|
||||||
method: 'POST',
|
|
||||||
}),
|
|
||||||
|
|
||||||
getStats: (eventId?: string) => {
|
getStats: (eventId?: string) => {
|
||||||
const query = eventId ? `?eventId=${eventId}` : '';
|
const query = eventId ? `?eventId=${eventId}` : '';
|
||||||
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
|
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
|
||||||
@@ -797,8 +790,6 @@ export interface EmailLog {
|
|||||||
sentAt?: string;
|
sentAt?: string;
|
||||||
sentBy?: string;
|
sentBy?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
resendAttempts?: number;
|
|
||||||
lastResentAt?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailStats {
|
export interface EmailStats {
|
||||||
|
|||||||
@@ -15,9 +15,7 @@
|
|||||||
"start:frontend": "npm run start --workspace=frontend",
|
"start:frontend": "npm run start --workspace=frontend",
|
||||||
"db:generate": "npm run db:generate --workspace=backend",
|
"db:generate": "npm run db:generate --workspace=backend",
|
||||||
"db:migrate": "npm run db:migrate --workspace=backend",
|
"db:migrate": "npm run db:migrate --workspace=backend",
|
||||||
"db:studio": "npm run db:studio --workspace=backend",
|
"db:studio": "npm run db:studio --workspace=backend"
|
||||||
"db:export": "npm run db:export --workspace=backend --",
|
|
||||||
"db:import": "npm run db:import --workspace=backend --"
|
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"backend",
|
"backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user