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,355 @@
|
||||
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 clsx from 'clsx';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ChevronDownIcon,
|
||||
ArrowDownTrayIcon,
|
||||
PlusIcon,
|
||||
EnvelopeIcon,
|
||||
StarIcon,
|
||||
FunnelIcon,
|
||||
ChatBubbleLeftIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { StatusBadge } from '../_components/StatusBadge';
|
||||
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
|
||||
|
||||
interface AttendeesTabProps {
|
||||
locale: string;
|
||||
tickets: Ticket[];
|
||||
filteredTickets: Ticket[];
|
||||
searchQuery: string;
|
||||
setSearchQuery: (value: string) => void;
|
||||
statusFilter: AttendeeStatusFilter;
|
||||
setStatusFilter: (value: AttendeeStatusFilter) => void;
|
||||
pendingCount: number;
|
||||
confirmedCount: number;
|
||||
checkedInCount: number;
|
||||
cancelledCount: number;
|
||||
exporting: boolean;
|
||||
showExportDropdown: boolean;
|
||||
setShowExportDropdown: (value: boolean) => void;
|
||||
showAddTicketDropdown: boolean;
|
||||
setShowAddTicketDropdown: (value: boolean) => void;
|
||||
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
|
||||
setShowManualTicketModal: (value: boolean) => void;
|
||||
setShowAddAtDoorModal: (value: boolean) => void;
|
||||
setShowInviteGuestModal: (value: boolean) => void;
|
||||
setMobileFilterOpen: (value: boolean) => void;
|
||||
setShowExportSheet: (value: boolean) => void;
|
||||
setShowAddTicketSheet: (value: boolean) => void;
|
||||
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
|
||||
handleOpenNoteModal: (ticket: Ticket) => void;
|
||||
}
|
||||
|
||||
export function AttendeesTab({
|
||||
locale,
|
||||
tickets,
|
||||
filteredTickets,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
pendingCount,
|
||||
confirmedCount,
|
||||
checkedInCount,
|
||||
cancelledCount,
|
||||
exporting,
|
||||
showExportDropdown,
|
||||
setShowExportDropdown,
|
||||
showAddTicketDropdown,
|
||||
setShowAddTicketDropdown,
|
||||
handleExportAttendees,
|
||||
setShowManualTicketModal,
|
||||
setShowAddAtDoorModal,
|
||||
setShowInviteGuestModal,
|
||||
setMobileFilterOpen,
|
||||
setShowExportSheet,
|
||||
setShowAddTicketSheet,
|
||||
getPrimaryAction,
|
||||
handleOpenNoteModal,
|
||||
}: AttendeesTabProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Desktop toolbar */}
|
||||
<Card className="p-3 hidden md:block">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Left: Search + Status */}
|
||||
<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 name, email, phone..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(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={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as AttendeeStatusFilter)}
|
||||
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 ({tickets.length})</option>
|
||||
<option value="pending">Pending ({pendingCount})</option>
|
||||
<option value="confirmed">Confirmed ({confirmedCount})</option>
|
||||
<option value="checked_in">Checked In ({checkedInCount})</option>
|
||||
<option value="cancelled">Cancelled ({cancelledCount})</option>
|
||||
</select>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right: Export + Add Ticket dropdown */}
|
||||
<Dropdown
|
||||
open={showExportDropdown}
|
||||
onOpenChange={setShowExportDropdown}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={exporting}>
|
||||
{exporting ? (
|
||||
<div className="w-3.5 h-3.5 mr-1.5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
|
||||
)}
|
||||
Export
|
||||
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => handleExportAttendees('all')}>Export All</DropdownItem>
|
||||
<DropdownItem onClick={() => handleExportAttendees('confirmed')}>Export Confirmed</DropdownItem>
|
||||
<DropdownItem onClick={() => handleExportAttendees('checked_in')}>Export Checked-in</DropdownItem>
|
||||
<DropdownItem onClick={() => handleExportAttendees('confirmed_pending')}>Confirmed & Pending</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>
|
||||
|
||||
<Dropdown
|
||||
open={showAddTicketDropdown}
|
||||
onOpenChange={setShowAddTicketDropdown}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<PlusIcon className="w-3.5 h-3.5 mr-1.5" />
|
||||
Add Ticket
|
||||
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}>
|
||||
<EnvelopeIcon className="w-4 h-4 mr-2" /> Manual Ticket
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
|
||||
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{(searchQuery || statusFilter !== 'all') && (
|
||||
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span>Showing {filteredTickets.length} of {tickets.length}</span>
|
||||
<button onClick={() => { setSearchQuery(''); setStatusFilter('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 name, email, phone..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(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">
|
||||
<button
|
||||
onClick={() => setMobileFilterOpen(true)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||||
statusFilter !== 'all'
|
||||
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
|
||||
: 'border-secondary-light-gray text-gray-600'
|
||||
)}
|
||||
>
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
{statusFilter === 'all' ? 'Filter' : statusFilter.replace('_', ' ')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowExportSheet(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>
|
||||
{(searchQuery || statusFilter !== 'all') && (
|
||||
<button
|
||||
onClick={() => { setSearchQuery(''); setStatusFilter('all'); }}
|
||||
className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(searchQuery || statusFilter !== 'all') && (
|
||||
<p className="text-xs text-gray-500">Showing {filteredTickets.length} of {tickets.length}</p>
|
||||
)}
|
||||
</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">Contact</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">Booked</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">
|
||||
{filteredTickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-gray-500 text-sm">
|
||||
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredTickets.map((ticket) => {
|
||||
const primary = getPrimaryAction(ticket);
|
||||
return (
|
||||
<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" title={`Booking: ${ticket.bookingId}`}>
|
||||
Group booking
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="text-sm text-gray-600 truncate max-w-[200px]">{ticket.attendeeEmail}</p>
|
||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<StatusBadge status={ticket.status} compact />
|
||||
{!!ticket.isGuest && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
||||
)}
|
||||
</div>
|
||||
{ticket.checkinAt && (
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{primary && (
|
||||
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2 py-1">
|
||||
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
|
||||
{primary.label}
|
||||
</Button>
|
||||
)}
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
||||
</DropdownItem>
|
||||
{ticket.adminNote && (
|
||||
<div className="px-4 py-1.5 text-[10px] text-gray-400 truncate max-w-[180px]">
|
||||
Note: {ticket.adminNote}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
|
||||
ID: {ticket.id.slice(0, 8)}...
|
||||
</div>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card layout */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{filteredTickets.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">
|
||||
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
||||
</div>
|
||||
) : (
|
||||
filteredTickets.map((ticket) => {
|
||||
const primary = getPrimaryAction(ticket);
|
||||
return (
|
||||
<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>
|
||||
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
|
||||
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
||||
<StatusBadge status={ticket.status} compact />
|
||||
{!!ticket.isGuest && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
||||
{ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{primary && (
|
||||
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
|
||||
{primary.label}
|
||||
</Button>
|
||||
)}
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||
<button
|
||||
onClick={() => setShowAddTicketSheet(true)}
|
||||
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform"
|
||||
>
|
||||
<PlusIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user