Mobile scanner redesign + backend live search #7

Merged
Michilis merged 1 commits from dev into main 2026-02-14 04:28:44 +00:00
8 changed files with 1125 additions and 459 deletions

View File

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

View File

@@ -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<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
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<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)
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const body = await c.req.json().catch(() => ({}));

View File

@@ -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,7 +498,9 @@ export default function AdminEventDetailPage() {
</div>
{/* 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">
<div className="flex items-center gap-3">
<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>
</Card>
</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 */}
<div className="border-b border-secondary-light-gray mb-6">
@@ -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>

View File

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

View File

@@ -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<HTMLDivElement>(null);
const touchStartX = useRef<number>(0);
@@ -148,7 +148,7 @@ export default function Header() {
{t('nav.dashboard')}
</Button>
</Link>
{isAdmin && (
{hasAdminAccess && (
<Link href="/admin">
<Button variant="ghost" size="sm">
{t('nav.admin')}
@@ -270,7 +270,7 @@ export default function Header() {
{t('nav.dashboard')}
</Button>
</Link>
{isAdmin && (
{hasAdminAccess && (
<Link href="/admin" onClick={closeMenu}>
<Button variant="outline" className="w-full justify-center">
{t('nav.admin')}

View File

@@ -21,6 +21,7 @@ interface AuthContextType {
token: string | null;
isLoading: boolean;
isAdmin: boolean;
hasAdminAccess: boolean;
login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (credential: 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 hasAdminAccess = user?.role === 'admin' || user?.role === 'organizer' || user?.role === 'staff' || user?.role === 'marketing';
return (
<AuthContext.Provider
@@ -185,6 +187,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
token,
isLoading,
isAdmin,
hasAdminAccess,
login,
loginWithGoogle,
loginWithMagicLink,

View File

@@ -93,6 +93,27 @@ export const ticketsApi = {
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) =>
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
method: 'POST',
@@ -508,6 +529,39 @@ export interface TicketValidationResult {
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 {
id: string;
ticketId: string;