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:
@@ -72,3 +72,4 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
|
|||||||
# Maximum number of emails that can be sent per hour (default: 30)
|
# Maximum number of emails that can be sent per hour (default: 30)
|
||||||
# If the limit is reached, queued emails will pause and resume automatically
|
# If the limit is reached, queued emails will pause and resume automatically
|
||||||
MAX_EMAILS_PER_HOUR=30
|
MAX_EMAILS_PER_HOUR=30
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
||||||
import { eq, and, sql } from 'drizzle-orm';
|
import { eq, and, or, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||||
@@ -490,6 +490,125 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get event check-in stats for scanner (lightweight endpoint for staff)
|
||||||
|
ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const eventId = c.req.query('eventId');
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
return c.json({ error: 'eventId is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get event info
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count checked-in tickets
|
||||||
|
const checkedInCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
eq((tickets as any).status, 'checked_in')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count confirmed + checked_in (total active)
|
||||||
|
const totalActiveCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
eventId,
|
||||||
|
capacity: event.capacity,
|
||||||
|
checkedIn: checkedInCount?.count || 0,
|
||||||
|
totalActive: totalActiveCount?.count || 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live search tickets (GET - for scanner live search)
|
||||||
|
ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const q = c.req.query('q')?.trim() || '';
|
||||||
|
const eventId = c.req.query('eventId');
|
||||||
|
|
||||||
|
if (q.length < 2) {
|
||||||
|
return c.json({ tickets: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = `%${q.toLowerCase()}%`;
|
||||||
|
|
||||||
|
// Search by name (ILIKE), email (ILIKE), ticket ID (exact or partial)
|
||||||
|
const nameEmailConditions = [
|
||||||
|
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
|
||||||
|
// Ticket ID exact or partial match (cast UUID to text for LOWER)
|
||||||
|
sql`LOWER(CAST(${(tickets as any).id} AS TEXT)) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(CAST(${(tickets as any).qrCode} AS TEXT)) LIKE ${searchTerm}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
let whereClause: any = and(
|
||||||
|
or(...nameEmailConditions),
|
||||||
|
// Exclude cancelled tickets by default
|
||||||
|
sql`${(tickets as any).status} != 'cancelled'`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingTickets = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(whereClause)
|
||||||
|
.limit(20)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enrich with event details
|
||||||
|
const results = await Promise.all(
|
||||||
|
matchingTickets.map(async (ticket: any) => {
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ticket_id: ticket.id,
|
||||||
|
name: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
|
email: ticket.attendeeEmail,
|
||||||
|
status: ticket.status,
|
||||||
|
checked_in: ticket.status === 'checked_in',
|
||||||
|
checkinAt: ticket.checkinAt,
|
||||||
|
event_id: ticket.eventId,
|
||||||
|
qrCode: ticket.qrCode,
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ tickets: results });
|
||||||
|
});
|
||||||
|
|
||||||
// Get ticket by ID
|
// Get ticket by ID
|
||||||
ticketsRouter.get('/:id', async (c) => {
|
ticketsRouter.get('/:id', async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
@@ -554,6 +673,65 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
|||||||
return c.json({ ticket: updated });
|
return c.json({ ticket: updated });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Search tickets by name/email (for scanner manual search)
|
||||||
|
ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const { query, eventId } = body;
|
||||||
|
|
||||||
|
if (!query || typeof query !== 'string' || query.trim().length < 2) {
|
||||||
|
return c.json({ error: 'Search query must be at least 2 characters' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = `%${query.trim().toLowerCase()}%`;
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
|
||||||
|
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
let whereClause = or(...conditions);
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingTickets = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(whereClause)
|
||||||
|
.limit(20)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enrich with event details
|
||||||
|
const results = await Promise.all(
|
||||||
|
matchingTickets.map(async (ticket: any) => {
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: ticket.id,
|
||||||
|
qrCode: ticket.qrCode,
|
||||||
|
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
|
attendeePhone: ticket.attendeePhone,
|
||||||
|
status: ticket.status,
|
||||||
|
checkinAt: ticket.checkinAt,
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ tickets: results });
|
||||||
|
});
|
||||||
|
|
||||||
// Validate ticket by QR code (for scanner)
|
// Validate ticket by QR code (for scanner)
|
||||||
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
const body = await c.req.json().catch(() => ({}));
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
|
EyeSlashIcon,
|
||||||
PaperAirplaneIcon,
|
PaperAirplaneIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
@@ -63,6 +64,7 @@ export default function AdminEventDetailPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
||||||
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
||||||
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
||||||
|
const [showStats, setShowStats] = useState(true);
|
||||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||||
const [noteText, setNoteText] = useState('');
|
const [noteText, setNoteText] = useState('');
|
||||||
@@ -496,7 +498,9 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
<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">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
@@ -553,6 +557,26 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
) : 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 */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-secondary-light-gray mb-6">
|
<div className="border-b border-secondary-light-gray mb-6">
|
||||||
@@ -966,9 +990,16 @@ export default function AdminEventDetailPage() {
|
|||||||
|
|
||||||
{/* Add at Door Modal */}
|
{/* Add at Door Modal */}
|
||||||
{showAddAtDoorModal && (
|
{showAddAtDoorModal && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div
|
||||||
<Card className="w-full max-w-md">
|
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
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>
|
<h2 className="text-lg font-bold">Add Attendee at Door</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddAtDoorModal(false)}
|
onClick={() => setShowAddAtDoorModal(false)}
|
||||||
@@ -977,7 +1008,7 @@ export default function AdminEventDetailPage() {
|
|||||||
<XMarkIcon className="w-5 h-5" />
|
<XMarkIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">First Name *</label>
|
<label className="block text-sm font-medium mb-1">First Name *</label>
|
||||||
@@ -1063,9 +1094,16 @@ export default function AdminEventDetailPage() {
|
|||||||
|
|
||||||
{/* Manual Ticket Modal */}
|
{/* Manual Ticket Modal */}
|
||||||
{showManualTicketModal && (
|
{showManualTicketModal && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div
|
||||||
<Card className="w-full max-w-md">
|
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
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>
|
<div>
|
||||||
<h2 className="text-lg font-bold">Create Manual Ticket</h2>
|
<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>
|
<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" />
|
<XMarkIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">First Name *</label>
|
<label className="block text-sm font-medium mb-1">First Name *</label>
|
||||||
|
|||||||
@@ -37,14 +37,56 @@ export default function AdminLayout({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const { user, isAdmin, isLoading, logout } = useAuth();
|
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
|
||||||
|
const userRole = (user?.role || 'user') as Role;
|
||||||
|
|
||||||
|
const navigationWithRoles: { name: string; href: string; icon: typeof HomeIcon; allowedRoles: Role[] }[] = [
|
||||||
|
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||||
|
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||||
|
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
|
||||||
|
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
||||||
|
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
|
||||||
|
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
|
||||||
|
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allowedPathsForRole = new Set(
|
||||||
|
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href)
|
||||||
|
);
|
||||||
|
const defaultAdminRoute =
|
||||||
|
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
|
||||||
|
|
||||||
|
// All hooks must be called unconditionally before any early returns
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && (!user || !isAdmin)) {
|
if (!isLoading && (!user || !hasAdminAccess)) {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
}, [user, isAdmin, isLoading, router]);
|
}, [user, hasAdminAccess, isLoading, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !hasAdminAccess) return;
|
||||||
|
if (!pathname.startsWith('/admin')) return;
|
||||||
|
if (pathname === '/admin' && (userRole === 'staff' || userRole === 'marketing')) {
|
||||||
|
router.replace(defaultAdminRoute);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isPathAllowed = (path: string) => {
|
||||||
|
if (allowedPathsForRole.has(path)) return true;
|
||||||
|
return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/'));
|
||||||
|
};
|
||||||
|
if (!isPathAllowed(pathname)) {
|
||||||
|
router.replace(defaultAdminRoute);
|
||||||
|
}
|
||||||
|
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -54,31 +96,29 @@ export default function AdminLayout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !isAdmin) {
|
if (!user || !hasAdminAccess) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigation = [
|
const visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole));
|
||||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
const navigation = visibleNav;
|
||||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
|
|
||||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
|
|
||||||
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon },
|
|
||||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
|
|
||||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
|
|
||||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
|
|
||||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
|
|
||||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
|
|
||||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
|
|
||||||
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon },
|
|
||||||
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon },
|
|
||||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scanner page gets fullscreen layout without sidebar
|
||||||
|
const isScannerPage = pathname === '/admin/scanner';
|
||||||
|
|
||||||
|
if (isScannerPage) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-secondary-gray">
|
<div className="min-h-screen bg-secondary-gray">
|
||||||
{/* Mobile sidebar backdrop */}
|
{/* Mobile sidebar backdrop */}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
|
|||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, hasAdminAccess, logout } = useAuth();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const touchStartX = useRef<number>(0);
|
const touchStartX = useRef<number>(0);
|
||||||
@@ -148,7 +148,7 @@ export default function Header() {
|
|||||||
{t('nav.dashboard')}
|
{t('nav.dashboard')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{isAdmin && (
|
{hasAdminAccess && (
|
||||||
<Link href="/admin">
|
<Link href="/admin">
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
{t('nav.admin')}
|
{t('nav.admin')}
|
||||||
@@ -270,7 +270,7 @@ export default function Header() {
|
|||||||
{t('nav.dashboard')}
|
{t('nav.dashboard')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{isAdmin && (
|
{hasAdminAccess && (
|
||||||
<Link href="/admin" onClick={closeMenu}>
|
<Link href="/admin" onClick={closeMenu}>
|
||||||
<Button variant="outline" className="w-full justify-center">
|
<Button variant="outline" className="w-full justify-center">
|
||||||
{t('nav.admin')}
|
{t('nav.admin')}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface AuthContextType {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
hasAdminAccess: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
loginWithGoogle: (credential: string) => Promise<void>;
|
loginWithGoogle: (credential: string) => Promise<void>;
|
||||||
loginWithMagicLink: (token: string) => Promise<void>;
|
loginWithMagicLink: (token: string) => Promise<void>;
|
||||||
@@ -177,6 +178,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
|
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
|
||||||
|
const hasAdminAccess = user?.role === 'admin' || user?.role === 'organizer' || user?.role === 'staff' || user?.role === 'marketing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
@@ -185,6 +187,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
token,
|
token,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
hasAdminAccess,
|
||||||
login,
|
login,
|
||||||
loginWithGoogle,
|
loginWithGoogle,
|
||||||
loginWithMagicLink,
|
loginWithMagicLink,
|
||||||
|
|||||||
@@ -93,6 +93,27 @@ export const ticketsApi = {
|
|||||||
body: JSON.stringify({ code, eventId }),
|
body: JSON.stringify({ code, eventId }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Search tickets by name/email (for scanner manual search)
|
||||||
|
search: (query: string, eventId?: string) =>
|
||||||
|
fetchApi<{ tickets: TicketSearchResult[] }>('/api/tickets/search', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ query, eventId }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get event check-in stats (for scanner header counter)
|
||||||
|
getCheckinStats: (eventId: string) =>
|
||||||
|
fetchApi<{ eventId: string; capacity: number; checkedIn: number; totalActive: number }>(
|
||||||
|
`/api/tickets/stats/checkin?eventId=${eventId}`
|
||||||
|
),
|
||||||
|
|
||||||
|
// Live search tickets (GET - for scanner live search with debounce)
|
||||||
|
searchLive: (q: string, eventId?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('q', q);
|
||||||
|
if (eventId) params.set('eventId', eventId);
|
||||||
|
return fetchApi<{ tickets: LiveSearchResult[] }>(`/api/tickets/search?${params}`);
|
||||||
|
},
|
||||||
|
|
||||||
checkin: (id: string) =>
|
checkin: (id: string) =>
|
||||||
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -508,6 +529,39 @@ export interface TicketValidationResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TicketSearchResult {
|
||||||
|
id: string;
|
||||||
|
qrCode: string;
|
||||||
|
attendeeName: string;
|
||||||
|
attendeeEmail?: string;
|
||||||
|
attendeePhone?: string;
|
||||||
|
status: string;
|
||||||
|
checkinAt?: string;
|
||||||
|
event?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startDatetime: string;
|
||||||
|
location: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveSearchResult {
|
||||||
|
ticket_id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
status: string;
|
||||||
|
checked_in: boolean;
|
||||||
|
checkinAt?: string;
|
||||||
|
event_id: string;
|
||||||
|
qrCode: string;
|
||||||
|
event?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startDatetime: string;
|
||||||
|
location: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
id: string;
|
id: string;
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user