Compare commits
11 Commits
b5f14335c4
...
1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 15655e3987 | |||
|
|
5263fa6834 | ||
|
|
923c86a3b3 | ||
| d8b3864411 | |||
|
|
4aaffe99c7 | ||
| 194cbd6ca8 | |||
|
|
a11da5a977 | ||
| d5445c2282 | |||
|
|
6bc7e13e78 | ||
| dcfefc8371 | |||
|
|
c3897efd02 |
@@ -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
|
||||||
|
|||||||
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' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -9,6 +9,7 @@ import {
|
|||||||
QrCodeIcon,
|
QrCodeIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
|
XMarkIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
@@ -76,86 +77,135 @@ function vibrate(pattern: number | number[]) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Stop all tracks on a media stream ───────────────────────
|
||||||
|
function stopAllTracks() {
|
||||||
|
try {
|
||||||
|
// Find all video elements and stop their streams
|
||||||
|
document.querySelectorAll('video').forEach((video) => {
|
||||||
|
const stream = video.srcObject as MediaStream | null;
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
video.srcObject = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── QR Scanner Component ────────────────────────────────────
|
// ─── QR Scanner Component ────────────────────────────────────
|
||||||
|
// This component fully mounts/unmounts — use a key prop externally
|
||||||
|
// to force a fresh instance when the scan tab becomes active.
|
||||||
function QRScanner({
|
function QRScanner({
|
||||||
onScan,
|
onScan,
|
||||||
isActive,
|
onError,
|
||||||
onActiveChange,
|
|
||||||
}: {
|
}: {
|
||||||
onScan: (code: string) => void;
|
onScan: (code: string) => void;
|
||||||
isActive: boolean;
|
onError: () => void;
|
||||||
onActiveChange: (active: boolean) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scannerRef = useRef<any>(null);
|
const scannerRef = useRef<any>(null);
|
||||||
const scannerElementId = useRef(`qr-scanner-${Math.random().toString(36).substr(2, 9)}`);
|
const mountedRef = useRef(true);
|
||||||
|
const elementId = useRef(`qr-scanner-${Date.now()}`);
|
||||||
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
|
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Full cleanup helper
|
||||||
if (containerRef.current && !document.getElementById(scannerElementId.current)) {
|
const destroyScanner = useCallback(async () => {
|
||||||
const scannerDiv = document.createElement('div');
|
|
||||||
scannerDiv.id = scannerElementId.current;
|
|
||||||
scannerDiv.style.width = '100%';
|
|
||||||
containerRef.current.appendChild(scannerDiv);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (scannerRef.current) {
|
if (scannerRef.current) {
|
||||||
try { scannerRef.current.stop().catch(() => {}); } catch {}
|
try { await scannerRef.current.stop(); } catch {}
|
||||||
|
try { scannerRef.current.clear(); } catch {}
|
||||||
scannerRef.current = null;
|
scannerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
stopAllTracks();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Start scanner on mount, destroy on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const startScanner = async () => {
|
const init = async () => {
|
||||||
const elementId = scannerElementId.current;
|
const container = containerRef.current;
|
||||||
const element = document.getElementById(elementId);
|
if (!container) return;
|
||||||
if (!element) return;
|
|
||||||
|
// Create a fresh div for the scanner
|
||||||
|
const id = elementId.current;
|
||||||
|
container.innerHTML = '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.id = id;
|
||||||
|
div.style.width = '100%';
|
||||||
|
div.style.height = '100%';
|
||||||
|
container.appendChild(div);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { Html5Qrcode } = await import('html5-qrcode');
|
const { Html5Qrcode } = await import('html5-qrcode');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (scannerRef.current) {
|
|
||||||
try { await scannerRef.current.stop(); } catch {}
|
|
||||||
scannerRef.current = null;
|
|
||||||
}
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const scanner = new Html5Qrcode(elementId);
|
const scanner = new Html5Qrcode(id);
|
||||||
scannerRef.current = scanner;
|
scannerRef.current = scanner;
|
||||||
|
|
||||||
await scanner.start(
|
await scanner.start(
|
||||||
{ facingMode },
|
{ facingMode },
|
||||||
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 },
|
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 },
|
||||||
(decodedText: string) => onScan(decodedText),
|
(decodedText: string) => {
|
||||||
|
if (mountedRef.current) onScan(decodedText);
|
||||||
|
},
|
||||||
() => {}
|
() => {}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
await destroyScanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force layout recalculation after camera starts
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (container) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
container.offsetHeight; // force reflow
|
||||||
|
container.style.display = '';
|
||||||
|
}
|
||||||
|
if (mountedRef.current) setReady(true);
|
||||||
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Scanner error:', error);
|
console.error('Scanner error:', error);
|
||||||
if (!cancelled) {
|
if (!cancelled && mountedRef.current) {
|
||||||
toast.error('Failed to start camera. Check permissions.');
|
toast.error('Failed to start camera. Check permissions.');
|
||||||
onActiveChange(false);
|
onError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopScanner = async () => {
|
init();
|
||||||
if (scannerRef.current) {
|
|
||||||
try { await scannerRef.current.stop(); } catch {}
|
return () => {
|
||||||
scannerRef.current = null;
|
cancelled = true;
|
||||||
|
mountedRef.current = false;
|
||||||
|
destroyScanner();
|
||||||
|
};
|
||||||
|
}, [facingMode]); // restart when camera flips
|
||||||
|
|
||||||
|
// Handle browser visibility change (suspend/resume)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
destroyScanner();
|
||||||
|
} else if (document.visibilityState === 'visible' && mountedRef.current) {
|
||||||
|
// Re-trigger by flipping facingMode back-and-forth (forces useEffect re-run)
|
||||||
|
setFacingMode((prev) => {
|
||||||
|
// Toggle and toggle back to trigger the effect
|
||||||
|
const temp = prev === 'environment' ? 'user' : 'environment';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (mountedRef.current) setFacingMode(prev);
|
||||||
|
}, 100);
|
||||||
|
return temp;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isActive) {
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
startScanner();
|
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
} else {
|
}, [destroyScanner]);
|
||||||
stopScanner();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [isActive, facingMode, onScan, onActiveChange]);
|
|
||||||
|
|
||||||
const switchCamera = () => {
|
const switchCamera = () => {
|
||||||
setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'));
|
setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'));
|
||||||
@@ -163,8 +213,11 @@ function QRScanner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full bg-black flex-1 min-h-0 overflow-hidden">
|
<div className="relative w-full bg-black flex-1 min-h-0 overflow-hidden">
|
||||||
<div ref={containerRef} className="w-full h-full [&_video]:!object-cover [&_video]:!h-full" />
|
<div
|
||||||
{isActive && (
|
ref={containerRef}
|
||||||
|
className="w-full h-full [&_video]:!object-cover [&_video]:!h-full [&_video]:!w-full"
|
||||||
|
/>
|
||||||
|
{ready && (
|
||||||
<button
|
<button
|
||||||
onClick={switchCamera}
|
onClick={switchCamera}
|
||||||
className="absolute top-3 right-3 z-10 bg-black/50 backdrop-blur-sm text-white p-2.5 rounded-full active:scale-95 transition-transform"
|
className="absolute top-3 right-3 z-10 bg-black/50 backdrop-blur-sm text-white p-2.5 rounded-full active:scale-95 transition-transform"
|
||||||
@@ -173,7 +226,7 @@ function QRScanner({
|
|||||||
<VideoCameraIcon className="w-5 h-5" />
|
<VideoCameraIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!isActive && (
|
{!ready && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
|
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
|
||||||
@@ -189,15 +242,27 @@ function QRScanner({
|
|||||||
function ValidTicketScreen({
|
function ValidTicketScreen({
|
||||||
validation,
|
validation,
|
||||||
onConfirmCheckin,
|
onConfirmCheckin,
|
||||||
|
onClose,
|
||||||
checkingIn,
|
checkingIn,
|
||||||
}: {
|
}: {
|
||||||
validation: TicketValidationResult;
|
validation: TicketValidationResult;
|
||||||
onConfirmCheckin: () => void;
|
onConfirmCheckin: () => void;
|
||||||
|
onClose: () => void;
|
||||||
checkingIn: boolean;
|
checkingIn: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-emerald-600 flex flex-col animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-50 bg-emerald-600 flex flex-col animate-in fade-in duration-200">
|
||||||
<div className="flex-1 flex flex-col items-center justify-center px-6 text-white">
|
{/* Close button (dismiss without check-in) */}
|
||||||
|
<div className="flex justify-end p-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-full bg-white/20 text-white active:scale-95 transition-transform"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center px-6 -mt-14 text-white">
|
||||||
<div className="w-24 h-24 rounded-full bg-white/20 flex items-center justify-center mb-6">
|
<div className="w-24 h-24 rounded-full bg-white/20 flex items-center justify-center mb-6">
|
||||||
<CheckCircleIcon className="w-16 h-16 text-white" />
|
<CheckCircleIcon className="w-16 h-16 text-white" />
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +646,7 @@ export default function AdminScannerPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<ActiveTab>('scan');
|
const [activeTab, setActiveTab] = useState<ActiveTab>('scan');
|
||||||
|
|
||||||
// Scanner state
|
// Scanner state
|
||||||
const [cameraActive, setCameraActive] = useState(false);
|
const [scannerKey, setScannerKey] = useState(0); // increment to force remount
|
||||||
const [scanResult, setScanResult] = useState<ScanResultData>({ state: 'idle' });
|
const [scanResult, setScanResult] = useState<ScanResultData>({ state: 'idle' });
|
||||||
const [lastScannedCode, setLastScannedCode] = useState('');
|
const [lastScannedCode, setLastScannedCode] = useState('');
|
||||||
const [checkingIn, setCheckingIn] = useState(false);
|
const [checkingIn, setCheckingIn] = useState(false);
|
||||||
@@ -638,21 +703,8 @@ export default function AdminScannerPage() {
|
|||||||
loadStats();
|
loadStats();
|
||||||
}, [selectedEventId]);
|
}, [selectedEventId]);
|
||||||
|
|
||||||
// Auto-start camera on page load (Scan tab)
|
// When scan tab becomes active again or scan result is dismissed, bump key to remount scanner
|
||||||
useEffect(() => {
|
const scannerActive = activeTab === 'scan' && scanResult.state === 'idle' && !loading;
|
||||||
if (!loading && activeTab === 'scan') {
|
|
||||||
setCameraActive(true);
|
|
||||||
}
|
|
||||||
}, [loading, activeTab]);
|
|
||||||
|
|
||||||
// Pause camera when switching away from scan tab
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab !== 'scan') {
|
|
||||||
setCameraActive(false);
|
|
||||||
} else if (scanResult.state === 'idle') {
|
|
||||||
setCameraActive(true);
|
|
||||||
}
|
|
||||||
}, [activeTab, scanResult.state]);
|
|
||||||
|
|
||||||
// Validate ticket
|
// Validate ticket
|
||||||
const validateTicket = useCallback(async (code: string) => {
|
const validateTicket = useCallback(async (code: string) => {
|
||||||
@@ -690,7 +742,6 @@ export default function AdminScannerPage() {
|
|||||||
if (decodedText === lastScannedCodeRef.current) return;
|
if (decodedText === lastScannedCodeRef.current) return;
|
||||||
lastScannedCodeRef.current = decodedText;
|
lastScannedCodeRef.current = decodedText;
|
||||||
setLastScannedCode(decodedText);
|
setLastScannedCode(decodedText);
|
||||||
setCameraActive(false);
|
|
||||||
|
|
||||||
let code = decodedText;
|
let code = decodedText;
|
||||||
const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/);
|
const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/);
|
||||||
@@ -768,12 +819,12 @@ export default function AdminScannerPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset scan state
|
// Reset scan state — bump key so scanner fully remounts
|
||||||
const resetScan = () => {
|
const resetScan = () => {
|
||||||
setScanResult({ state: 'idle' });
|
setScanResult({ state: 'idle' });
|
||||||
setLastScannedCode('');
|
setLastScannedCode('');
|
||||||
lastScannedCodeRef.current = '';
|
lastScannedCodeRef.current = '';
|
||||||
if (activeTab === 'scan') setCameraActive(true);
|
setScannerKey((k) => k + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get selected event name
|
// Get selected event name
|
||||||
@@ -788,7 +839,7 @@ export default function AdminScannerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-950 flex flex-col h-screen max-h-screen overflow-hidden">
|
<div className="bg-gray-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||||
{/* ── Sticky Header ── */}
|
{/* ── Sticky Header ── */}
|
||||||
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top">
|
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -864,14 +915,17 @@ export default function AdminScannerPage() {
|
|||||||
|
|
||||||
{/* ── Tab Content ── */}
|
{/* ── Tab Content ── */}
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
{/* SCAN TAB */}
|
{/* SCAN TAB — scanner fully unmounts when not active */}
|
||||||
{activeTab === 'scan' && (
|
{scannerActive && (
|
||||||
<QRScanner
|
<QRScanner
|
||||||
isActive={cameraActive && scanResult.state === 'idle'}
|
key={scannerKey}
|
||||||
onScan={handleScan}
|
onScan={handleScan}
|
||||||
onActiveChange={setCameraActive}
|
onError={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'scan' && !scannerActive && scanResult.state !== 'idle' && (
|
||||||
|
<div className="flex-1 bg-black" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* SEARCH TAB */}
|
{/* SEARCH TAB */}
|
||||||
{activeTab === 'search' && (
|
{activeTab === 'search' && (
|
||||||
@@ -889,6 +943,7 @@ export default function AdminScannerPage() {
|
|||||||
<ValidTicketScreen
|
<ValidTicketScreen
|
||||||
validation={scanResult.validation}
|
validation={scanResult.validation}
|
||||||
onConfirmCheckin={handleCheckin}
|
onConfirmCheckin={handleCheckin}
|
||||||
|
onClose={resetScan}
|
||||||
checkingIn={checkingIn}
|
checkingIn={checkingIn}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -372,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
|
||||||
|
|||||||
Reference in New Issue
Block a user