Files
Spanglish/frontend/src/app/admin/events/[id]/page.tsx
Michilis 958181e049 Mobile-friendly admin pages, redesigned homepage Next Event card
- Extract shared mobile components (BottomSheet, MoreMenu, Dropdown, etc.) into MobileComponents.tsx
- Make admin pages mobile-friendly: bookings, emails, events, faq, payments, tickets, users
- Redesign homepage Next Event card with banner image, responsive layout, and updated styling
- Fix past events showing on homepage/linktree: use proper Date comparison, auto-unfeature expired events
- Add "Over" tag to admin events list for past events
- Fix backend FRONTEND_URL for cache revalidation

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 03:27:49 +00:00

2092 lines
103 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, BottomSheet, MoreMenu, AdminMobileStyles } from '@/components/admin/MobileComponents';
import {
ArrowLeftIcon,
CalendarIcon,
MapPinIcon,
CurrencyDollarIcon,
UsersIcon,
TicketIcon,
CheckCircleIcon,
ClockIcon,
XCircleIcon,
EnvelopeIcon,
PencilIcon,
EyeIcon,
EyeSlashIcon,
PaperAirplaneIcon,
UserGroupIcon,
MagnifyingGlassIcon,
FunnelIcon,
PlusIcon,
ChatBubbleLeftIcon,
ArrowUturnLeftIcon,
XMarkIcon,
CreditCardIcon,
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
ArrowPathIcon,
ArrowDownTrayIcon,
ChevronDownIcon,
EllipsisVerticalIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
export default function AdminEventDetailPage() {
const params = useParams();
const router = useRouter();
const eventId = params.id as string;
const { t, locale } = useLanguage();
const [loading, setLoading] = useState(true);
const [event, setEvent] = useState<Event | null>(null);
const [tickets, setTickets] = useState<Ticket[]>([]);
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Email state
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
const [recipientFilter, setRecipientFilter] = useState<'all' | 'confirmed' | 'pending' | 'checked_in'>('confirmed');
const [customMessage, setCustomMessage] = useState('');
const [sending, setSending] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
// Attendees tab state
const [searchQuery, setSearchQuery] = useState('');
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('');
const [addAtDoorForm, setAddAtDoorForm] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
autoCheckin: true,
adminNote: '',
});
const [manualTicketForm, setManualTicketForm] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
});
const [submitting, setSubmitting] = useState(false);
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
const [showTicketExportDropdown, setShowTicketExportDropdown] = useState(false); // desktop
const [showTicketExportSheet, setShowTicketExportSheet] = useState(false); // mobile
const [exporting, setExporting] = useState(false);
// Add Ticket — separate desktop dropdown vs mobile bottom sheet
const [showAddTicketDropdown, setShowAddTicketDropdown] = useState(false); // desktop
const [showAddTicketSheet, setShowAddTicketSheet] = useState(false); // mobile FAB
// Tickets tab state
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
const [ticketStatusFilter, setTicketStatusFilter] = useState<'all' | 'confirmed' | 'checked_in'>('all');
// Payment options state
const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null);
const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({});
const [hasPaymentOverrides, setHasPaymentOverrides] = useState(false);
const [savingPayments, setSavingPayments] = useState(false);
const [loadingPayments, setLoadingPayments] = useState(false);
// Mobile-specific state
const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [mobileStatsExpanded, setMobileStatsExpanded] = useState(false);
// Tab bar ref for sticky
const tabBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadEventData();
}, [eventId]);
const loadEventData = async () => {
try {
const [eventRes, ticketsRes, templatesRes] = await Promise.all([
eventsApi.getById(eventId),
ticketsApi.getAll({ eventId }),
emailsApi.getTemplates(),
]);
setEvent(eventRes.event);
setTickets(ticketsRes.tickets);
setTemplates(templatesRes.templates.filter(t => t.isActive));
} catch (error) {
toast.error('Failed to load event data');
} finally {
setLoading(false);
}
};
const loadPaymentOptions = async () => {
if (globalPaymentOptions) return;
setLoadingPayments(true);
try {
const [globalRes, overridesRes] = await Promise.all([
paymentOptionsApi.getGlobal(),
paymentOptionsApi.getEventOverrides(eventId),
]);
setGlobalPaymentOptions(globalRes.paymentOptions);
if (overridesRes.overrides) {
setPaymentOverrides(overridesRes.overrides);
setHasPaymentOverrides(true);
}
} catch (error) {
toast.error('Failed to load payment options');
} finally {
setLoadingPayments(false);
}
};
useEffect(() => {
if (activeTab === 'payments') {
loadPaymentOptions();
}
}, [activeTab]);
const getEffectivePaymentOption = <K extends keyof PaymentOptionsConfig>(key: K): PaymentOptionsConfig[K] => {
if (paymentOverrides[key] !== undefined && paymentOverrides[key] !== null) {
return paymentOverrides[key] as PaymentOptionsConfig[K];
}
return globalPaymentOptions?.[key] as PaymentOptionsConfig[K];
};
const updatePaymentOverride = <K extends keyof PaymentOptionsConfig>(
key: K,
value: PaymentOptionsConfig[K] | null
) => {
setPaymentOverrides((prev) => ({ ...prev, [key]: value }));
setHasPaymentOverrides(true);
};
const handleSavePaymentOptions = async () => {
setSavingPayments(true);
try {
await paymentOptionsApi.updateEventOverrides(eventId, paymentOverrides);
toast.success(locale === 'es' ? 'Opciones de pago guardadas' : 'Payment options saved');
} catch (error: any) {
toast.error(error.message || 'Failed to save payment options');
} finally {
setSavingPayments(false);
}
};
const handleResetToGlobal = async () => {
if (!confirm(locale === 'es'
? '¿Resetear a la configuración global? Se eliminarán todas las personalizaciones de este evento.'
: 'Reset to global settings? This will remove all customizations for this event.')) {
return;
}
setSavingPayments(true);
try {
await paymentOptionsApi.deleteEventOverrides(eventId);
setPaymentOverrides({});
setHasPaymentOverrides(false);
toast.success(locale === 'es' ? 'Restablecido a configuración global' : 'Reset to global settings');
} catch (error: any) {
toast.error(error.message || 'Failed to reset payment options');
} finally {
setSavingPayments(false);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'America/Asuncion',
});
};
const formatDateShort = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'America/Asuncion',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
};
const formatCurrency = (amount: number, currency: string) => {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
};
const getTicketsByStatus = (status: string) => {
return tickets.filter(t => t.status === status);
};
const getFilteredRecipientCount = () => {
if (recipientFilter === 'all') return tickets.length;
return getTicketsByStatus(recipientFilter).length;
};
const getStatusBadge = (status: string, compact = false) => {
const styles: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
confirmed: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
checked_in: 'bg-blue-100 text-blue-800',
};
return (
<span className={clsx(
'inline-flex items-center rounded-full font-medium',
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
styles[status] || 'bg-gray-100 text-gray-800'
)}>
{status.replace('_', ' ')}
</span>
);
};
const handleMarkPaid = async (ticketId: string) => {
try {
await ticketsApi.markPaid(ticketId);
toast.success('Payment marked as received');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to mark payment');
}
};
const handleCheckin = async (ticketId: string) => {
try {
await ticketsApi.checkin(ticketId);
toast.success('Attendee checked in');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to check in');
}
};
const handleRemoveCheckin = async (ticketId: string) => {
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
try {
await ticketsApi.removeCheckin(ticketId);
toast.success('Check-in removed');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to remove check-in');
}
};
const handleOpenNoteModal = (ticket: Ticket) => {
setSelectedTicket(ticket);
setNoteText(ticket.adminNote || '');
setShowNoteModal(true);
};
const handleSaveNote = async () => {
if (!selectedTicket) return;
setSubmitting(true);
try {
await ticketsApi.updateNote(selectedTicket.id, noteText);
toast.success('Note saved');
setShowNoteModal(false);
setSelectedTicket(null);
setNoteText('');
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to save note');
} finally {
setSubmitting(false);
}
};
const handleAddAtDoor = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.adminCreate({
eventId: event.id,
firstName: addAtDoorForm.firstName,
lastName: addAtDoorForm.lastName || undefined,
email: addAtDoorForm.email,
phone: addAtDoorForm.phone,
autoCheckin: addAtDoorForm.autoCheckin,
adminNote: addAtDoorForm.adminNote || undefined,
});
toast.success(addAtDoorForm.autoCheckin ? 'Attendee added and checked in' : 'Attendee added');
setShowAddAtDoorModal(false);
setAddAtDoorForm({ firstName: '', lastName: '', email: '', phone: '', autoCheckin: true, adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to add attendee');
} finally {
setSubmitting(false);
}
};
const handleManualTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.manualCreate({
eventId: event.id,
firstName: manualTicketForm.firstName,
lastName: manualTicketForm.lastName || undefined,
email: manualTicketForm.email,
phone: manualTicketForm.phone || undefined,
adminNote: manualTicketForm.adminNote || undefined,
});
toast.success('Manual ticket created — confirmation email sent');
setShowManualTicketModal(false);
setManualTicketForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to create manual ticket');
} finally {
setSubmitting(false);
}
};
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
if (!event) return;
setExporting(true);
setShowExportDropdown(false);
try {
const { blob, filename } = await adminApi.exportAttendees(event.id, { status, format: 'csv', q: searchQuery || undefined });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.message || 'Failed to export attendees');
} finally {
setExporting(false);
}
};
const handleExportTickets = async (status: 'confirmed' | 'checked_in' | 'all') => {
if (!event) return;
setExporting(true);
setShowTicketExportDropdown(false);
try {
const { blob, filename } = await adminApi.exportTicketsCSV(event.id, { status, q: ticketSearchQuery || undefined });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.message || 'Failed to export tickets');
} finally {
setExporting(false);
}
};
// Filtered tickets for attendees tab
const filteredTickets = tickets.filter((ticket) => {
if (statusFilter !== 'all' && ticket.status !== statusFilter) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
(ticket.attendeeEmail?.toLowerCase().includes(query) || false) ||
(ticket.attendeePhone?.toLowerCase().includes(query) || false) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
// Filtered tickets for the Tickets tab (only confirmed/checked_in)
const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status));
const filteredConfirmedTickets = confirmedTickets.filter((ticket) => {
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) return false;
if (ticketSearchQuery) {
const query = ticketSearchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
const handlePreviewEmail = async () => {
if (!selectedTemplate) {
toast.error('Please select a template');
return;
}
try {
const res = await emailsApi.preview({
templateSlug: selectedTemplate,
variables: {
attendeeName: 'John Doe',
attendeeEmail: 'john@example.com',
ticketId: 'TKT-PREVIEW',
eventTitle: event?.title || '',
eventDate: event ? formatDate(event.startDatetime) : '',
eventTime: event ? formatTime(event.startDatetime) : '',
eventLocation: event?.location || '',
eventLocationUrl: event?.locationUrl || '',
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
customMessage: customMessage || 'Your custom message will appear here.',
},
locale,
});
setPreviewHtml(res.bodyHtml);
} catch (error) {
toast.error('Failed to preview email');
}
};
const handleSendEmail = async () => {
if (!selectedTemplate) {
toast.error('Please select a template');
return;
}
const recipientCount = getFilteredRecipientCount();
if (recipientCount === 0) {
toast.error('No recipients match the selected filter');
return;
}
if (!confirm(`Send email to ${recipientCount} ${recipientFilter === 'all' ? 'attendee(s)' : `${recipientFilter} attendee(s)`}?`)) {
return;
}
try {
const res = await emailsApi.sendToEvent(eventId, {
templateSlug: selectedTemplate,
recipientFilter,
customVariables: customMessage ? { customMessage } : undefined,
});
if (res.success) {
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
} else {
toast.error(res.error || 'Failed to queue emails');
}
} catch (error: any) {
toast.error(error.message || 'Failed to send emails');
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
if (!event) {
return (
<div className="text-center py-12">
<p className="text-gray-500">Event not found</p>
<Link href="/admin/events">
<Button variant="outline" className="mt-4">
Back to Events
</Button>
</Link>
</div>
);
}
const confirmedCount = getTicketsByStatus('confirmed').length;
const pendingCount = getTicketsByStatus('pending').length;
const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length;
const revenue = (confirmedCount + checkedInCount) * event.price;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
{ key: 'attendees', label: 'Attendees', icon: UserGroupIcon, count: tickets.length },
{ key: 'tickets', label: 'Tickets', icon: TicketIcon, count: confirmedTickets.length },
{ key: 'email', label: 'Email', icon: EnvelopeIcon },
{ key: 'payments', label: locale === 'es' ? 'Pagos' : 'Payments', icon: CreditCardIcon },
];
// ========== Primary action for a ticket ==========
const getPrimaryAction = (ticket: Ticket) => {
if (ticket.status === 'pending') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' as const };
}
if (ticket.status === 'confirmed') {
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' as const };
}
if (ticket.status === 'checked_in') {
return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline' as const, icon: ArrowUturnLeftIcon };
}
return null;
};
return (
<div className="max-w-full overflow-x-hidden">
{/* ============= HEADER ============= */}
<div className="flex items-start gap-3 mb-3">
<Link href="/admin/events" className="mt-1 flex-shrink-0">
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<ArrowLeftIcon className="w-5 h-5" />
</button>
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} &middot; {formatTime(event.startDatetime)}</p>
</div>
{/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
<Link href={`/events/${event.id}`} target="_blank">
<Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" />
View Public
</Button>
</Link>
<Link href={`/admin/events?edit=${event.id}`}>
<Button variant="outline" size="sm">
<PencilIcon className="w-4 h-4 mr-1.5" />
Edit
</Button>
</Link>
</div>
{/* Mobile header overflow menu */}
<div className="md:hidden flex-shrink-0">
<Dropdown
open={mobileHeaderMenuOpen}
onOpenChange={setMobileHeaderMenuOpen}
trigger={
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
}
>
<DropdownItem onClick={() => { window.open(`/events/${event.id}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
<EyeIcon className="w-4 h-4 mr-2" /> View Public
</DropdownItem>
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
</DropdownItem>
<DropdownItem onClick={() => { setShowStats(v => !v); setMobileHeaderMenuOpen(false); }}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</DropdownItem>
</Dropdown>
</div>
</div>
{/* ============= COMPACT META CHIPS (desktop) ============= */}
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CalendarIcon className="w-3.5 h-3.5" />
{formatDateShort(event.startDatetime)} {formatTime(event.startDatetime)}{event.endDatetime && ` ${formatTime(event.endDatetime)}`}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<MapPinIcon className="w-3.5 h-3.5" />
{event.location}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<CurrencyDollarIcon className="w-3.5 h-3.5" />
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
<UsersIcon className="w-3.5 h-3.5" />
{confirmedCount + checkedInCount}/{event.capacity}
</span>
</div>
{/* ============= STATS ROW ============= */}
{/* Desktop: always-visible compact 4-card row */}
<div className="hidden md:block mb-4">
{showStats && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600' },
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
<stat.icon className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
<p className="text-xs text-gray-500">{stat.label}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* Mobile: collapsible stats */}
<div className="md:hidden mb-3">
{showStats && (
<div>
<button
onClick={() => setMobileStatsExpanded(!mobileStatsExpanded)}
className="w-full flex items-center justify-between px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]"
>
<div className="flex items-center gap-3 overflow-x-auto">
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">Stats</span>
<span className="text-sm font-semibold whitespace-nowrap">{confirmedCount + checkedInCount}/{event.capacity}</span>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{checkedInCount} in</span>
<span className="text-xs text-gray-400">|</span>
<span className="text-sm whitespace-nowrap">{formatCurrency(revenue, event.currency)}</span>
</div>
<ChevronDownIcon className={clsx('w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ml-2', mobileStatsExpanded && 'rotate-180')} />
</button>
{mobileStatsExpanded && (
<div className="grid grid-cols-2 gap-2">
{[
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50' },
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
<p className="text-[10px] text-gray-500">{stat.label}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* ============= TABS + CONTENT ============= */}
{/* Unified container: tabs are visually connected to the content below */}
{/* ============= TAB BAR ============= */}
{/* Desktop: tab bar inside a card top-section */}
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200">
<div ref={tabBarRef} className="sticky top-0 z-30 bg-white rounded-t-2xl px-4">
<nav className="flex gap-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'py-2.5 px-3 font-medium text-sm transition-colors flex items-center gap-1.5 whitespace-nowrap relative',
activeTab === tab.key
? 'text-primary-dark'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg'
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
{tab.count !== undefined && <span className="text-xs text-gray-400">({tab.count})</span>}
{activeTab === tab.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-yellow rounded-full" />
)}
</button>
))}
</nav>
</div>
</div>
{/* Mobile: segmented tab bar */}
<div className="md:hidden sticky top-0 z-30 -mx-4 px-3 py-1.5 bg-gray-100">
<div className="overflow-x-auto scrollbar-hide">
<div className="flex gap-0.5 min-w-max">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'px-3 py-2 rounded-lg text-xs font-medium transition-all whitespace-nowrap min-h-[36px] flex items-center gap-1',
activeTab === tab.key
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
)}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
{tab.count !== undefined && <span className="text-[10px] opacity-70">({tab.count})</span>}
</button>
))}
</div>
</div>
</div>
{/* ============= TAB CONTENT ============= */}
{/* Desktop: content panel continues from the tab bar card (no top radius) */}
{/* Mobile: content flows directly below the segmented control */}
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
{/* ============= OVERVIEW TAB ============= */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="p-5">
<h3 className="font-semibold text-base mb-3">Event Information</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<CalendarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Date & Time</p>
<p className="text-sm text-gray-600">{formatDate(event.startDatetime)}</p>
<p className="text-sm text-gray-600">{formatTime(event.startDatetime)}{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPinIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Location</p>
<p className="text-sm text-gray-600">{event.location}</p>
{event.locationUrl && (
<a href={event.locationUrl} target="_blank" rel="noopener" className="text-blue-600 text-xs hover:underline">
View on Map
</a>
)}
</div>
</div>
<div className="flex items-start gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Price</p>
<p className="text-sm text-gray-600">{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Capacity</p>
<p className="text-sm text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
<p className="text-xs text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
</div>
</div>
</div>
</Card>
<Card className="p-5">
<h3 className="font-semibold text-base mb-3">Description</h3>
<div className="prose prose-sm max-w-none">
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.description}</p>
{event.descriptionEs && (
<>
<p className="font-medium text-sm mt-3">Spanish:</p>
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.descriptionEs}</p>
</>
)}
</div>
</Card>
{event.bannerUrl && (
<Card className="p-5 lg:col-span-2">
<h3 className="font-semibold text-base mb-3">Event Banner</h3>
<img
src={event.bannerUrl}
alt={event.title}
className="w-full max-h-64 object-cover rounded-lg"
/>
</Card>
)}
</div>
)}
{/* ============= ATTENDEES TAB ============= */}
{activeTab === 'attendees' && (
<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 any)}
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 &amp; 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>
</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">
{getStatusBadge(ticket.status, true)}
{ticket.checkinAt && (
<p className="text-[10px] text-gray-400 mt-0.5">
{new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}
</p>
)}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
</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">
{getStatusBadge(ticket.status, true)}
</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">
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
{ticket.checkinAt && ` · Checked in ${new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`}
</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>
)}
{/* ============= TICKETS TAB ============= */}
{activeTab === 'tickets' && (
<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 any)}
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 any)}
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 ? (
new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
})
) : (
<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 ${new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`
: '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>
)}
{/* ============= EMAIL TAB ============= */}
{activeTab === 'email' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="p-5">
<h3 className="font-semibold text-base mb-3">Send Email to Attendees</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Email Template</label>
<select
value={selectedTemplate}
onChange={(e) => setSelectedTemplate(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
>
<option value="">Select a template...</option>
{templates.map((template) => (
<option key={template.id} value={template.slug}>
{template.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Recipients</label>
<select
value={recipientFilter}
onChange={(e) => setRecipientFilter(e.target.value as any)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
>
<option value="all">All Attendees ({tickets.length})</option>
<option value="confirmed">Confirmed Only ({confirmedCount})</option>
<option value="pending">Pending Only ({pendingCount})</option>
<option value="checked_in">Checked In Only ({checkedInCount})</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Custom Message (optional)</label>
<textarea
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
rows={3}
placeholder="Add a custom message that will be included in the email..."
/>
<p className="text-[10px] text-gray-500 mt-1">
This message will replace the {`{{customMessage}}`} variable in the template.
</p>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<Button
variant="outline"
size="sm"
onClick={handlePreviewEmail}
disabled={!selectedTemplate}
className="min-h-[44px] md:min-h-0"
>
<EyeIcon className="w-4 h-4 mr-1.5" />
Preview
</Button>
<Button
size="sm"
onClick={handleSendEmail}
disabled={!selectedTemplate || getFilteredRecipientCount() === 0}
isLoading={sending}
className="min-h-[44px] md:min-h-0"
>
<PaperAirplaneIcon className="w-4 h-4 mr-1.5" />
Send to {getFilteredRecipientCount()} {getFilteredRecipientCount() === 1 ? 'person' : 'people'}
</Button>
</div>
</div>
</Card>
<Card className="p-5">
<h3 className="font-semibold text-base mb-3">Recipient Summary</h3>
<div className="space-y-2">
{[
{ label: 'Confirmed', count: confirmedCount, icon: CheckCircleIcon, color: 'text-green-500' },
{ label: 'Pending Payment', count: pendingCount, icon: ClockIcon, color: 'text-yellow-500' },
{ label: 'Checked In', count: checkedInCount, icon: TicketIcon, color: 'text-blue-500' },
{ label: 'Cancelled', count: cancelledCount, icon: XCircleIcon, color: 'text-red-500' },
].map((item) => (
<div key={item.label} className="flex items-center justify-between p-2.5 bg-gray-50 rounded-btn">
<div className="flex items-center gap-2">
<item.icon className={clsx('w-4 h-4', item.color)} />
<span className="text-sm">{item.label}</span>
</div>
<span className="font-semibold text-sm">{item.count}</span>
</div>
))}
<div className="border-t pt-2 mt-2">
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total Bookings</span>
<span>{tickets.length}</span>
</div>
</div>
</div>
</Card>
</div>
)}
{/* ============= PAYMENTS TAB ============= */}
{activeTab === 'payments' && (
<div className="space-y-4">
{loadingPayments ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
) : (
<>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h3 className="font-semibold text-base">
{locale === 'es' ? 'Métodos de Pago del Evento' : 'Event Payment Methods'}
</h3>
<p className="text-xs text-gray-500">
{hasPaymentOverrides
? (locale === 'es' ? 'Configuración personalizada' : 'Custom settings')
: (locale === 'es' ? 'Usando configuración global' : 'Using global settings')}
</p>
</div>
<div className="flex items-center gap-2">
{hasPaymentOverrides && (
<Button variant="outline" size="sm" onClick={handleResetToGlobal} disabled={savingPayments} className="min-h-[44px] md:min-h-0">
<ArrowPathIcon className="w-4 h-4 mr-1.5" />
{locale === 'es' ? 'Resetear' : 'Reset'}
</Button>
)}
<Button size="sm" onClick={handleSavePaymentOptions} isLoading={savingPayments} className="min-h-[44px] md:min-h-0">
{locale === 'es' ? 'Guardar' : 'Save'}
</Button>
</div>
</div>
{/* TPago */}
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<CreditCardIcon className="w-4 h-4 text-blue-600" />
</div>
<div>
<h4 className="font-semibold text-sm">
{locale === 'es' ? 'TPago / Tarjeta' : 'TPago / Card'}
</h4>
<p className="text-[10px] text-gray-500">
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{globalPaymentOptions && !globalPaymentOptions.tpagoEnabled && (
<span className="text-[10px] text-gray-400 hidden sm:inline">
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
</span>
)}
<button
onClick={() => updatePaymentOverride('tpagoEnabled', !getEffectivePaymentOption('tpagoEnabled'))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
getEffectivePaymentOption('tpagoEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
getEffectivePaymentOption('tpagoEnabled') ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
{getEffectivePaymentOption('tpagoEnabled') && (
<div className="space-y-3 pt-3 border-t">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
</label>
<input
type="url"
value={paymentOverrides.tpagoLink ?? ''}
onChange={(e) => updatePaymentOverride('tpagoLink', e.target.value || null)}
placeholder={globalPaymentOptions?.tpagoLink || 'https://www.tpago.com.py/links?alias=...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>
<textarea
value={paymentOverrides.tpagoInstructions ?? ''}
onChange={(e) => updatePaymentOverride('tpagoInstructions', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.tpagoInstructions || 'Instructions for users...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Instrucciones (ES)</label>
<textarea
value={paymentOverrides.tpagoInstructionsEs ?? ''}
onChange={(e) => updatePaymentOverride('tpagoInstructionsEs', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.tpagoInstructionsEs || 'Instrucciones para usuarios...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
</div>
<p className="text-[10px] text-gray-400">
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
</p>
</div>
)}
</div>
</Card>
{/* Bank Transfer */}
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0">
<BuildingLibraryIcon className="w-4 h-4 text-green-600" />
</div>
<div>
<h4 className="font-semibold text-sm">
{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}
</h4>
<p className="text-[10px] text-gray-500">
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{globalPaymentOptions && !globalPaymentOptions.bankTransferEnabled && (
<span className="text-[10px] text-gray-400 hidden sm:inline">
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
</span>
)}
<button
onClick={() => updatePaymentOverride('bankTransferEnabled', !getEffectivePaymentOption('bankTransferEnabled'))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
getEffectivePaymentOption('bankTransferEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
getEffectivePaymentOption('bankTransferEnabled') ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
{getEffectivePaymentOption('bankTransferEnabled') && (
<div className="space-y-3 pt-3 border-t">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{ label: locale === 'es' ? 'Banco' : 'Bank Name', key: 'bankName' as const, placeholder: 'e.g., Banco Itaú' },
{ label: locale === 'es' ? 'Titular' : 'Account Holder', key: 'bankAccountHolder' as const, placeholder: 'e.g., Juan Pérez' },
{ label: locale === 'es' ? 'N° Cuenta' : 'Account Number', key: 'bankAccountNumber' as const, placeholder: 'e.g., 1234567890' },
{ label: 'Alias', key: 'bankAlias' as const, placeholder: 'e.g., spanglish.pagos' },
{ label: locale === 'es' ? 'Teléfono' : 'Phone', key: 'bankPhone' as const, placeholder: '+595 981 123456' },
].map((field) => (
<div key={field.key}>
<label className="block text-xs font-medium text-gray-700 mb-1">{field.label}</label>
<input
type="text"
value={(paymentOverrides as any)[field.key] ?? ''}
onChange={(e) => updatePaymentOverride(field.key, e.target.value || null)}
placeholder={(globalPaymentOptions as any)?.[field.key] || field.placeholder}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Notes (EN)</label>
<textarea
value={paymentOverrides.bankNotes ?? ''}
onChange={(e) => updatePaymentOverride('bankNotes', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.bankNotes || 'Additional notes...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Notas (ES)</label>
<textarea
value={paymentOverrides.bankNotesEs ?? ''}
onChange={(e) => updatePaymentOverride('bankNotesEs', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.bankNotesEs || 'Notas adicionales...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
</div>
<p className="text-[10px] text-gray-400">
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
</p>
</div>
)}
</div>
</Card>
{/* Bitcoin Lightning */}
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
<BoltIcon className="w-4 h-4 text-orange-600" />
</div>
<div>
<h4 className="font-semibold text-sm">Bitcoin Lightning</h4>
<p className="text-[10px] text-gray-500">
{locale === 'es' ? 'Confirmación automática' : 'Auto confirmation'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{globalPaymentOptions && !globalPaymentOptions.lightningEnabled && (
<span className="text-[10px] text-gray-400 hidden sm:inline">
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
</span>
)}
<button
onClick={() => updatePaymentOverride('lightningEnabled', !getEffectivePaymentOption('lightningEnabled'))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
getEffectivePaymentOption('lightningEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
getEffectivePaymentOption('lightningEnabled') ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
{getEffectivePaymentOption('lightningEnabled') && (
<div className="pt-3 border-t mt-3">
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
<p className="text-xs text-orange-800">
{locale === 'es'
? 'Lightning configurado vía LNbits. No personalizable por evento.'
: 'Lightning is configured via LNbits. Cannot be customized per event.'}
</p>
</div>
</div>
)}
</div>
</Card>
{/* Cash at Door */}
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
<BanknotesIcon className="w-4 h-4 text-yellow-600" />
</div>
<div>
<h4 className="font-semibold text-sm">
{locale === 'es' ? 'Efectivo' : 'Cash at Door'}
</h4>
<p className="text-[10px] text-gray-500">
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{globalPaymentOptions && !globalPaymentOptions.cashEnabled && (
<span className="text-[10px] text-gray-400 hidden sm:inline">
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
</span>
)}
<button
onClick={() => updatePaymentOverride('cashEnabled', !getEffectivePaymentOption('cashEnabled'))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
getEffectivePaymentOption('cashEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
getEffectivePaymentOption('cashEnabled') ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
{getEffectivePaymentOption('cashEnabled') && (
<div className="space-y-3 pt-3 border-t">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>
<textarea
value={paymentOverrides.cashInstructions ?? ''}
onChange={(e) => updatePaymentOverride('cashInstructions', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.cashInstructions || 'Cash payment instructions...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Instrucciones (ES)</label>
<textarea
value={paymentOverrides.cashInstructionsEs ?? ''}
onChange={(e) => updatePaymentOverride('cashInstructionsEs', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.cashInstructionsEs || 'Instrucciones de pago en efectivo...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
</div>
<p className="text-[10px] text-gray-400">
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
</p>
</div>
)}
</div>
</Card>
{/* Summary */}
<Card>
<div className="p-4 md:p-5">
<h4 className="font-semibold text-sm mb-3">
{locale === 'es' ? 'Resumen' : 'Active Methods'}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'TPago', enabled: getEffectivePaymentOption('tpagoEnabled') },
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
].map((method) => (
<div key={method.label} className="flex items-center gap-1.5">
{method.enabled ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<XCircleIcon className="w-4 h-4 text-gray-300" />
)}
<span className={clsx('text-sm', method.enabled ? 'text-gray-900' : 'text-gray-400')}>
{method.label}
</span>
</div>
))}
</div>
{hasPaymentOverrides && (
<p className="text-[10px] text-gray-500 mt-3 flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 bg-primary-yellow rounded-full" />
{locale === 'es'
? 'Configuración personalizada activa'
: 'Custom settings override global defaults'}
</p>
)}
</div>
</Card>
</>
)}
</div>
)}
</div>
{/* ============= MODALS ============= */}
{/* Mobile filter bottom sheet */}
<BottomSheet
open={mobileFilterOpen}
onClose={() => setMobileFilterOpen(false)}
title="Filter by Status"
>
<div className="space-y-1">
{[
{ value: 'all', label: `All (${tickets.length})` },
{ value: 'pending', label: `Pending (${pendingCount})` },
{ value: 'confirmed', label: `Confirmed (${confirmedCount})` },
{ value: 'checked_in', label: `Checked In (${checkedInCount})` },
{ value: 'cancelled', label: `Cancelled (${cancelledCount})` },
].map((option) => (
<button
key={option.value}
onClick={() => { setStatusFilter(option.value as any); setMobileFilterOpen(false); }}
className={clsx(
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
statusFilter === option.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}
>
{option.label}
{statusFilter === option.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</BottomSheet>
{/* Mobile FAB bottom sheet */}
<BottomSheet
open={showAddTicketSheet}
onClose={() => setShowAddTicketSheet(false)}
title="Add Ticket"
>
<div className="space-y-1">
<button
onClick={() => { setShowManualTicketModal(true); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<EnvelopeIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Manual Ticket</p>
<p className="text-xs text-gray-500">Send confirmation email with ticket</p>
</div>
</button>
<button
onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<PlusIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Add at Door</p>
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
</div>
</button>
</div>
</BottomSheet>
{/* Mobile export bottom sheet (attendees) */}
<BottomSheet
open={showExportSheet}
onClose={() => setShowExportSheet(false)}
title="Export Attendees"
>
<div className="space-y-1">
{[
{ status: 'all' as const, label: 'Export All' },
{ status: 'confirmed' as const, label: 'Export Confirmed' },
{ status: 'checked_in' as const, label: 'Export Checked-in' },
{ status: 'confirmed_pending' as const, label: 'Confirmed & Pending' },
].map((opt) => (
<button
key={opt.status}
onClick={() => { handleExportAttendees(opt.status); setShowExportSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px]"
>
{opt.label}
</button>
))}
<p className="text-[10px] text-gray-400 px-4 pt-2">Format: CSV</p>
</div>
</BottomSheet>
{/* Mobile export bottom sheet (tickets) */}
<BottomSheet
open={showTicketExportSheet}
onClose={() => setShowTicketExportSheet(false)}
title="Export Tickets"
>
<div className="space-y-1">
{[
{ status: 'all' as const, label: 'Export All' },
{ status: 'confirmed' as const, label: 'Export Valid' },
{ status: 'checked_in' as const, label: 'Export Checked-in' },
].map((opt) => (
<button
key={opt.status}
onClick={() => { handleExportTickets(opt.status); setShowTicketExportSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px]"
>
{opt.label}
</button>
))}
<p className="text-[10px] text-gray-400 px-4 pt-2">Format: CSV</p>
</div>
</BottomSheet>
{/* Add at Door Modal */}
{showAddAtDoorModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowAddAtDoorModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
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-base font-bold">Add Attendee at Door</h2>
<button
onClick={() => setShowAddAtDoorModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleAddAtDoor} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={addAtDoorForm.firstName}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, firstName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={addAtDoorForm.lastName}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, lastName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={addAtDoorForm.email}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, email: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="email@example.com" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={addAtDoorForm.phone}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, phone: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={addAtDoorForm.adminNote}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, adminNote: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} placeholder="Internal note..." />
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="autoCheckin" checked={addAtDoorForm.autoCheckin}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, autoCheckin: e.target.checked })}
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
<label htmlFor="autoCheckin" className="text-sm font-medium">Auto check-in immediately</label>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowAddAtDoorModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">Add Attendee</Button>
</div>
</form>
</Card>
</div>
)}
{/* Manual Ticket Modal */}
{showManualTicketModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowManualTicketModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
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-base font-bold">Create Manual Ticket</h2>
<p className="text-xs text-gray-500">Confirmation email will be sent</p>
</div>
<button onClick={() => setShowManualTicketModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleManualTicket} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={manualTicketForm.firstName}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, firstName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={manualTicketForm.lastName}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email *</label>
<input type="email" required value={manualTicketForm.email}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="email@example.com" />
<p className="text-[10px] text-gray-500 mt-1">Ticket will be sent to this email</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={manualTicketForm.phone}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={manualTicketForm.adminNote}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} placeholder="Internal note..." />
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<EnvelopeIcon className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="text-xs text-blue-800">
<p className="font-medium">This will send:</p>
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
<li>Booking confirmation email</li>
<li>Ticket with QR code</li>
</ul>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowManualTicketModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<EnvelopeIcon className="w-4 h-4 mr-1.5" />
Create & Send
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Note Modal */}
{showNoteModal && selectedTicket && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<Card className="w-full md:max-w-md rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<div>
<h2 className="text-base font-bold">Admin Note</h2>
<p className="text-xs text-gray-500">{selectedTicket.attendeeFirstName} {selectedTicket.attendeeLastName || ''}</p>
</div>
<button onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-3">
<div>
<label className="block text-xs font-medium mb-1">Note</label>
<textarea value={noteText} onChange={(e) => setNoteText(e.target.value)}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={4} placeholder="Add a private note..." maxLength={1000} />
<p className="text-[10px] text-gray-400 mt-1 text-right">{noteText.length}/1000</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }} className="flex-1 min-h-[44px]">Cancel</Button>
<Button onClick={handleSaveNote} isLoading={submitting} className="flex-1 min-h-[44px]">Save Note</Button>
</div>
</div>
</Card>
</div>
)}
{/* Preview Modal */}
{previewHtml && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<h2 className="text-base font-bold">Email Preview</h2>
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)} className="min-h-[44px] md:min-h-0">Close</Button>
</div>
<div className="flex-1 overflow-auto">
<iframe srcDoc={previewHtml} className="w-full h-full min-h-[500px]" title="Email Preview" />
</div>
</Card>
</div>
)}
<AdminMobileStyles />
</div>
);
}