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:
@@ -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(() => ({}));
|
||||
|
||||
Reference in New Issue
Block a user