Compare commits
13 Commits
d44ac949b5
...
1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 15655e3987 | |||
|
|
5263fa6834 | ||
|
|
923c86a3b3 | ||
| d8b3864411 | |||
|
|
4aaffe99c7 | ||
| 194cbd6ca8 | |||
|
|
a11da5a977 | ||
| d5445c2282 | |||
|
|
6bc7e13e78 | ||
| dcfefc8371 | |||
|
|
c3897efd02 | ||
| b5f14335c4 | |||
|
|
62bf048680 |
@@ -19,7 +19,7 @@ GOOGLE_CLIENT_ID=
|
|||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3001
|
PORT=3001
|
||||||
API_URL=http://localhost:3001
|
API_URL=http://localhost:3001
|
||||||
FRONTEND_URL=http://localhost:3002
|
FRONTEND_URL=http://localhost:3019
|
||||||
|
|
||||||
# Revalidation secret (shared with frontend for on-demand cache revalidation)
|
# Revalidation secret (shared with frontend for on-demand cache revalidation)
|
||||||
# Must match the REVALIDATE_SECRET in frontend/.env
|
# Must match the REVALIDATE_SECRET in frontend/.env
|
||||||
@@ -72,3 +72,4 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
|
|||||||
# Maximum number of emails that can be sent per hour (default: 30)
|
# Maximum number of emails that can be sent per hour (default: 30)
|
||||||
# If the limit is reached, queued emails will pause and resume automatically
|
# If the limit is reached, queued emails will pause and resume automatically
|
||||||
MAX_EMAILS_PER_HOUR=30
|
MAX_EMAILS_PER_HOUR=30
|
||||||
|
|
||||||
|
|||||||
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');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, ema
|
|||||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
|
||||||
|
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,29 +16,6 @@ interface UserContext {
|
|||||||
|
|
||||||
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||||
|
|
||||||
// Trigger frontend cache revalidation (fire-and-forget)
|
|
||||||
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
|
|
||||||
function revalidateFrontendCache() {
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
|
||||||
const secret = process.env.REVALIDATE_SECRET;
|
|
||||||
if (!secret) {
|
|
||||||
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch(`${frontendUrl}/api/revalidate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
|
|
||||||
else console.log('Frontend revalidation triggered (sitemap + next-event)');
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('Frontend revalidation error:', err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to normalize event data for API response
|
// Helper to normalize event data for API response
|
||||||
// PostgreSQL decimal returns strings, booleans are stored as integers
|
// PostgreSQL decimal returns strings, booleans are stored as integers
|
||||||
function normalizeEvent(event: any) {
|
function normalizeEvent(event: any) {
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,6 +222,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 +238,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,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
||||||
import { eq, and, sql } from 'drizzle-orm';
|
import { eq, and, or, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||||
@@ -490,6 +490,125 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get event check-in stats for scanner (lightweight endpoint for staff)
|
||||||
|
ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const eventId = c.req.query('eventId');
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
return c.json({ error: 'eventId is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get event info
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count checked-in tickets
|
||||||
|
const checkedInCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
eq((tickets as any).status, 'checked_in')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count confirmed + checked_in (total active)
|
||||||
|
const totalActiveCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
eventId,
|
||||||
|
capacity: event.capacity,
|
||||||
|
checkedIn: checkedInCount?.count || 0,
|
||||||
|
totalActive: totalActiveCount?.count || 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live search tickets (GET - for scanner live search)
|
||||||
|
ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const q = c.req.query('q')?.trim() || '';
|
||||||
|
const eventId = c.req.query('eventId');
|
||||||
|
|
||||||
|
if (q.length < 2) {
|
||||||
|
return c.json({ tickets: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = `%${q.toLowerCase()}%`;
|
||||||
|
|
||||||
|
// Search by name (ILIKE), email (ILIKE), ticket ID (exact or partial)
|
||||||
|
const nameEmailConditions = [
|
||||||
|
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
|
||||||
|
// Ticket ID exact or partial match (cast UUID to text for LOWER)
|
||||||
|
sql`LOWER(CAST(${(tickets as any).id} AS TEXT)) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(CAST(${(tickets as any).qrCode} AS TEXT)) LIKE ${searchTerm}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
let whereClause: any = and(
|
||||||
|
or(...nameEmailConditions),
|
||||||
|
// Exclude cancelled tickets by default
|
||||||
|
sql`${(tickets as any).status} != 'cancelled'`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingTickets = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(whereClause)
|
||||||
|
.limit(20)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enrich with event details
|
||||||
|
const results = await Promise.all(
|
||||||
|
matchingTickets.map(async (ticket: any) => {
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
name: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
|
email: ticket.attendeeEmail,
|
||||||
|
status: ticket.status,
|
||||||
|
checked_in: ticket.status === 'checked_in',
|
||||||
|
checkinAt: ticket.checkinAt,
|
||||||
|
event_id: ticket.eventId,
|
||||||
|
qrCode: ticket.qrCode,
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ tickets: results });
|
||||||
|
});
|
||||||
|
|
||||||
// Get ticket by ID
|
// Get ticket by ID
|
||||||
ticketsRouter.get('/:id', async (c) => {
|
ticketsRouter.get('/:id', async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
@@ -554,6 +673,65 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
|||||||
return c.json({ ticket: updated });
|
return c.json({ ticket: updated });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Search tickets by name/email (for scanner manual search)
|
||||||
|
ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const { query, eventId } = body;
|
||||||
|
|
||||||
|
if (!query || typeof query !== 'string' || query.trim().length < 2) {
|
||||||
|
return c.json({ error: 'Search query must be at least 2 characters' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = `%${query.trim().toLowerCase()}%`;
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
let whereClause = or(...conditions);
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingTickets = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(whereClause)
|
||||||
|
.limit(20)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enrich with event details
|
||||||
|
const results = await Promise.all(
|
||||||
|
matchingTickets.map(async (ticket: any) => {
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: ticket.id,
|
||||||
|
qrCode: ticket.qrCode,
|
||||||
|
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
|
attendeePhone: ticket.attendeePhone,
|
||||||
|
status: ticket.status,
|
||||||
|
checkinAt: ticket.checkinAt,
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ tickets: results });
|
||||||
|
});
|
||||||
|
|
||||||
// Validate ticket by QR code (for scanner)
|
// Validate ticket by QR code (for scanner)
|
||||||
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
const body = await c.req.json().catch(() => ({}));
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -37,14 +37,56 @@ export default function AdminLayout({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const { user, isAdmin, isLoading, logout } = useAuth();
|
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
|
||||||
|
const userRole = (user?.role || 'user') as Role;
|
||||||
|
|
||||||
|
const navigationWithRoles: { name: string; href: string; icon: typeof HomeIcon; allowedRoles: Role[] }[] = [
|
||||||
|
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||||
|
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||||
|
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
|
||||||
|
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
||||||
|
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
|
||||||
|
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
|
||||||
|
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allowedPathsForRole = new Set(
|
||||||
|
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href)
|
||||||
|
);
|
||||||
|
const defaultAdminRoute =
|
||||||
|
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
|
||||||
|
|
||||||
|
// All hooks must be called unconditionally before any early returns
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && (!user || !isAdmin)) {
|
if (!isLoading && (!user || !hasAdminAccess)) {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
}, [user, isAdmin, isLoading, router]);
|
}, [user, hasAdminAccess, isLoading, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !hasAdminAccess) return;
|
||||||
|
if (!pathname.startsWith('/admin')) return;
|
||||||
|
if (pathname === '/admin' && (userRole === 'staff' || userRole === 'marketing')) {
|
||||||
|
router.replace(defaultAdminRoute);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isPathAllowed = (path: string) => {
|
||||||
|
if (allowedPathsForRole.has(path)) return true;
|
||||||
|
return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/'));
|
||||||
|
};
|
||||||
|
if (!isPathAllowed(pathname)) {
|
||||||
|
router.replace(defaultAdminRoute);
|
||||||
|
}
|
||||||
|
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -54,31 +96,29 @@ export default function AdminLayout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !isAdmin) {
|
if (!user || !hasAdminAccess) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigation = [
|
const visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole));
|
||||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
const navigation = visibleNav;
|
||||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
|
|
||||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
|
|
||||||
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon },
|
|
||||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
|
|
||||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
|
|
||||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
|
|
||||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
|
|
||||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
|
|
||||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
|
|
||||||
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon },
|
|
||||||
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon },
|
|
||||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scanner page gets fullscreen layout without sidebar
|
||||||
|
const isScannerPage = pathname === '/admin/scanner';
|
||||||
|
|
||||||
|
if (isScannerPage) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-secondary-gray">
|
<div className="min-h-screen bg-secondary-gray">
|
||||||
{/* Mobile sidebar backdrop */}
|
{/* Mobile sidebar backdrop */}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ interface LlmsEvent {
|
|||||||
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
||||||
next: { tags: ['next-event'] },
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -41,7 +41,7 @@ async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
|
|||||||
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
|
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
|
||||||
next: { tags: ['next-event'] },
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -115,7 +115,7 @@ function getEventStatus(event: LlmsEvent): string {
|
|||||||
async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
|
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
|
||||||
next: { revalidate: 3600 },
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -128,6 +128,8 @@ async function getHomepageFaqs(): Promise<LlmsFaq[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
|
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
|
||||||
getNextUpcomingEvent(),
|
getNextUpcomingEvent(),
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -351,6 +372,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
|
||||||
@@ -508,6 +572,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user