diff --git a/backend/.env.example b/backend/.env.example index b244e74..90332ce 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -72,3 +72,4 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true # Maximum number of emails that can be sent per hour (default: 30) # If the limit is reached, queued emails will pause and resume automatically MAX_EMAILS_PER_HOUR=30 + diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index f6d0809..3b9f92e 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; 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 { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.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( + (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( + (db as any) + .select({ count: sql`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( + (db as any) + .select({ count: sql`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( + (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( + (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 ticketsRouter.get('/:id', async (c) => { const id = c.req.param('id'); @@ -554,6 +673,65 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat 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( + (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( + (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) ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => { const body = await c.req.json().catch(() => ({})); diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 1a78d32..a3efda1 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -20,6 +20,7 @@ import { EnvelopeIcon, PencilIcon, EyeIcon, + EyeSlashIcon, PaperAirplaneIcon, UserGroupIcon, MagnifyingGlassIcon, @@ -63,6 +64,7 @@ export default function AdminEventDetailPage() { const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all'); const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false); const [showManualTicketModal, setShowManualTicketModal] = useState(false); + const [showStats, setShowStats] = useState(true); const [showNoteModal, setShowNoteModal] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); const [noteText, setNoteText] = useState(''); @@ -496,62 +498,84 @@ export default function AdminEventDetailPage() { {/* Stats Cards */} -
- -
-
- -
-
-

{event.capacity}

-

Capacity

-
+
+ {showStats ? ( +
+ +
+
+ +
+
+

{event.capacity}

+

Capacity

+
+
+
+ +
+
+ +
+
+

{confirmedCount}

+

Confirmed

+
+
+
+ +
+
+ +
+
+

{pendingCount}

+

Pending

+
+
+
+ +
+
+ +
+
+

{checkedInCount}

+

Checked In

+
+
+
+ +
+
+ +
+
+

{formatCurrency(confirmedCount * event.price, event.currency)}

+

Revenue

+
+
+
- - -
-
- -
-
-

{confirmedCount}

-

Confirmed

-
-
-
- -
-
- -
-
-

{pendingCount}

-

Pending

-
-
-
- -
-
- -
-
-

{checkedInCount}

-

Checked In

-
-
-
- -
-
- -
-
-

{formatCurrency(confirmedCount * event.price, event.currency)}

-

Revenue

-
-
-
+ ) : null} +
{/* Tabs */} @@ -966,9 +990,16 @@ export default function AdminEventDetailPage() { {/* Add at Door Modal */} {showAddAtDoorModal && ( -
- -
+
setShowAddAtDoorModal(false)} + role="presentation" + > + e.stopPropagation()} + > +

Add Attendee at Door

-
+
@@ -1063,9 +1094,16 @@ export default function AdminEventDetailPage() { {/* Manual Ticket Modal */} {showManualTicketModal && ( -
- -
+
setShowManualTicketModal(false)} + role="presentation" + > + e.stopPropagation()} + > +

Create Manual Ticket

Attendee will receive a confirmation email with their ticket

@@ -1077,7 +1115,7 @@ export default function AdminEventDetailPage() {
- +
diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index d12b155..679a960 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -37,14 +37,56 @@ export default function AdminLayout({ const router = useRouter(); const pathname = usePathname(); const { t, locale } = useLanguage(); - const { user, isAdmin, isLoading, logout } = useAuth(); + const { user, hasAdminAccess, isLoading, logout } = useAuth(); 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(() => { - if (!isLoading && (!user || !isAdmin)) { + if (!isLoading && (!user || !hasAdminAccess)) { 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) { return ( @@ -54,31 +96,29 @@ export default function AdminLayout({ ); } - if (!user || !isAdmin) { + if (!user || !hasAdminAccess) { return null; } - const navigation = [ - { name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon }, - { 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 visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)); + const navigation = visibleNav; const handleLogout = () => { logout(); router.push('/'); }; + // Scanner page gets fullscreen layout without sidebar + const isScannerPage = pathname === '/admin/scanner'; + + if (isScannerPage) { + return ( +
+ {children} +
+ ); + } + return (
{/* Mobile sidebar backdrop */} diff --git a/frontend/src/app/admin/scanner/page.tsx b/frontend/src/app/admin/scanner/page.tsx index 750d633..a8b1a07 100644 --- a/frontend/src/app/admin/scanner/page.tsx +++ b/frontend/src/app/admin/scanner/page.tsx @@ -1,41 +1,87 @@ 'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; -import { ticketsApi, eventsApi, Event, TicketValidationResult } from '@/lib/api'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; +import { useAuth } from '@/context/AuthContext'; +import { ticketsApi, eventsApi, Event, TicketValidationResult, LiveSearchResult } from '@/lib/api'; import { QrCodeIcon, CheckCircleIcon, XCircleIcon, - ExclamationTriangleIcon, MagnifyingGlassIcon, - VideoCameraIcon, - VideoCameraSlashIcon, ArrowPathIcon, ClockIcon, UserIcon, - XMarkIcon, + ArrowLeftIcon, + VideoCameraIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; -type ScanState = 'idle' | 'scanning' | 'success' | 'error' | 'already_checked_in' | 'pending'; +// ─── Types ─────────────────────────────────────────────────── +type ActiveTab = 'scan' | 'search' | 'recent'; +type ScanState = 'idle' | 'scanning' | 'valid' | 'invalid'; +type InvalidReason = 'already_checked_in' | 'cancelled' | 'not_found' | 'pending' | 'wrong_event' | 'unknown'; -interface ScanResult { +interface ScanResultData { state: ScanState; validation?: TicketValidationResult; + invalidReason?: InvalidReason; error?: string; } -// Scanner component that manages its own DOM -function QRScanner({ - onScan, - isActive, - onActiveChange -}: { +interface RecentCheckin { + name: string; + time: string; + ticketId: string; +} + +// ─── Haptic + Sound Feedback ───────────────────────────────── +function playSuccessSound() { + try { + const ctx = new AudioContext(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = 880; + osc.type = 'sine'; + gain.gain.value = 0.3; + osc.start(); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15); + osc.stop(ctx.currentTime + 0.15); + } catch {} +} + +function playErrorSound() { + try { + const ctx = new AudioContext(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = 300; + osc.type = 'square'; + gain.gain.value = 0.2; + osc.start(); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3); + osc.stop(ctx.currentTime + 0.3); + } catch {} +} + +function vibrate(pattern: number | number[]) { + try { + if (navigator.vibrate) navigator.vibrate(pattern); + } catch {} +} + +// ─── QR Scanner Component ──────────────────────────────────── +function QRScanner({ + onScan, + isActive, + onActiveChange, +}: { onScan: (code: string) => void; isActive: boolean; onActiveChange: (active: boolean) => void; @@ -43,8 +89,8 @@ function QRScanner({ const containerRef = useRef(null); const scannerRef = useRef(null); const scannerElementId = useRef(`qr-scanner-${Math.random().toString(36).substr(2, 9)}`); + const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment'); - // Create scanner element on mount useEffect(() => { if (containerRef.current && !document.getElementById(scannerElementId.current)) { const scannerDiv = document.createElement('div'); @@ -52,21 +98,14 @@ function QRScanner({ scannerDiv.style.width = '100%'; containerRef.current.appendChild(scannerDiv); } - return () => { - // Cleanup scanner on unmount if (scannerRef.current) { - try { - scannerRef.current.stop().catch(() => {}); - } catch (e) { - // Ignore - } + try { scannerRef.current.stop().catch(() => {}); } catch {} scannerRef.current = null; } }; }, []); - // Handle scanner start/stop useEffect(() => { let cancelled = false; @@ -77,42 +116,26 @@ function QRScanner({ try { const { Html5Qrcode } = await import('html5-qrcode'); - if (cancelled) return; - - // Stop existing scanner if (scannerRef.current) { - try { - await scannerRef.current.stop(); - } catch (e) { - // Ignore - } + try { await scannerRef.current.stop(); } catch {} scannerRef.current = null; } - if (cancelled) return; const scanner = new Html5Qrcode(elementId); scannerRef.current = scanner; await scanner.start( - { facingMode: 'environment' }, - { - fps: 10, - qrbox: { width: 250, height: 250 }, - aspectRatio: 1, - }, - (decodedText: string) => { - onScan(decodedText); - }, - () => { - // QR parsing error - ignore - } + { facingMode }, + { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 }, + (decodedText: string) => onScan(decodedText), + () => {} ); } catch (error: any) { console.error('Scanner error:', error); if (!cancelled) { - toast.error('Failed to start camera. Please check camera permissions.'); + toast.error('Failed to start camera. Check permissions.'); onActiveChange(false); } } @@ -120,11 +143,7 @@ function QRScanner({ const stopScanner = async () => { if (scannerRef.current) { - try { - await scannerRef.current.stop(); - } catch (e) { - // Ignore - } + try { await scannerRef.current.stop(); } catch {} scannerRef.current = null; } }; @@ -135,19 +154,30 @@ function QRScanner({ stopScanner(); } - return () => { - cancelled = true; - }; - }, [isActive, onScan, onActiveChange]); + return () => { cancelled = true; }; + }, [isActive, facingMode, onScan, onActiveChange]); + + const switchCamera = () => { + setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment')); + }; return ( -
-
+
+
+ {isActive && ( + + )} {!isActive && ( -
-
- -

Click "Start Camera" to begin scanning

+
+
+ +

Starting camera...

)} @@ -155,187 +185,431 @@ function QRScanner({ ); } -// Scan Result Modal -function ScanResultModal({ - scanResult, - onCheckin, - onClose, +// ─── Fullscreen Valid Ticket State ─────────────────────────── +function ValidTicketScreen({ + validation, + onConfirmCheckin, checkingIn, - formatDateTime, }: { - scanResult: ScanResult; - onCheckin: () => void; - onClose: () => void; + validation: TicketValidationResult; + onConfirmCheckin: () => void; checkingIn: boolean; - formatDateTime: (dateStr: string) => string; }) { - if (scanResult.state === 'idle') return null; - - const isSuccess = scanResult.state === 'success'; - const isAlreadyCheckedIn = scanResult.state === 'already_checked_in'; - const isPending = scanResult.state === 'pending'; - const isError = scanResult.state === 'error'; - - // Determine colors based on state - const bgColor = isSuccess ? 'bg-green-500' : isAlreadyCheckedIn ? 'bg-yellow-500' : isPending ? 'bg-orange-500' : 'bg-red-500'; - const bgColorLight = isSuccess ? 'bg-green-50' : isAlreadyCheckedIn ? 'bg-yellow-50' : isPending ? 'bg-orange-50' : 'bg-red-50'; - const borderColor = isSuccess ? 'border-green-500' : isAlreadyCheckedIn ? 'border-yellow-500' : isPending ? 'border-orange-500' : 'border-red-500'; - const textColor = isSuccess ? 'text-green-800' : isAlreadyCheckedIn ? 'text-yellow-800' : isPending ? 'text-orange-800' : 'text-red-800'; - const textColorLight = isSuccess ? 'text-green-600' : isAlreadyCheckedIn ? 'text-yellow-600' : isPending ? 'text-orange-600' : 'text-red-600'; - const iconBg = isSuccess ? 'bg-green-100' : isAlreadyCheckedIn ? 'bg-yellow-100' : isPending ? 'bg-orange-100' : 'bg-red-100'; - const iconColor = isSuccess ? 'text-green-600' : isAlreadyCheckedIn ? 'text-yellow-600' : isPending ? 'text-orange-600' : 'text-red-600'; - - const StatusIcon = isSuccess ? CheckCircleIcon : isAlreadyCheckedIn ? ExclamationTriangleIcon : isPending ? ClockIcon : XCircleIcon; - const statusTitle = isSuccess ? 'Valid Ticket' : isAlreadyCheckedIn ? 'Already Checked In' : isPending ? 'Payment Pending' : 'Invalid Ticket'; - const statusSubtitle = isSuccess ? 'Ready for check-in' : - isAlreadyCheckedIn ? ( - scanResult.validation?.ticket?.checkinAt - ? `Checked in at ${new Date(scanResult.validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}${scanResult.validation?.ticket?.checkedInBy ? ` by ${scanResult.validation.ticket.checkedInBy}` : ''}` - : 'This ticket was already used' - ) : - isPending ? 'Ticket not yet confirmed' : - (scanResult.validation?.error || scanResult.error || 'Ticket not found or cancelled'); - return ( -
- {/* Backdrop */} -
- - {/* Modal */} -
- {/* Close button */} - - - {/* Header with color band */} -
- -
- {/* Status Icon & Title */} -
-
- -
-
-

{statusTitle}

-

{statusSubtitle}

-
-
- - {/* Ticket Details */} - {scanResult.validation?.ticket && ( -
-
-
- -
-
-

- {scanResult.validation.ticket.attendeeName} -

- {scanResult.validation.ticket.attendeeEmail && ( -

- {scanResult.validation.ticket.attendeeEmail} -

- )} -
-
- - {scanResult.validation.event && ( -
-

{scanResult.validation.event.title}

-

{formatDateTime(scanResult.validation.event.startDatetime)}

-

{scanResult.validation.event.location}

-
- )} - -

- Ticket ID: {scanResult.validation.ticket.id.slice(0, 8)}... -

-
- )} - - {/* Actions */} -
- {isSuccess && scanResult.validation?.canCheckIn && ( - - )} - -
+
+
+
+
+

+ {validation.ticket?.attendeeName || 'Guest'} +

+ {validation.ticket?.attendeeEmail && ( +

{validation.ticket.attendeeEmail}

+ )} +
+ {validation.event && ( +

{validation.event.title}

+ )} +

+ Ticket: {validation.ticket?.id.slice(0, 12)}... +

+
+
+
+
); } +// ─── Fullscreen Invalid Ticket State ───────────────────────── +function InvalidTicketScreen({ + reason, + validation, + error, + onScanNext, +}: { + reason: InvalidReason; + validation?: TicketValidationResult; + error?: string; + onScanNext: () => void; +}) { + const reasonText: Record = { + already_checked_in: 'Already Checked In', + cancelled: 'Ticket Cancelled', + not_found: 'Ticket Not Found', + pending: 'Payment Pending', + wrong_event: 'Wrong Event', + unknown: 'Invalid Ticket', + }; + + const reasonDetail: Record = { + already_checked_in: validation?.ticket?.checkinAt + ? `Checked in at ${new Date(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}` + : 'This ticket was already used', + cancelled: 'This ticket has been cancelled and is no longer valid.', + not_found: error || 'No ticket matching this code was found.', + pending: 'This ticket has not been paid for yet.', + wrong_event: 'This ticket belongs to a different event.', + unknown: error || 'This ticket could not be validated.', + }; + + return ( +
+
+
+ +
+

{reasonText[reason]}

+

{reasonDetail[reason]}

+ {validation?.ticket && ( +
+

{validation.ticket.attendeeName}

+ {validation.ticket.attendeeEmail && ( +

{validation.ticket.attendeeEmail}

+ )} +
+ )} +
+
+ +
+
+ ); +} + +// ─── Search Tab Content ────────────────────────────────────── +function SearchTab({ + eventId, + onSelectTicket, +}: { + eventId: string; + onSelectTicket: (ticket: LiveSearchResult) => void; +}) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [hasSearched, setHasSearched] = useState(false); + const inputRef = useRef(null); + const debounceRef = useRef>(); + + // Auto-focus search field + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 100); + }, []); + + // Debounced live search + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (query.trim().length < 2) { + setResults([]); + setHasSearched(false); + return; + } + + debounceRef.current = setTimeout(async () => { + setLoading(true); + setHasSearched(true); + try { + const { tickets } = await ticketsApi.searchLive(query.trim(), eventId || undefined); + setResults(tickets); + } catch (err: any) { + console.error('Search error:', err); + setResults([]); + } finally { + setLoading(false); + } + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, eventId]); + + const statusBadge = (status: string) => { + const config: Record = { + confirmed: { bg: 'bg-emerald-100', text: 'text-emerald-700', label: 'Confirmed' }, + checked_in: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Checked In' }, + pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Pending' }, + cancelled: { bg: 'bg-red-100', text: 'text-red-700', label: 'Cancelled' }, + }; + const c = config[status] || { bg: 'bg-gray-100', text: 'text-gray-700', label: status }; + return ( + + {c.label} + + ); + }; + + return ( +
+ {/* Search input */} +
+
+ + setQuery(e.target.value)} + placeholder="Search by name, email, or ticket ID..." + className="w-full pl-12 pr-4 py-4 bg-gray-800 border border-gray-700 rounded-2xl text-white placeholder:text-gray-500 text-lg focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent" + autoComplete="off" + autoCorrect="off" + spellCheck={false} + /> + {loading && ( +
+
+
+ )} +
+
+ + {/* Results */} +
+ {!hasSearched && query.length < 2 && ( +
+ +

Type at least 2 characters to search

+
+ )} + {hasSearched && !loading && results.length === 0 && ( +
+

No tickets found

+
+ )} + {results.map((ticket) => ( + + ))} +
+
+ ); +} + +// ─── Ticket Detail View (from search) ──────────────────────── +function TicketDetailView({ + validation, + onCheckin, + onBack, + checkingIn, +}: { + validation: TicketValidationResult; + onCheckin: () => void; + onBack: () => void; + checkingIn: boolean; +}) { + const isValid = validation.status === 'valid' && validation.canCheckIn; + const isCheckedIn = validation.status === 'already_checked_in'; + const isPending = validation.status === 'pending_payment'; + const isCancelled = validation.status === 'cancelled'; + + const bgColor = isValid ? 'bg-emerald-600' : isCheckedIn ? 'bg-blue-600' : isPending ? 'bg-amber-600' : 'bg-red-600'; + + return ( +
+ {/* Back button */} +
+ +
+ +
+
+ {isValid ? ( + + ) : isCheckedIn ? ( + + ) : ( + + )} +
+ +

+ {validation.ticket?.attendeeName || 'Unknown'} +

+ {validation.ticket?.attendeeEmail && ( +

{validation.ticket.attendeeEmail}

+ )} + +
+

Status

+

+ {isValid ? 'Ready for Check-in' : isCheckedIn ? 'Already Checked In' : isPending ? 'Payment Pending' : isCancelled ? 'Cancelled' : 'Invalid'} +

+ {validation.event && ( +

{validation.event.title}

+ )} +

+ ID: {validation.ticket?.id.slice(0, 12)}... +

+
+
+ +
+ {isValid ? ( + + ) : ( + + )} +
+
+ ); +} + +// ─── Recent Tab Content ────────────────────────────────────── +function RecentTab({ recentCheckins, sessionCount }: { recentCheckins: RecentCheckin[]; sessionCount: number }) { + return ( +
+ {/* Session counter */} +
+
+

{sessionCount}

+

Checked in this session

+
+
+ + {/* Recent list */} +
+ {recentCheckins.length === 0 ? ( +
+ +

No check-ins yet

+
+ ) : ( +
+ {recentCheckins.map((checkin, i) => ( +
+
+ +
+
+

{checkin.name}

+

{checkin.ticketId.slice(0, 12)}...

+
+

{checkin.time}

+
+ ))} +
+ )} +
+
+ ); +} + +// ═══════════════════════════════════════════════════════════════ +// ─── Main Scanner Page ─────────────────────────────────────── +// ═══════════════════════════════════════════════════════════════ export default function AdminScannerPage() { const { locale } = useLanguage(); + const router = useRouter(); + const { user } = useAuth(); + + // Determine back destination based on role (staff can only access scanner/events) + const backHref = user?.role === 'staff' ? '/admin/events' : '/admin'; + + // Events const [events, setEvents] = useState([]); const [selectedEventId, setSelectedEventId] = useState(''); const [loading, setLoading] = useState(true); - + + // Tabs + const [activeTab, setActiveTab] = useState('scan'); + // Scanner state const [cameraActive, setCameraActive] = useState(false); - const [scanResult, setScanResult] = useState({ state: 'idle' }); - const [lastScannedCode, setLastScannedCode] = useState(''); + const [scanResult, setScanResult] = useState({ state: 'idle' }); + const [lastScannedCode, setLastScannedCode] = useState(''); const [checkingIn, setCheckingIn] = useState(false); - - // Manual search - const [searchQuery, setSearchQuery] = useState(''); - const [searching, setSearching] = useState(false); - + + // Search detail view + const [searchDetailValidation, setSearchDetailValidation] = useState(null); + // Stats const [checkinCount, setCheckinCount] = useState(0); - const [recentCheckins, setRecentCheckins] = useState>([]); - - // Refs for callbacks - const selectedEventIdRef = useRef(''); - const lastScannedCodeRef = useRef(''); + const [recentCheckins, setRecentCheckins] = useState([]); - // Keep refs in sync - useEffect(() => { - selectedEventIdRef.current = selectedEventId; - }, [selectedEventId]); + // Event stats + const [eventCheckedIn, setEventCheckedIn] = useState(0); + const [eventCapacity, setEventCapacity] = useState(0); - useEffect(() => { - lastScannedCodeRef.current = lastScannedCode; - }, [lastScannedCode]); + // Refs + const selectedEventIdRef = useRef(''); + const lastScannedCodeRef = useRef(''); + + useEffect(() => { selectedEventIdRef.current = selectedEventId; }, [selectedEventId]); + useEffect(() => { lastScannedCodeRef.current = lastScannedCode; }, [lastScannedCode]); // Load events useEffect(() => { eventsApi.getAll({ status: 'published' }) - .then(res => { + .then((res) => { setEvents(res.events); - const upcoming = res.events.filter(e => new Date(e.startDatetime) >= new Date()); + const upcoming = res.events.filter((e) => new Date(e.startDatetime) >= new Date()); if (upcoming.length === 1) { setSelectedEventId(upcoming[0].id); } @@ -344,68 +618,111 @@ export default function AdminScannerPage() { .finally(() => setLoading(false)); }, []); + // Load event check-in stats when event changes + useEffect(() => { + if (!selectedEventId) { + setEventCheckedIn(0); + setEventCapacity(0); + return; + } + + const loadStats = async () => { + try { + const stats = await ticketsApi.getCheckinStats(selectedEventId); + setEventCapacity(stats.capacity); + setEventCheckedIn(stats.checkedIn); + } catch (err) { + console.error('Failed to load event stats:', err); + } + }; + loadStats(); + }, [selectedEventId]); + + // Auto-start camera on page load (Scan tab) + useEffect(() => { + 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 const validateTicket = useCallback(async (code: string) => { try { const result = await ticketsApi.validate(code, selectedEventIdRef.current || undefined); - - let state: ScanState = 'idle'; + if (result.status === 'valid') { - state = 'success'; - } else if (result.status === 'already_checked_in') { - state = 'already_checked_in'; - } else if (result.status === 'pending_payment') { - state = 'pending'; + vibrate(100); + playSuccessSound(); + setScanResult({ state: 'valid', validation: result }); } else { - state = 'error'; + vibrate([100, 50, 100]); + playErrorSound(); + let reason: InvalidReason = 'unknown'; + if (result.status === 'already_checked_in') reason = 'already_checked_in'; + else if (result.status === 'cancelled') reason = 'cancelled'; + else if (result.status === 'pending_payment') reason = 'pending'; + else if (result.status === 'wrong_event') reason = 'wrong_event'; + else if (result.status === 'invalid') reason = 'not_found'; + setScanResult({ state: 'invalid', validation: result, invalidReason: reason }); } - - setScanResult({ state, validation: result }); } catch (error: any) { - setScanResult({ - state: 'error', - error: error.message || 'Failed to validate ticket' + vibrate([100, 50, 100]); + playErrorSound(); + setScanResult({ + state: 'invalid', + invalidReason: 'not_found', + error: error.message || 'Failed to validate ticket', }); } }, []); // Handle QR scan const handleScan = useCallback((decodedText: string) => { - // Avoid duplicate scans if (decodedText === lastScannedCodeRef.current) return; lastScannedCodeRef.current = decodedText; setLastScannedCode(decodedText); - - // Extract ticket ID from URL if present + setCameraActive(false); + let code = decodedText; const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/); - if (urlMatch) { - code = urlMatch[1]; - } - + if (urlMatch) code = urlMatch[1]; + validateTicket(code); }, [validateTicket]); - // Check in ticket + // Check in ticket (from scan) const handleCheckin = async () => { if (!scanResult.validation?.ticket?.id) return; - setCheckingIn(true); try { const result = await ticketsApi.checkin(scanResult.validation.ticket.id); - + vibrate(200); toast.success(`${result.ticket.attendeeName || 'Guest'} checked in!`); - - setCheckinCount(prev => prev + 1); - setRecentCheckins(prev => [ - { - name: result.ticket.attendeeName || 'Guest', - time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' }) + + setCheckinCount((p) => p + 1); + setEventCheckedIn((p) => p + 1); + setRecentCheckins((prev) => [ + { + name: result.ticket.attendeeName || 'Guest', + time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + ticketId: scanResult.validation!.ticket!.id, }, - ...prev.slice(0, 4), + ...prev.slice(0, 19), ]); - - handleCloseModal(); + + // Auto-return to camera after 1.5s + setTimeout(() => { + resetScan(); + }, 1500); } catch (error: any) { toast.error(error.message || 'Check-in failed'); } finally { @@ -413,151 +730,186 @@ export default function AdminScannerPage() { } }; - // Close modal and reset - const handleCloseModal = () => { + // Check in from search detail + const handleSearchCheckin = async () => { + if (!searchDetailValidation?.ticket?.id) return; + setCheckingIn(true); + try { + const result = await ticketsApi.checkin(searchDetailValidation.ticket.id); + vibrate(200); + toast.success(`${result.ticket.attendeeName || 'Guest'} checked in!`); + + setCheckinCount((p) => p + 1); + setEventCheckedIn((p) => p + 1); + setRecentCheckins((prev) => [ + { + name: result.ticket.attendeeName || 'Guest', + time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + ticketId: searchDetailValidation!.ticket!.id, + }, + ...prev.slice(0, 19), + ]); + + setSearchDetailValidation(null); + } catch (error: any) { + toast.error(error.message || 'Check-in failed'); + } finally { + setCheckingIn(false); + } + }; + + // Select search result → validate and show detail + const handleSelectSearchResult = async (ticket: LiveSearchResult) => { + try { + const result = await ticketsApi.validate(ticket.qrCode || ticket.ticket_id, selectedEventIdRef.current || undefined); + setSearchDetailValidation(result); + } catch (err: any) { + toast.error(err.message || 'Failed to load ticket details'); + } + }; + + // Reset scan state + const resetScan = () => { setScanResult({ state: 'idle' }); setLastScannedCode(''); lastScannedCodeRef.current = ''; + if (activeTab === 'scan') setCameraActive(true); }; - // Manual search - const handleSearch = async (e: React.FormEvent) => { - e.preventDefault(); - if (!searchQuery.trim()) return; - - setSearching(true); - setScanResult({ state: 'idle' }); - - await validateTicket(searchQuery.trim()); - setSearching(false); - setSearchQuery(''); - }; - - // Format datetime - const formatDateTime = (dateStr: string) => { - return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - timeZone: 'America/Asuncion', - }); - }; + // Get selected event name + const selectedEvent = events.find((e) => e.id === selectedEventId); if (loading) { return ( -
+
); } return ( -
-
-

- - Ticket Scanner -

-

Scan QR codes to check in attendees

-
- - {/* Event Selector */} - - - - - - {/* Scanner Area */} - -
-

Camera Scanner

- -
- - -
- - {/* Manual Search */} - -

Manual Search

- - setSearchQuery(e.target.value)} - className="flex-1" - /> - - -
- - {/* Stats */} -
- -

{checkinCount}

-

Checked in this session

-
- - -

Recent Check-ins

- {recentCheckins.length === 0 ? ( -

No check-ins yet

- ) : ( -
    - {recentCheckins.map((checkin, i) => ( -
  • - {checkin.name} - {checkin.time} -
  • + + + {/* Event selector */} +
    + +
    + {/* Live counter */} + {selectedEventId && eventCapacity > 0 && ( +
    +

    + {eventCheckedIn} / {eventCapacity} +

    +

    Checked in

    +
    )} - +
+ + + {/* ── Tab Bar ── */} +
+
+ {( + [ + { key: 'scan' as ActiveTab, label: 'Scan', icon: QrCodeIcon }, + { key: 'search' as ActiveTab, label: 'Search', icon: MagnifyingGlassIcon }, + { key: 'recent' as ActiveTab, label: 'Recent', icon: ClockIcon }, + ] as const + ).map((tab) => ( + + ))} +
- {/* Scan Result Modal */} - + {/* ── Tab Content ── */} +
+ {/* SCAN TAB */} + {activeTab === 'scan' && ( + + )} + + {/* SEARCH TAB */} + {activeTab === 'search' && ( + + )} + + {/* RECENT TAB */} + {activeTab === 'recent' && ( + + )} +
+ + {/* ── Fullscreen overlays ── */} + {scanResult.state === 'valid' && scanResult.validation && ( + + )} + + {scanResult.state === 'invalid' && ( + + )} + + {searchDetailValidation && ( + setSearchDetailValidation(null)} + checkingIn={checkingIn} + /> + )}
); } diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index dc96bdc..fb23cd0 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -43,7 +43,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re export default function Header() { const { t } = useLanguage(); - const { user, isAdmin, logout } = useAuth(); + const { user, hasAdminAccess, logout } = useAuth(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const menuRef = useRef(null); const touchStartX = useRef(0); @@ -148,7 +148,7 @@ export default function Header() { {t('nav.dashboard')} - {isAdmin && ( + {hasAdminAccess && ( - {isAdmin && ( + {hasAdminAccess && (