- 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>
2092 lines
103 KiB
TypeScript
2092 lines
103 KiB
TypeScript
'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)} · {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 & 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>
|
||
);
|
||
}
|