Most attendees arrive without their QR open, and taking money at the door meant leaving the scanner for the event dashboard, where the Add Ticket modal demanded an email and recorded no payment method. The screen now leads with manual name search, keeps the camera one tap away behind a fullscreen overlay, and creates and charges walk-ins inline. Check-in and payment are one action: anything done here is born confirmed, paid (or comp) and checked in through a single endpoint, POST /api/events/:eventId/door-checkin. There are no confirm dialogs anywhere, because they stall the queue; a ten-second Undo replaces them, reversing exactly what the action changed via the undo state recorded alongside its idempotency key. Writes fire in the background with retries, so venue wifi never blocks the person at the door, and a capacity limit only warns, since staff at the door are the authority. Every write carries a client-generated idempotency key, inserted in the same transaction as the writes it guards, so a double tap or a retry after a timeout cannot produce a second ticket, payment or check-in. Search runs entirely in memory over one preloaded list: names are matched accent- and case-insensitively in both directions, per word, prefix before substring, with a mostly-numeric query searching phone digits so two people with the same name can be told apart. Door money is recorded as payments.source 'door' plus payments.method (cash, bitcoin, transfer or guest) while provider keeps its existing value, so capacity counting, the stale-booking sweeps and the admin payment lists are unaffected and revenue can still be split pre-sale versus door. Bitcoin records the payment as made, on the same trust model as cash, with no invoice generated; lib/doorPayments.ts is where a real Lightning flow slots in later. Also fixes the SQLite tickets DDL, which still created the pre-split attendee_name column with NOT NULL email and phone. Only fresh databases were affected -- existing ones were relaxed by later ALTERs -- but on those, door walk-ins (and any other ticket) could not be inserted at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
791 lines
32 KiB
TypeScript
791 lines
32 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,
|
||
UserGroupIcon,
|
||
CreditCardIcon,
|
||
ChevronDownIcon,
|
||
EllipsisVerticalIcon,
|
||
ArrowUturnLeftIcon,
|
||
} from '@heroicons/react/24/outline';
|
||
import toast from 'react-hot-toast';
|
||
import clsx from 'clsx';
|
||
import { usePrivacy } from '@/context/PrivacyContext';
|
||
import type {
|
||
TabType,
|
||
AttendeeStatusFilter,
|
||
TicketStatusFilter,
|
||
RecipientFilter,
|
||
AddTicketType,
|
||
AddTicketFormState,
|
||
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 { AddTicketModal } from './_modals/AddTicketModal';
|
||
import EventFormModal from '../_components/EventFormModal';
|
||
|
||
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
|
||
type: 'paid',
|
||
firstName: '',
|
||
lastName: '',
|
||
email: '',
|
||
phone: '',
|
||
adminNote: '',
|
||
checkinNow: false,
|
||
};
|
||
|
||
export default function AdminEventDetailPage() {
|
||
const params = useParams();
|
||
const eventId = params.id as string;
|
||
const { locale } = useLanguage();
|
||
|
||
const { loading, event, tickets, templates, doorSummary, 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 { privacyMode } = usePrivacy();
|
||
const showStats = !privacyMode;
|
||
const [showNoteModal, setShowNoteModal] = useState(false);
|
||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||
const [noteText, setNoteText] = useState('');
|
||
// Unified Add Ticket modal (paid / door / unpaid / guest via segmented control)
|
||
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
|
||
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const openAddTicket = (type: AddTicketType) => {
|
||
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
|
||
setShowAddTicketModal(true);
|
||
};
|
||
|
||
// 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 handleAddTicket = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!event) return;
|
||
setSubmitting(true);
|
||
try {
|
||
const res = await ticketsApi.adminAdd({
|
||
eventId: event.id,
|
||
type: addTicketForm.type,
|
||
firstName: addTicketForm.firstName || undefined,
|
||
lastName: addTicketForm.lastName || undefined,
|
||
email: addTicketForm.email || undefined,
|
||
phone: addTicketForm.phone || undefined,
|
||
checkinNow: addTicketForm.checkinNow,
|
||
adminNote: addTicketForm.adminNote || undefined,
|
||
});
|
||
toast.success(res.message || 'Ticket created');
|
||
setShowAddTicketModal(false);
|
||
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
|
||
loadEventData();
|
||
} catch (error: any) {
|
||
toast.error(error.message || 'Failed to add 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 });
|
||
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;
|
||
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
|
||
// tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
|
||
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
|
||
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
|
||
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
|
||
// Door sales can be taken at a custom amount (someone paying for their whole
|
||
// group), so once the door summary is loaded it is the authority on the total:
|
||
// pre-sale tickets at face value plus whatever was actually taken on the night.
|
||
const presaleRevenue = doorSummary
|
||
? doorSummary.presale.total
|
||
: (paidConfirmedCount + paidCheckedInCount) * event.price;
|
||
const doorRevenue = doorSummary?.door.total ?? 0;
|
||
const revenue = presaleRevenue + doorRevenue;
|
||
|
||
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') {
|
||
// Unpaid tickets resolve their balance first; check-in stays available via scanner
|
||
if (ticket.paymentStatus === 'unpaid') {
|
||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||
}
|
||
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">
|
||
<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>
|
||
</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',
|
||
detail: doorSummary
|
||
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
|
||
: undefined,
|
||
},
|
||
].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 truncate">{('detail' in stat && stat.detail) || 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',
|
||
detail: doorSummary
|
||
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
|
||
: undefined,
|
||
},
|
||
].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 truncate">{('detail' in stat && stat.detail) || 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}
|
||
openAddTicket={openAddTicket}
|
||
setMobileFilterOpen={setMobileFilterOpen}
|
||
setShowExportSheet={setShowExportSheet}
|
||
setShowAddTicketSheet={setShowAddTicketSheet}
|
||
getPrimaryAction={getPrimaryAction}
|
||
handleOpenNoteModal={handleOpenNoteModal}
|
||
handleReactivate={handleReactivate}
|
||
handleMarkPaid={handleMarkPaid}
|
||
handleCheckin={handleCheckin}
|
||
/>
|
||
)}
|
||
|
||
{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} doorSummary={doorSummary} />
|
||
)}
|
||
</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}
|
||
openAddTicket={openAddTicket}
|
||
showExportSheet={showExportSheet}
|
||
setShowExportSheet={setShowExportSheet}
|
||
handleExportAttendees={handleExportAttendees}
|
||
showTicketExportSheet={showTicketExportSheet}
|
||
setShowTicketExportSheet={setShowTicketExportSheet}
|
||
handleExportTickets={handleExportTickets}
|
||
submitting={submitting}
|
||
showNoteModal={showNoteModal}
|
||
setShowNoteModal={setShowNoteModal}
|
||
selectedTicket={selectedTicket}
|
||
setSelectedTicket={setSelectedTicket}
|
||
noteText={noteText}
|
||
setNoteText={setNoteText}
|
||
handleSaveNote={handleSaveNote}
|
||
previewHtml={previewHtml}
|
||
setPreviewHtml={setPreviewHtml}
|
||
/>
|
||
|
||
<AddTicketModal
|
||
open={showAddTicketModal}
|
||
onClose={() => setShowAddTicketModal(false)}
|
||
form={addTicketForm}
|
||
setForm={setAddTicketForm}
|
||
onSubmit={handleAddTicket}
|
||
submitting={submitting}
|
||
eventPriceLabel={event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
||
/>
|
||
|
||
<EventFormModal
|
||
open={showEditForm}
|
||
event={event}
|
||
featuredEventId={featuredEventId}
|
||
onFeaturedChange={setFeaturedEventId}
|
||
onClose={() => setShowEditForm(false)}
|
||
onSaved={() => { setShowEditForm(false); loadEventData(); }}
|
||
/>
|
||
|
||
<AdminMobileStyles />
|
||
</div>
|
||
);
|
||
}
|