11 Commits

Author SHA1 Message Date
15655e3987 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-02-16 23:11:52 +00:00
Michilis
5263fa6834 Make llms.txt always fetch fresh data from the backend
- Switch from tag-based caching to cache: no-store for all backend fetches
- Add dynamic = force-dynamic to prevent Next.js static caching
- Ensures llms.txt always reflects the current featured event and FAQ data

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 18:27:27 +00:00
dcfefc8371 Merge pull request 'feat(admin): add event attendees export (CSV) with status filters' (#8) from dev into main
Reviewed-on: #8
2026-02-14 05:28:24 +00:00
Michilis
c3897efd02 feat(admin): add event attendees export (CSV) with status filters
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 05:27:17 +00:00
10 changed files with 2047 additions and 1311 deletions

View File

@@ -19,7 +19,7 @@ GOOGLE_CLIENT_ID=
# Server Configuration # Server Configuration
PORT=3001 PORT=3001
API_URL=http://localhost:3001 API_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3002 FRONTEND_URL=http://localhost:3019
# Revalidation secret (shared with frontend for on-demand cache revalidation) # Revalidation secret (shared with frontend for on-demand cache revalidation)
# Must match the REVALIDATE_SECRET in frontend/.env # Must match the REVALIDATE_SECRET in frontend/.env

View File

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

View File

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

View File

@@ -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) {

View File

@@ -5,6 +5,7 @@ import { db, dbGet, siteSettings, events } from '../db/index.js';
import { eq, and, gte } from 'drizzle-orm'; import { eq, and, gte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -172,6 +173,11 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id)) (db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
); );
// Revalidate frontend cache if featured event changed
if (data.featuredEventId !== undefined) {
revalidateFrontendCache();
}
return c.json({ settings: updated, message: 'Settings updated successfully' }); return c.json({ settings: updated, message: 'Settings updated successfully' });
}); });
@@ -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' });
}); });

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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'); if (scannerRef.current) {
scannerDiv.id = scannerElementId.current; try { await scannerRef.current.stop(); } catch {}
scannerDiv.style.width = '100%'; try { scannerRef.current.clear(); } catch {}
containerRef.current.appendChild(scannerDiv); scannerRef.current = null;
} }
return () => { stopAllTracks();
if (scannerRef.current) {
try { scannerRef.current.stop().catch(() => {}); } catch {}
scannerRef.current = null;
}
};
}, []); }, []);
// 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}
/> />
)} )}

View File

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

View File

@@ -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