Refactor monolithic modules and harden booking, email, and auth infrastructure.

Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-06-25 07:12:59 +00:00
co-authored by Cursor
parent f0e2de2834
commit 613bd7be1d
75 changed files with 7702 additions and 5580 deletions
@@ -0,0 +1,19 @@
import clsx from 'clsx';
export function StatusBadge({ status, compact = false }: { status: string; compact?: boolean }) {
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>
);
}
@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api';
/**
* Loads the core data for the admin event detail page (event, tickets, active
* email templates) and exposes a reload function used after mutations.
*/
export function useEventDetailData(eventId: string) {
const [loading, setLoading] = useState(true);
const [event, setEvent] = useState<Event | null>(null);
const [tickets, setTickets] = useState<Ticket[]>([]);
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
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);
}
};
useEffect(() => {
loadEventData();
}, [eventId]);
return { loading, event, tickets, templates, loadEventData };
}
@@ -0,0 +1,96 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { paymentOptionsApi, PaymentOptionsConfig } from '@/lib/api';
/**
* Manages the event-level payment override editor state: loading global +
* override config, computing effective values, editing, saving and resetting.
*/
export function usePaymentOverrides(eventId: string, locale: string) {
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);
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);
}
};
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);
}
};
return {
globalPaymentOptions,
paymentOverrides,
hasPaymentOverrides,
savingPayments,
loadingPayments,
loadPaymentOptions,
getEffectivePaymentOption,
updatePaymentOverride,
handleSavePaymentOptions,
handleResetToGlobal,
};
}
export type PaymentOverridesController = ReturnType<typeof usePaymentOverrides>;
@@ -0,0 +1,521 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react';
import { Ticket } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { BottomSheet } from '@/components/admin/MobileComponents';
import clsx from 'clsx';
import {
CheckCircleIcon,
EnvelopeIcon,
PlusIcon,
StarIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
interface EventModalsProps {
// counts + filter
ticketsCount: number;
pendingCount: number;
confirmedCount: number;
checkedInCount: number;
cancelledCount: number;
statusFilter: AttendeeStatusFilter;
setStatusFilter: (value: AttendeeStatusFilter) => void;
// mobile filter sheet
mobileFilterOpen: boolean;
setMobileFilterOpen: (value: boolean) => void;
// add ticket sheet
showAddTicketSheet: boolean;
setShowAddTicketSheet: (value: boolean) => void;
// export sheets
showExportSheet: boolean;
setShowExportSheet: (value: boolean) => void;
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
showTicketExportSheet: boolean;
setShowTicketExportSheet: (value: boolean) => void;
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
// add at door
showAddAtDoorModal: boolean;
setShowAddAtDoorModal: (value: boolean) => void;
addAtDoorForm: AddAtDoorFormState;
setAddAtDoorForm: Dispatch<SetStateAction<AddAtDoorFormState>>;
handleAddAtDoor: (e: FormEvent) => void;
// manual ticket
showManualTicketModal: boolean;
setShowManualTicketModal: (value: boolean) => void;
manualTicketForm: AttendeeFormState;
setManualTicketForm: Dispatch<SetStateAction<AttendeeFormState>>;
handleManualTicket: (e: FormEvent) => void;
// invite guest
showInviteGuestModal: boolean;
setShowInviteGuestModal: (value: boolean) => void;
inviteGuestForm: AttendeeFormState;
setInviteGuestForm: Dispatch<SetStateAction<AttendeeFormState>>;
handleInviteGuest: (e: FormEvent) => void;
// shared submit flag
submitting: boolean;
// note modal
showNoteModal: boolean;
setShowNoteModal: (value: boolean) => void;
selectedTicket: Ticket | null;
setSelectedTicket: (value: Ticket | null) => void;
noteText: string;
setNoteText: (value: string) => void;
handleSaveNote: () => void;
// preview modal
previewHtml: string | null;
setPreviewHtml: (value: string | null) => void;
}
export function EventModals(props: EventModalsProps) {
const {
ticketsCount,
pendingCount,
confirmedCount,
checkedInCount,
cancelledCount,
statusFilter,
setStatusFilter,
mobileFilterOpen,
setMobileFilterOpen,
showAddTicketSheet,
setShowAddTicketSheet,
showExportSheet,
setShowExportSheet,
handleExportAttendees,
showTicketExportSheet,
setShowTicketExportSheet,
handleExportTickets,
showAddAtDoorModal,
setShowAddAtDoorModal,
addAtDoorForm,
setAddAtDoorForm,
handleAddAtDoor,
showManualTicketModal,
setShowManualTicketModal,
manualTicketForm,
setManualTicketForm,
handleManualTicket,
showInviteGuestModal,
setShowInviteGuestModal,
inviteGuestForm,
setInviteGuestForm,
handleInviteGuest,
submitting,
showNoteModal,
setShowNoteModal,
selectedTicket,
setSelectedTicket,
noteText,
setNoteText,
handleSaveNote,
previewHtml,
setPreviewHtml,
} = props;
return (
<>
{/* Mobile filter bottom sheet */}
<BottomSheet
open={mobileFilterOpen}
onClose={() => setMobileFilterOpen(false)}
title="Filter by Status"
>
<div className="space-y-1">
{[
{ value: 'all', label: `All (${ticketsCount})` },
{ 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 AttendeeStatusFilter); 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>
<button
onClick={() => { setShowInviteGuestModal(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"
>
<StarIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Invite Guest</p>
<p className="text-xs text-gray-500">Free ticket, not counted in revenue</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>
)}
{/* Invite Guest Modal */}
{showInviteGuestModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowInviteGuestModal(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">Invite Guest</h2>
<p className="text-xs text-gray-500">Free ticket not counted in revenue</p>
</div>
<button onClick={() => setShowInviteGuestModal(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={handleInviteGuest} 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={inviteGuestForm.firstName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, 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={inviteGuestForm.lastName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, 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={inviteGuestForm.email}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, 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 (optional)" />
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={inviteGuestForm.phone}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, 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={inviteGuestForm.adminNote}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, 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-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-amber-800">
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
</p>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<StarIcon className="w-4 h-4 mr-1.5" />
Invite Guest
</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} sandbox="" className="w-full h-full min-h-[500px]" title="Email Preview" />
</div>
</Card>
</div>
)}
</>
);
}
@@ -0,0 +1,355 @@
import { Ticket } from '@/lib/api';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
import clsx from 'clsx';
import {
MagnifyingGlassIcon,
ChevronDownIcon,
ArrowDownTrayIcon,
PlusIcon,
EnvelopeIcon,
StarIcon,
FunnelIcon,
ChatBubbleLeftIcon,
} from '@heroicons/react/24/outline';
import { StatusBadge } from '../_components/StatusBadge';
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
interface AttendeesTabProps {
locale: string;
tickets: Ticket[];
filteredTickets: Ticket[];
searchQuery: string;
setSearchQuery: (value: string) => void;
statusFilter: AttendeeStatusFilter;
setStatusFilter: (value: AttendeeStatusFilter) => void;
pendingCount: number;
confirmedCount: number;
checkedInCount: number;
cancelledCount: number;
exporting: boolean;
showExportDropdown: boolean;
setShowExportDropdown: (value: boolean) => void;
showAddTicketDropdown: boolean;
setShowAddTicketDropdown: (value: boolean) => void;
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
setShowManualTicketModal: (value: boolean) => void;
setShowAddAtDoorModal: (value: boolean) => void;
setShowInviteGuestModal: (value: boolean) => void;
setMobileFilterOpen: (value: boolean) => void;
setShowExportSheet: (value: boolean) => void;
setShowAddTicketSheet: (value: boolean) => void;
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
handleOpenNoteModal: (ticket: Ticket) => void;
}
export function AttendeesTab({
locale,
tickets,
filteredTickets,
searchQuery,
setSearchQuery,
statusFilter,
setStatusFilter,
pendingCount,
confirmedCount,
checkedInCount,
cancelledCount,
exporting,
showExportDropdown,
setShowExportDropdown,
showAddTicketDropdown,
setShowAddTicketDropdown,
handleExportAttendees,
setShowManualTicketModal,
setShowAddAtDoorModal,
setShowInviteGuestModal,
setMobileFilterOpen,
setShowExportSheet,
setShowAddTicketSheet,
getPrimaryAction,
handleOpenNoteModal,
}: AttendeesTabProps) {
return (
<div className="space-y-3">
{/* Desktop toolbar */}
<Card className="p-3 hidden md:block">
<div className="flex items-center gap-3">
{/* Left: Search + Status */}
<div className="relative flex-1 max-w-sm">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search name, email, phone..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as AttendeeStatusFilter)}
className="px-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="all">All ({tickets.length})</option>
<option value="pending">Pending ({pendingCount})</option>
<option value="confirmed">Confirmed ({confirmedCount})</option>
<option value="checked_in">Checked In ({checkedInCount})</option>
<option value="cancelled">Cancelled ({cancelledCount})</option>
</select>
<div className="flex-1" />
{/* Right: Export + Add Ticket dropdown */}
<Dropdown
open={showExportDropdown}
onOpenChange={setShowExportDropdown}
trigger={
<Button variant="outline" size="sm" disabled={exporting}>
{exporting ? (
<div className="w-3.5 h-3.5 mr-1.5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
)}
Export
<ChevronDownIcon className="w-3 h-3 ml-1" />
</Button>
}
>
<DropdownItem onClick={() => handleExportAttendees('all')}>Export All</DropdownItem>
<DropdownItem onClick={() => handleExportAttendees('confirmed')}>Export Confirmed</DropdownItem>
<DropdownItem onClick={() => handleExportAttendees('checked_in')}>Export Checked-in</DropdownItem>
<DropdownItem onClick={() => handleExportAttendees('confirmed_pending')}>Confirmed &amp; Pending</DropdownItem>
<div className="border-t border-gray-100 mx-2" />
<div className="px-4 py-1.5 text-[10px] text-gray-400">Format: CSV</div>
</Dropdown>
<Dropdown
open={showAddTicketDropdown}
onOpenChange={setShowAddTicketDropdown}
trigger={
<Button size="sm">
<PlusIcon className="w-3.5 h-3.5 mr-1.5" />
Add Ticket
<ChevronDownIcon className="w-3 h-3 ml-1" />
</Button>
}
>
<DropdownItem onClick={() => { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Manual Ticket
</DropdownItem>
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
</DropdownItem>
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
</DropdownItem>
</Dropdown>
</div>
{(searchQuery || statusFilter !== 'all') && (
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
<span>Showing {filteredTickets.length} of {tickets.length}</span>
<button onClick={() => { setSearchQuery(''); setStatusFilter('all'); }} className="text-primary-yellow hover:underline">
Clear
</button>
</div>
)}
</Card>
{/* Mobile toolbar */}
<div className="md:hidden space-y-2">
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search name, email, phone..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
statusFilter !== 'all'
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
: 'border-secondary-light-gray text-gray-600'
)}
>
<FunnelIcon className="w-4 h-4" />
{statusFilter === 'all' ? 'Filter' : statusFilter.replace('_', ' ')}
</button>
<button
onClick={() => setShowExportSheet(true)}
disabled={exporting}
className="flex items-center gap-1.5 px-3 py-2 rounded-btn border border-secondary-light-gray text-sm text-gray-600 min-h-[44px]"
>
<ArrowDownTrayIcon className="w-4 h-4" />
Export
</button>
{(searchQuery || statusFilter !== 'all') && (
<button
onClick={() => { setSearchQuery(''); setStatusFilter('all'); }}
className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center"
>
Clear
</button>
)}
</div>
{(searchQuery || statusFilter !== 'all') && (
<p className="text-xs text-gray-500">Showing {filteredTickets.length} of {tickets.length}</p>
)}
</div>
{/* Desktop: Dense table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredTickets.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-gray-500 text-sm">
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
</td>
</tr>
) : (
filteredTickets.map((ticket) => {
const primary = getPrimaryAction(ticket);
return (
<tr key={ticket.id} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5">
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
{ticket.bookingId && (
<span className="text-[10px] text-purple-600" title={`Booking: ${ticket.bookingId}`}>
Group booking
</span>
)}
</td>
<td className="px-4 py-2.5">
<p className="text-sm text-gray-600 truncate max-w-[200px]">{ticket.attendeeEmail}</p>
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1 flex-wrap">
<StatusBadge status={ticket.status} compact />
{!!ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
</div>
{ticket.checkinAt && (
<p className="text-[10px] text-gray-400 mt-0.5">
{parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}
</p>
)}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
{primary && (
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2 py-1">
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
{primary.label}
</Button>
)}
<MoreMenu>
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
</DropdownItem>
{ticket.adminNote && (
<div className="px-4 py-1.5 text-[10px] text-gray-400 truncate max-w-[180px]">
Note: {ticket.adminNote}
</div>
)}
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
ID: {ticket.id.slice(0, 8)}...
</div>
</MoreMenu>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Card>
{/* Mobile: Card layout */}
<div className="md:hidden space-y-2">
{filteredTickets.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
</div>
) : (
filteredTickets.map((ticket) => {
const primary = getPrimaryAction(ticket);
return (
<Card key={ticket.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
<StatusBadge status={ticket.status} compact />
{!!ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
{ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`}
</p>
<div className="flex items-center gap-1">
{primary && (
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
{primary.label}
</Button>
)}
<MoreMenu>
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
</DropdownItem>
</MoreMenu>
</div>
</div>
</Card>
);
})
)}
</div>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button
onClick={() => setShowAddTicketSheet(true)}
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform"
>
<PlusIcon className="w-6 h-6" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,155 @@
import { EmailTemplate } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import clsx from 'clsx';
import {
EyeIcon,
PaperAirplaneIcon,
CheckCircleIcon,
ClockIcon,
TicketIcon,
XCircleIcon,
} from '@heroicons/react/24/outline';
type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
interface EmailTabProps {
templates: EmailTemplate[];
selectedTemplate: string;
setSelectedTemplate: (value: string) => void;
recipientFilter: RecipientFilter;
setRecipientFilter: (value: RecipientFilter) => void;
customMessage: string;
setCustomMessage: (value: string) => void;
sending: boolean;
handlePreviewEmail: () => void;
handleSendEmail: () => void;
getFilteredRecipientCount: () => number;
ticketsCount: number;
confirmedCount: number;
pendingCount: number;
checkedInCount: number;
cancelledCount: number;
}
export function EmailTab({
templates,
selectedTemplate,
setSelectedTemplate,
recipientFilter,
setRecipientFilter,
customMessage,
setCustomMessage,
sending,
handlePreviewEmail,
handleSendEmail,
getFilteredRecipientCount,
ticketsCount,
confirmedCount,
pendingCount,
checkedInCount,
cancelledCount,
}: EmailTabProps) {
return (
<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 RecipientFilter)}
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 ({ticketsCount})</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>{ticketsCount}</span>
</div>
</div>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,83 @@
import { Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
interface OverviewTabProps {
event: Event;
formatDate: (dateStr: string) => string;
fmtTime: (dateStr: string) => string;
formatCurrency: (amount: number, currency: string) => string;
confirmedCount: number;
checkedInCount: number;
}
export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount }: OverviewTabProps) {
return (
<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">{fmtTime(event.startDatetime)}{event.endDatetime && ` - ${fmtTime(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>
);
}
@@ -0,0 +1,406 @@
import { PaymentOptionsConfig } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import clsx from 'clsx';
import {
CreditCardIcon,
BuildingLibraryIcon,
BoltIcon,
BanknotesIcon,
ArrowPathIcon,
CheckCircleIcon,
XCircleIcon,
} from '@heroicons/react/24/outline';
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
interface PaymentsTabProps {
locale: string;
payments: PaymentOverridesController;
}
export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
const {
loadingPayments,
hasPaymentOverrides,
savingPayments,
globalPaymentOptions,
paymentOverrides,
getEffectivePaymentOption,
updatePaymentOverride,
handleResetToGlobal,
handleSavePaymentOptions,
} = payments;
return (
<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' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'}
</label>
<p className="text-[10px] text-gray-500 mb-2">
{locale === 'es'
? 'Cada enlace tiene un monto fijo. Un enlace distinto por cantidad de tickets.'
: 'Each link has a fixed amount. One link per ticket quantity.'}
</p>
<div className="space-y-2">
{([1, 2, 3, 4, 5] as const).map((qty) => {
const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig;
return (
<div key={qty} className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-600 w-20 flex-shrink-0">
{qty} {qty === 1 ? 'ticket' : 'tickets'}
</span>
<input
type="url"
value={(paymentOverrides[key] as string | null) ?? ''}
onChange={(e) => updatePaymentOverride(key, (e.target.value || null) as any)}
placeholder={(globalPaymentOptions?.[key] as string | null) || 'https://www.tpago.com.py/links?alias=...'}
className="flex-1 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>
<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>
);
}
@@ -0,0 +1,258 @@
import { Ticket } from '@/lib/api';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
import {
MagnifyingGlassIcon,
ChevronDownIcon,
ArrowDownTrayIcon,
ArrowUturnLeftIcon,
} from '@heroicons/react/24/outline';
import type { TicketStatusFilter } from '../_types';
interface TicketsTabProps {
locale: string;
confirmedTickets: Ticket[];
filteredConfirmedTickets: Ticket[];
ticketSearchQuery: string;
setTicketSearchQuery: (value: string) => void;
ticketStatusFilter: TicketStatusFilter;
setTicketStatusFilter: (value: TicketStatusFilter) => void;
confirmedCount: number;
checkedInCount: number;
exporting: boolean;
showTicketExportDropdown: boolean;
setShowTicketExportDropdown: (value: boolean) => void;
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
handleCheckin: (ticketId: string) => void;
handleRemoveCheckin: (ticketId: string) => void;
setShowTicketExportSheet: (value: boolean) => void;
}
export function TicketsTab({
locale,
confirmedTickets,
filteredConfirmedTickets,
ticketSearchQuery,
setTicketSearchQuery,
ticketStatusFilter,
setTicketStatusFilter,
confirmedCount,
checkedInCount,
exporting,
showTicketExportDropdown,
setShowTicketExportDropdown,
handleExportTickets,
handleCheckin,
handleRemoveCheckin,
setShowTicketExportSheet,
}: TicketsTabProps) {
return (
<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 TicketStatusFilter)}
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 TicketStatusFilter)}
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 ? (
parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE,
})
) : (
<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 ${parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`
: '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>
);
}
@@ -0,0 +1,26 @@
import type { ComponentType } from 'react';
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled';
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
export type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
export interface PrimaryAction {
label: string;
onClick: () => void;
variant: 'outline' | 'primary';
icon?: ComponentType<{ className?: string }>;
}
export interface AttendeeFormState {
firstName: string;
lastName: string;
email: string;
phone: string;
adminNote: string;
}
export interface AddAtDoorFormState extends AttendeeFormState {
autoCheckin: boolean;
}
@@ -0,0 +1,20 @@
// Pure formatting/IO helpers for the admin event detail page.
export function formatCurrency(amount: number, currency: string): string {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
}
/** Trigger a browser download for a blob with the given filename. */
export function downloadBlob(blob: Blob, filename: string): void {
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);
}
File diff suppressed because it is too large Load Diff