Mobile scanner redesign + backend live search
- Scanner page: fullscreen mobile-first layout, Scan/Search/Recent tabs - Scan tab: auto-start camera, switch camera, vibration/sound feedback - Valid/invalid fullscreen states, confirm check-in, auto-return to camera - Search tab: live backend search (300ms debounce), tap card for detail + check-in - Recent tab: last 20 check-ins, session counter - Backend: GET /api/tickets/search (live search), GET /api/tickets/stats/checkin - Admin layout: hide sidebar on scanner page; fix hooks order (no early return before useEffect) - Back button to dashboard/events (staff → events, others → admin) - API: searchLive, getCheckinStats, LiveSearchResult; PostgreSQL LOWER cast for UUID Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<Ticket | null>(null);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
@@ -496,62 +498,84 @@ export default function AdminEventDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<UsersIcon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{event.capacity}</p>
|
||||
<p className="text-sm text-gray-500">Capacity</p>
|
||||
</div>
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
{showStats ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 flex-1">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<UsersIcon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{event.capacity}</p>
|
||||
<p className="text-sm text-gray-500">Capacity</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{confirmedCount}</p>
|
||||
<p className="text-sm text-gray-500">Confirmed</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<ClockIcon className="w-5 h-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{pendingCount}</p>
|
||||
<p className="text-sm text-gray-500">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<TicketIcon className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{checkedInCount}</p>
|
||||
<p className="text-sm text-gray-500">Checked In</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{formatCurrency(confirmedCount * event.price, event.currency)}</p>
|
||||
<p className="text-sm text-gray-500">Revenue</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{confirmedCount}</p>
|
||||
<p className="text-sm text-gray-500">Confirmed</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<ClockIcon className="w-5 h-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{pendingCount}</p>
|
||||
<p className="text-sm text-gray-500">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<TicketIcon className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{checkedInCount}</p>
|
||||
<p className="text-sm text-gray-500">Checked In</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{formatCurrency(confirmedCount * event.price, event.currency)}</p>
|
||||
<p className="text-sm text-gray-500">Revenue</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowStats(!showStats)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{showStats ? (
|
||||
<>
|
||||
<EyeSlashIcon className="w-4 h-4 mr-2" />
|
||||
Hide stats
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeIcon className="w-4 h-4 mr-2" />
|
||||
Show stats
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -966,9 +990,16 @@ export default function AdminEventDetailPage() {
|
||||
|
||||
{/* Add at Door Modal */}
|
||||
{showAddAtDoorModal && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setShowAddAtDoorModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full max-w-md max-h-[90vh] flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-lg font-bold">Add Attendee at Door</h2>
|
||||
<button
|
||||
onClick={() => setShowAddAtDoorModal(false)}
|
||||
@@ -977,7 +1008,7 @@ export default function AdminEventDetailPage() {
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleAddAtDoor} className="p-4 space-y-4">
|
||||
<form onSubmit={handleAddAtDoor} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name *</label>
|
||||
@@ -1063,9 +1094,16 @@ export default function AdminEventDetailPage() {
|
||||
|
||||
{/* Manual Ticket Modal */}
|
||||
{showManualTicketModal && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setShowManualTicketModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full max-w-md max-h-[90vh] flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Create Manual Ticket</h2>
|
||||
<p className="text-sm text-gray-500">Attendee will receive a confirmation email with their ticket</p>
|
||||
@@ -1077,7 +1115,7 @@ export default function AdminEventDetailPage() {
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleManualTicket} className="p-4 space-y-4">
|
||||
<form onSubmit={handleManualTicket} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name *</label>
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-950">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-gray">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user