Add reusable Skeleton components and route-level loading files across public pages and admin lists so layouts stay stable while data loads. Co-authored-by: Cursor <cursoragent@cursor.com>
826 lines
33 KiB
TypeScript
826 lines
33 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import { useParams } from 'next/navigation';
|
||
import Link from 'next/link';
|
||
import { useLanguage } from '@/context/LanguageContext';
|
||
import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket } from '@/lib/api';
|
||
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
||
import Card from '@/components/ui/Card';
|
||
import Button from '@/components/ui/Button';
|
||
import { Skeleton, TableSkeleton } from '@/components/ui/Skeleton';
|
||
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||
import {
|
||
ArrowLeftIcon,
|
||
CalendarIcon,
|
||
MapPinIcon,
|
||
CurrencyDollarIcon,
|
||
UsersIcon,
|
||
TicketIcon,
|
||
CheckCircleIcon,
|
||
EnvelopeIcon,
|
||
PencilIcon,
|
||
EyeIcon,
|
||
EyeSlashIcon,
|
||
UserGroupIcon,
|
||
CreditCardIcon,
|
||
ChevronDownIcon,
|
||
EllipsisVerticalIcon,
|
||
ArrowUturnLeftIcon,
|
||
} from '@heroicons/react/24/outline';
|
||
import toast from 'react-hot-toast';
|
||
import clsx from 'clsx';
|
||
import { useStatsPrivacy } from '@/hooks/useStatsPrivacy';
|
||
import type {
|
||
TabType,
|
||
AttendeeStatusFilter,
|
||
TicketStatusFilter,
|
||
RecipientFilter,
|
||
AttendeeFormState,
|
||
AddAtDoorFormState,
|
||
PrimaryAction,
|
||
} from './_types';
|
||
import { formatCurrency, downloadBlob } from './_utils/format';
|
||
import { useEventDetailData } from './_hooks/useEventDetailData';
|
||
import { usePaymentOverrides } from './_hooks/usePaymentOverrides';
|
||
import { OverviewTab } from './_tabs/OverviewTab';
|
||
import { AttendeesTab } from './_tabs/AttendeesTab';
|
||
import { TicketsTab } from './_tabs/TicketsTab';
|
||
import { EmailTab } from './_tabs/EmailTab';
|
||
import { PaymentsTab } from './_tabs/PaymentsTab';
|
||
import { EventModals } from './_modals/EventModals';
|
||
import EventFormModal from '../_components/EventFormModal';
|
||
|
||
export default function AdminEventDetailPage() {
|
||
const params = useParams();
|
||
const eventId = params.id as string;
|
||
const { locale } = useLanguage();
|
||
|
||
const { loading, event, tickets, templates, loadEventData } = useEventDetailData(eventId);
|
||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||
|
||
// Email state
|
||
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
||
const [recipientFilter, setRecipientFilter] = useState<RecipientFilter>('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<AttendeeStatusFilter>('all');
|
||
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
||
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
||
const [showStats, , toggleStats] = useStatsPrivacy();
|
||
const [showNoteModal, setShowNoteModal] = useState(false);
|
||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||
const [noteText, setNoteText] = useState('');
|
||
const [addAtDoorForm, setAddAtDoorForm] = useState<AddAtDoorFormState>({
|
||
firstName: '',
|
||
lastName: '',
|
||
email: '',
|
||
phone: '',
|
||
autoCheckin: true,
|
||
adminNote: '',
|
||
});
|
||
const [manualTicketForm, setManualTicketForm] = useState<AttendeeFormState>({
|
||
firstName: '',
|
||
lastName: '',
|
||
email: '',
|
||
phone: '',
|
||
adminNote: '',
|
||
});
|
||
const [showInviteGuestModal, setShowInviteGuestModal] = useState(false);
|
||
const [inviteGuestForm, setInviteGuestForm] = useState<AttendeeFormState>({
|
||
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<TicketStatusFilter>('all');
|
||
|
||
// Payment options state + handlers
|
||
const payments = usePaymentOverrides(eventId, locale);
|
||
|
||
// Edit event modal (opens in place instead of redirecting to the list page)
|
||
const [showEditForm, setShowEditForm] = useState(false);
|
||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
siteSettingsApi
|
||
.get()
|
||
.then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null))
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
// 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(() => {
|
||
if (activeTab === 'payments') {
|
||
payments.loadPaymentOptions();
|
||
}
|
||
}, [activeTab]);
|
||
|
||
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
|
||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||
|
||
const getTicketsByStatus = (status: string) => {
|
||
return tickets.filter(t => t.status === status);
|
||
};
|
||
|
||
const getFilteredRecipientCount = () => {
|
||
if (recipientFilter === 'all') return tickets.length;
|
||
return getTicketsByStatus(recipientFilter).length;
|
||
};
|
||
|
||
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 handleReactivate = async (ticket: Ticket) => {
|
||
if (!ticket.payment?.id) return;
|
||
try {
|
||
await paymentsApi.reactivate(ticket.payment.id);
|
||
toast.success('Booking reactivated');
|
||
loadEventData();
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to reactivate booking');
|
||
}
|
||
};
|
||
|
||
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 handleInviteGuest = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!event) return;
|
||
setSubmitting(true);
|
||
try {
|
||
await ticketsApi.guestCreate({
|
||
eventId: event.id,
|
||
firstName: inviteGuestForm.firstName,
|
||
lastName: inviteGuestForm.lastName || undefined,
|
||
email: inviteGuestForm.email || undefined,
|
||
phone: inviteGuestForm.phone || undefined,
|
||
adminNote: inviteGuestForm.adminNote || undefined,
|
||
});
|
||
toast.success('Guest invited successfully');
|
||
setShowInviteGuestModal(false);
|
||
setInviteGuestForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
|
||
loadEventData();
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to invite guest');
|
||
} 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 });
|
||
downloadBlob(blob, filename);
|
||
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 });
|
||
downloadBlob(blob, filename);
|
||
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 ? fmtTime(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;
|
||
}
|
||
|
||
setSending(true);
|
||
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');
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-6">
|
||
<Skeleton className="h-8 w-64" />
|
||
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||
</div>
|
||
<TableSkeleton rows={5} cols={4} />
|
||
</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 onHoldCount = getTicketsByStatus('on_hold').length;
|
||
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
|
||
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
|
||
const revenue = (paidConfirmedCount + paidCheckedInCount) * 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): PrimaryAction | null => {
|
||
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
|
||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||
}
|
||
if (ticket.status === 'confirmed') {
|
||
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
|
||
}
|
||
if (ticket.status === 'checked_in') {
|
||
return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline', 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)} · {fmtTime(event.startDatetime)}</p>
|
||
</div>
|
||
{/* Desktop header actions */}
|
||
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
||
<Button variant="outline" size="sm" onClick={toggleStats} title={showStats ? 'Hide stats' : 'Show stats'}>
|
||
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
|
||
{showStats ? 'Hide Stats' : 'Show Stats'}
|
||
</Button>
|
||
<Link href={`/events/${event.slug}`} target="_blank">
|
||
<Button variant="outline" size="sm">
|
||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||
View Public
|
||
</Button>
|
||
</Link>
|
||
<Button variant="outline" size="sm" onClick={() => setShowEditForm(true)}>
|
||
<PencilIcon className="w-4 h-4 mr-1.5" />
|
||
Edit
|
||
</Button>
|
||
</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.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
|
||
<EyeIcon className="w-4 h-4 mr-2" /> View Public
|
||
</DropdownItem>
|
||
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
|
||
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
||
</DropdownItem>
|
||
<DropdownItem onClick={() => { toggleStats(); 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)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(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>
|
||
{showStats && (
|
||
<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>
|
||
|
||
{/* ============= 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 ============= */}
|
||
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
|
||
{activeTab === 'overview' && (
|
||
<OverviewTab
|
||
event={event}
|
||
formatDate={formatDate}
|
||
fmtTime={fmtTime}
|
||
formatCurrency={formatCurrency}
|
||
confirmedCount={confirmedCount}
|
||
checkedInCount={checkedInCount}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'attendees' && (
|
||
<AttendeesTab
|
||
locale={locale}
|
||
tickets={tickets}
|
||
filteredTickets={filteredTickets}
|
||
searchQuery={searchQuery}
|
||
setSearchQuery={setSearchQuery}
|
||
statusFilter={statusFilter}
|
||
setStatusFilter={setStatusFilter}
|
||
pendingCount={pendingCount}
|
||
confirmedCount={confirmedCount}
|
||
checkedInCount={checkedInCount}
|
||
cancelledCount={cancelledCount}
|
||
onHoldCount={onHoldCount}
|
||
exporting={exporting}
|
||
showExportDropdown={showExportDropdown}
|
||
setShowExportDropdown={setShowExportDropdown}
|
||
showAddTicketDropdown={showAddTicketDropdown}
|
||
setShowAddTicketDropdown={setShowAddTicketDropdown}
|
||
handleExportAttendees={handleExportAttendees}
|
||
setShowManualTicketModal={setShowManualTicketModal}
|
||
setShowAddAtDoorModal={setShowAddAtDoorModal}
|
||
setShowInviteGuestModal={setShowInviteGuestModal}
|
||
setMobileFilterOpen={setMobileFilterOpen}
|
||
setShowExportSheet={setShowExportSheet}
|
||
setShowAddTicketSheet={setShowAddTicketSheet}
|
||
getPrimaryAction={getPrimaryAction}
|
||
handleOpenNoteModal={handleOpenNoteModal}
|
||
handleReactivate={handleReactivate}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'tickets' && (
|
||
<TicketsTab
|
||
locale={locale}
|
||
confirmedTickets={confirmedTickets}
|
||
filteredConfirmedTickets={filteredConfirmedTickets}
|
||
ticketSearchQuery={ticketSearchQuery}
|
||
setTicketSearchQuery={setTicketSearchQuery}
|
||
ticketStatusFilter={ticketStatusFilter}
|
||
setTicketStatusFilter={setTicketStatusFilter}
|
||
confirmedCount={confirmedCount}
|
||
checkedInCount={checkedInCount}
|
||
exporting={exporting}
|
||
showTicketExportDropdown={showTicketExportDropdown}
|
||
setShowTicketExportDropdown={setShowTicketExportDropdown}
|
||
handleExportTickets={handleExportTickets}
|
||
handleCheckin={handleCheckin}
|
||
handleRemoveCheckin={handleRemoveCheckin}
|
||
setShowTicketExportSheet={setShowTicketExportSheet}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'email' && (
|
||
<EmailTab
|
||
templates={templates}
|
||
selectedTemplate={selectedTemplate}
|
||
setSelectedTemplate={setSelectedTemplate}
|
||
recipientFilter={recipientFilter}
|
||
setRecipientFilter={setRecipientFilter}
|
||
customMessage={customMessage}
|
||
setCustomMessage={setCustomMessage}
|
||
sending={sending}
|
||
handlePreviewEmail={handlePreviewEmail}
|
||
handleSendEmail={handleSendEmail}
|
||
getFilteredRecipientCount={getFilteredRecipientCount}
|
||
ticketsCount={tickets.length}
|
||
confirmedCount={confirmedCount}
|
||
pendingCount={pendingCount}
|
||
checkedInCount={checkedInCount}
|
||
cancelledCount={cancelledCount}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'payments' && (
|
||
<PaymentsTab locale={locale} payments={payments} />
|
||
)}
|
||
</div>
|
||
|
||
{/* ============= MODALS ============= */}
|
||
<EventModals
|
||
ticketsCount={tickets.length}
|
||
pendingCount={pendingCount}
|
||
confirmedCount={confirmedCount}
|
||
checkedInCount={checkedInCount}
|
||
cancelledCount={cancelledCount}
|
||
onHoldCount={onHoldCount}
|
||
statusFilter={statusFilter}
|
||
setStatusFilter={setStatusFilter}
|
||
mobileFilterOpen={mobileFilterOpen}
|
||
setMobileFilterOpen={setMobileFilterOpen}
|
||
showAddTicketSheet={showAddTicketSheet}
|
||
setShowAddTicketSheet={setShowAddTicketSheet}
|
||
showExportSheet={showExportSheet}
|
||
setShowExportSheet={setShowExportSheet}
|
||
handleExportAttendees={handleExportAttendees}
|
||
showTicketExportSheet={showTicketExportSheet}
|
||
setShowTicketExportSheet={setShowTicketExportSheet}
|
||
handleExportTickets={handleExportTickets}
|
||
showAddAtDoorModal={showAddAtDoorModal}
|
||
setShowAddAtDoorModal={setShowAddAtDoorModal}
|
||
addAtDoorForm={addAtDoorForm}
|
||
setAddAtDoorForm={setAddAtDoorForm}
|
||
handleAddAtDoor={handleAddAtDoor}
|
||
showManualTicketModal={showManualTicketModal}
|
||
setShowManualTicketModal={setShowManualTicketModal}
|
||
manualTicketForm={manualTicketForm}
|
||
setManualTicketForm={setManualTicketForm}
|
||
handleManualTicket={handleManualTicket}
|
||
showInviteGuestModal={showInviteGuestModal}
|
||
setShowInviteGuestModal={setShowInviteGuestModal}
|
||
inviteGuestForm={inviteGuestForm}
|
||
setInviteGuestForm={setInviteGuestForm}
|
||
handleInviteGuest={handleInviteGuest}
|
||
submitting={submitting}
|
||
showNoteModal={showNoteModal}
|
||
setShowNoteModal={setShowNoteModal}
|
||
selectedTicket={selectedTicket}
|
||
setSelectedTicket={setSelectedTicket}
|
||
noteText={noteText}
|
||
setNoteText={setNoteText}
|
||
handleSaveNote={handleSaveNote}
|
||
previewHtml={previewHtml}
|
||
setPreviewHtml={setPreviewHtml}
|
||
/>
|
||
|
||
<EventFormModal
|
||
open={showEditForm}
|
||
event={event}
|
||
featuredEventId={featuredEventId}
|
||
onFeaturedChange={setFeaturedEventId}
|
||
onClose={() => setShowEditForm(false)}
|
||
onSaved={() => { setShowEditForm(false); loadEventData(); }}
|
||
/>
|
||
|
||
<AdminMobileStyles />
|
||
</div>
|
||
);
|
||
}
|