Refactor monolithic modules and harden booking, email, and auth infrastructure.
Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
import { Ticket } from '@/lib/api';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ChevronDownIcon,
|
||||
ArrowDownTrayIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { TicketStatusFilter } from '../_types';
|
||||
|
||||
interface TicketsTabProps {
|
||||
locale: string;
|
||||
confirmedTickets: Ticket[];
|
||||
filteredConfirmedTickets: Ticket[];
|
||||
ticketSearchQuery: string;
|
||||
setTicketSearchQuery: (value: string) => void;
|
||||
ticketStatusFilter: TicketStatusFilter;
|
||||
setTicketStatusFilter: (value: TicketStatusFilter) => void;
|
||||
confirmedCount: number;
|
||||
checkedInCount: number;
|
||||
exporting: boolean;
|
||||
showTicketExportDropdown: boolean;
|
||||
setShowTicketExportDropdown: (value: boolean) => void;
|
||||
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
|
||||
handleCheckin: (ticketId: string) => void;
|
||||
handleRemoveCheckin: (ticketId: string) => void;
|
||||
setShowTicketExportSheet: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function TicketsTab({
|
||||
locale,
|
||||
confirmedTickets,
|
||||
filteredConfirmedTickets,
|
||||
ticketSearchQuery,
|
||||
setTicketSearchQuery,
|
||||
ticketStatusFilter,
|
||||
setTicketStatusFilter,
|
||||
confirmedCount,
|
||||
checkedInCount,
|
||||
exporting,
|
||||
showTicketExportDropdown,
|
||||
setShowTicketExportDropdown,
|
||||
handleExportTickets,
|
||||
handleCheckin,
|
||||
handleRemoveCheckin,
|
||||
setShowTicketExportSheet,
|
||||
}: TicketsTabProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Desktop toolbar */}
|
||||
<Card className="p-3 hidden md:block">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or ticket ID..."
|
||||
value={ticketSearchQuery}
|
||||
onChange={(e) => setTicketSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={ticketStatusFilter}
|
||||
onChange={(e) => setTicketStatusFilter(e.target.value as TicketStatusFilter)}
|
||||
className="px-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
<option value="all">All ({confirmedTickets.length})</option>
|
||||
<option value="confirmed">Valid ({confirmedCount})</option>
|
||||
<option value="checked_in">Checked In ({checkedInCount})</option>
|
||||
</select>
|
||||
<div className="flex-1" />
|
||||
<Dropdown
|
||||
open={showTicketExportDropdown}
|
||||
onOpenChange={setShowTicketExportDropdown}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={exporting}>
|
||||
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
|
||||
Export
|
||||
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => handleExportTickets('all')}>Export All</DropdownItem>
|
||||
<DropdownItem onClick={() => handleExportTickets('confirmed')}>Export Valid</DropdownItem>
|
||||
<DropdownItem onClick={() => handleExportTickets('checked_in')}>Export Checked-in</DropdownItem>
|
||||
<div className="border-t border-gray-100 mx-2" />
|
||||
<div className="px-4 py-1.5 text-[10px] text-gray-400">Format: CSV</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{(ticketSearchQuery || ticketStatusFilter !== 'all') && (
|
||||
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span>Showing {filteredConfirmedTickets.length} of {confirmedTickets.length}</span>
|
||||
<button onClick={() => { setTicketSearchQuery(''); setTicketStatusFilter('all'); }} className="text-primary-yellow hover:underline">Clear</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Mobile toolbar */}
|
||||
<div className="md:hidden space-y-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or ticket ID..."
|
||||
value={ticketSearchQuery}
|
||||
onChange={(e) => setTicketSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={ticketStatusFilter}
|
||||
onChange={(e) => setTicketStatusFilter(e.target.value as TicketStatusFilter)}
|
||||
className="px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]"
|
||||
>
|
||||
<option value="all">All ({confirmedTickets.length})</option>
|
||||
<option value="confirmed">Valid ({confirmedCount})</option>
|
||||
<option value="checked_in">Checked In ({checkedInCount})</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowTicketExportSheet(true)}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-btn border border-secondary-light-gray text-sm text-gray-600 min-h-[44px]"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Dense table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Check-in</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredConfirmedTickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-10 text-center text-gray-500 text-sm">
|
||||
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredConfirmedTickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||
{ticket.bookingId && (
|
||||
<span className="text-[10px] text-purple-600">Group booking</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{ticket.status === 'confirmed' ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-100 text-green-800 font-medium">Valid</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-800 font-medium">Checked In</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||
{ticket.checkinAt ? (
|
||||
parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE,
|
||||
})
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{ticket.status === 'confirmed' && (
|
||||
<Button size="sm" onClick={() => handleCheckin(ticket.id)} className="text-xs px-2 py-1">
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status === 'checked_in' && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleRemoveCheckin(ticket.id)} className="text-xs px-2 py-1">
|
||||
<ArrowUturnLeftIcon className="w-3 h-3 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
)}
|
||||
<MoreMenu>
|
||||
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
|
||||
ID: {ticket.id.slice(0, 8)}...
|
||||
</div>
|
||||
{ticket.bookingId && (
|
||||
<div className="px-4 py-1.5 text-[10px] text-purple-500 font-mono" title={ticket.bookingId}>
|
||||
Booking: {ticket.bookingId.slice(0, 8)}...
|
||||
</div>
|
||||
)}
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card layout */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{filteredConfirmedTickets.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">
|
||||
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
||||
</div>
|
||||
) : (
|
||||
filteredConfirmedTickets.map((ticket) => (
|
||||
<Card key={ticket.id} className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||
{ticket.bookingId && <p className="text-[10px] text-purple-600">Group booking</p>}
|
||||
</div>
|
||||
{ticket.status === 'confirmed' ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-100 text-green-800 font-medium flex-shrink-0">Valid</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-800 font-medium flex-shrink-0">Checked In</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{ticket.checkinAt
|
||||
? `Checked in ${parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`
|
||||
: 'Not checked in'}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{ticket.status === 'confirmed' && (
|
||||
<Button size="sm" onClick={() => handleCheckin(ticket.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status === 'checked_in' && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleRemoveCheckin(ticket.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||
<ArrowUturnLeftIcon className="w-3 h-3 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user