- Add in-memory email queue with rate limiting (MAX_EMAILS_PER_HOUR) - Bulk send to event attendees now queues and returns immediately - Frontend shows 'Emails are being sent in the background' - Legal pages, settings, and placeholders updates Co-authored-by: Cursor <cursoragent@cursor.com>
1805 lines
82 KiB
TypeScript
1805 lines
82 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { useLanguage } from '@/context/LanguageContext';
|
|
import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api';
|
|
import Card from '@/components/ui/Card';
|
|
import Button from '@/components/ui/Button';
|
|
import {
|
|
ArrowLeftIcon,
|
|
CalendarIcon,
|
|
MapPinIcon,
|
|
CurrencyDollarIcon,
|
|
UsersIcon,
|
|
TicketIcon,
|
|
CheckCircleIcon,
|
|
ClockIcon,
|
|
XCircleIcon,
|
|
EnvelopeIcon,
|
|
PencilIcon,
|
|
EyeIcon,
|
|
PaperAirplaneIcon,
|
|
UserGroupIcon,
|
|
MagnifyingGlassIcon,
|
|
FunnelIcon,
|
|
PlusIcon,
|
|
ChatBubbleLeftIcon,
|
|
ArrowUturnLeftIcon,
|
|
XMarkIcon,
|
|
CreditCardIcon,
|
|
BanknotesIcon,
|
|
BoltIcon,
|
|
BuildingLibraryIcon,
|
|
ArrowPathIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import toast from 'react-hot-toast';
|
|
import clsx from 'clsx';
|
|
|
|
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
|
|
|
export default function AdminEventDetailPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const eventId = params.id as string;
|
|
const { t, locale } = useLanguage();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [event, setEvent] = useState<Event | null>(null);
|
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
|
|
|
// Email state
|
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
|
const [recipientFilter, setRecipientFilter] = useState<'all' | 'confirmed' | 'pending' | 'checked_in'>('confirmed');
|
|
const [customMessage, setCustomMessage] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
|
|
|
// Attendees tab state
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
|
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
|
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
|
const [noteText, setNoteText] = useState('');
|
|
const [addAtDoorForm, setAddAtDoorForm] = useState({
|
|
firstName: '',
|
|
lastName: '',
|
|
email: '',
|
|
phone: '',
|
|
autoCheckin: true,
|
|
adminNote: '',
|
|
});
|
|
const [manualTicketForm, setManualTicketForm] = useState({
|
|
firstName: '',
|
|
lastName: '',
|
|
email: '',
|
|
phone: '',
|
|
adminNote: '',
|
|
});
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
// Tickets tab state
|
|
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
|
|
const [ticketStatusFilter, setTicketStatusFilter] = useState<'all' | 'confirmed' | 'checked_in'>('all');
|
|
|
|
// Payment options state
|
|
const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null);
|
|
const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({});
|
|
const [hasPaymentOverrides, setHasPaymentOverrides] = useState(false);
|
|
const [savingPayments, setSavingPayments] = useState(false);
|
|
const [loadingPayments, setLoadingPayments] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadEventData();
|
|
}, [eventId]);
|
|
|
|
const loadEventData = async () => {
|
|
try {
|
|
const [eventRes, ticketsRes, templatesRes] = await Promise.all([
|
|
eventsApi.getById(eventId),
|
|
ticketsApi.getAll({ eventId }),
|
|
emailsApi.getTemplates(),
|
|
]);
|
|
setEvent(eventRes.event);
|
|
setTickets(ticketsRes.tickets);
|
|
setTemplates(templatesRes.templates.filter(t => t.isActive));
|
|
} catch (error) {
|
|
toast.error('Failed to load event data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadPaymentOptions = async () => {
|
|
if (globalPaymentOptions) return; // Already loaded
|
|
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);
|
|
}
|
|
};
|
|
|
|
// Load payment options when switching to payments tab
|
|
useEffect(() => {
|
|
if (activeTab === 'payments') {
|
|
loadPaymentOptions();
|
|
}
|
|
}, [activeTab]);
|
|
|
|
const getEffectivePaymentOption = <K extends keyof PaymentOptionsConfig>(key: K): PaymentOptionsConfig[K] => {
|
|
if (paymentOverrides[key] !== undefined && paymentOverrides[key] !== null) {
|
|
return paymentOverrides[key] as PaymentOptionsConfig[K];
|
|
}
|
|
return globalPaymentOptions?.[key] as PaymentOptionsConfig[K];
|
|
};
|
|
|
|
const updatePaymentOverride = <K extends keyof PaymentOptionsConfig>(
|
|
key: K,
|
|
value: PaymentOptionsConfig[K] | null
|
|
) => {
|
|
setPaymentOverrides((prev) => ({ ...prev, [key]: value }));
|
|
setHasPaymentOverrides(true);
|
|
};
|
|
|
|
const handleSavePaymentOptions = async () => {
|
|
setSavingPayments(true);
|
|
try {
|
|
await paymentOptionsApi.updateEventOverrides(eventId, paymentOverrides);
|
|
toast.success(locale === 'es' ? 'Opciones de pago guardadas' : 'Payment options saved');
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to save payment options');
|
|
} finally {
|
|
setSavingPayments(false);
|
|
}
|
|
};
|
|
|
|
const handleResetToGlobal = async () => {
|
|
if (!confirm(locale === 'es'
|
|
? '¿Resetear a la configuración global? Se eliminarán todas las personalizaciones de este evento.'
|
|
: 'Reset to global settings? This will remove all customizations for this event.')) {
|
|
return;
|
|
}
|
|
setSavingPayments(true);
|
|
try {
|
|
await paymentOptionsApi.deleteEventOverrides(eventId);
|
|
setPaymentOverrides({});
|
|
setHasPaymentOverrides(false);
|
|
toast.success(locale === 'es' ? 'Restablecido a configuración global' : 'Reset to global settings');
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to reset payment options');
|
|
} finally {
|
|
setSavingPayments(false);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
timeZone: 'America/Asuncion',
|
|
});
|
|
};
|
|
|
|
const formatTime = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
timeZone: 'America/Asuncion',
|
|
});
|
|
};
|
|
|
|
const formatCurrency = (amount: number, currency: string) => {
|
|
if (currency === 'PYG') {
|
|
return `${amount.toLocaleString('es-PY')} PYG`;
|
|
}
|
|
return `$${amount.toFixed(2)} ${currency}`;
|
|
};
|
|
|
|
const getTicketsByStatus = (status: string) => {
|
|
return tickets.filter(t => t.status === status);
|
|
};
|
|
|
|
const getFilteredRecipientCount = () => {
|
|
if (recipientFilter === 'all') return tickets.length;
|
|
return getTicketsByStatus(recipientFilter).length;
|
|
};
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
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={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
|
|
{status.replace('_', ' ')}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const handleMarkPaid = async (ticketId: string) => {
|
|
try {
|
|
await ticketsApi.markPaid(ticketId);
|
|
toast.success('Payment marked as received');
|
|
loadEventData();
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to mark payment');
|
|
}
|
|
};
|
|
|
|
const handleCheckin = async (ticketId: string) => {
|
|
try {
|
|
await ticketsApi.checkin(ticketId);
|
|
toast.success('Attendee checked in');
|
|
loadEventData();
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to check in');
|
|
}
|
|
};
|
|
|
|
const handleRemoveCheckin = async (ticketId: string) => {
|
|
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
|
|
try {
|
|
await ticketsApi.removeCheckin(ticketId);
|
|
toast.success('Check-in removed');
|
|
loadEventData();
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to remove check-in');
|
|
}
|
|
};
|
|
|
|
const handleOpenNoteModal = (ticket: Ticket) => {
|
|
setSelectedTicket(ticket);
|
|
setNoteText(ticket.adminNote || '');
|
|
setShowNoteModal(true);
|
|
};
|
|
|
|
const handleSaveNote = async () => {
|
|
if (!selectedTicket) return;
|
|
setSubmitting(true);
|
|
try {
|
|
await ticketsApi.updateNote(selectedTicket.id, noteText);
|
|
toast.success('Note saved');
|
|
setShowNoteModal(false);
|
|
setSelectedTicket(null);
|
|
setNoteText('');
|
|
loadEventData();
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to save note');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleAddAtDoor = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!event) return;
|
|
setSubmitting(true);
|
|
try {
|
|
await ticketsApi.adminCreate({
|
|
eventId: event.id,
|
|
firstName: addAtDoorForm.firstName,
|
|
lastName: addAtDoorForm.lastName || undefined,
|
|
email: addAtDoorForm.email,
|
|
phone: addAtDoorForm.phone,
|
|
autoCheckin: addAtDoorForm.autoCheckin,
|
|
adminNote: addAtDoorForm.adminNote || undefined,
|
|
});
|
|
toast.success(addAtDoorForm.autoCheckin ? 'Attendee added and checked in' : 'Attendee added');
|
|
setShowAddAtDoorModal(false);
|
|
setAddAtDoorForm({ firstName: '', lastName: '', email: '', phone: '', autoCheckin: true, adminNote: '' });
|
|
loadEventData();
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to add attendee');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleManualTicket = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!event) return;
|
|
setSubmitting(true);
|
|
try {
|
|
await ticketsApi.manualCreate({
|
|
eventId: event.id,
|
|
firstName: manualTicketForm.firstName,
|
|
lastName: manualTicketForm.lastName || undefined,
|
|
email: manualTicketForm.email,
|
|
phone: manualTicketForm.phone || undefined,
|
|
adminNote: manualTicketForm.adminNote || undefined,
|
|
});
|
|
toast.success('Manual ticket created — confirmation email sent');
|
|
setShowManualTicketModal(false);
|
|
setManualTicketForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
|
|
loadEventData();
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to create manual ticket');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Filtered tickets for attendees tab
|
|
const filteredTickets = tickets.filter((ticket) => {
|
|
// Status filter
|
|
if (statusFilter !== 'all' && ticket.status !== statusFilter) {
|
|
return false;
|
|
}
|
|
// Search filter
|
|
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) => {
|
|
// Status filter
|
|
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) {
|
|
return false;
|
|
}
|
|
// Search filter
|
|
if (ticketSearchQuery) {
|
|
const query = ticketSearchQuery.toLowerCase();
|
|
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
|
|
return (
|
|
fullName.includes(query) ||
|
|
ticket.id.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const handlePreviewEmail = async () => {
|
|
if (!selectedTemplate) {
|
|
toast.error('Please select a template');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await emailsApi.preview({
|
|
templateSlug: selectedTemplate,
|
|
variables: {
|
|
attendeeName: 'John Doe',
|
|
attendeeEmail: 'john@example.com',
|
|
ticketId: 'TKT-PREVIEW',
|
|
eventTitle: event?.title || '',
|
|
eventDate: event ? formatDate(event.startDatetime) : '',
|
|
eventTime: event ? formatTime(event.startDatetime) : '',
|
|
eventLocation: event?.location || '',
|
|
eventLocationUrl: event?.locationUrl || '',
|
|
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
|
|
customMessage: customMessage || 'Your custom message will appear here.',
|
|
},
|
|
locale,
|
|
});
|
|
setPreviewHtml(res.bodyHtml);
|
|
} catch (error) {
|
|
toast.error('Failed to preview email');
|
|
}
|
|
};
|
|
|
|
const handleSendEmail = async () => {
|
|
if (!selectedTemplate) {
|
|
toast.error('Please select a template');
|
|
return;
|
|
}
|
|
|
|
const recipientCount = getFilteredRecipientCount();
|
|
if (recipientCount === 0) {
|
|
toast.error('No recipients match the selected filter');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Send email to ${recipientCount} ${recipientFilter === 'all' ? 'attendee(s)' : `${recipientFilter} attendee(s)`}?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await emailsApi.sendToEvent(eventId, {
|
|
templateSlug: selectedTemplate,
|
|
recipientFilter,
|
|
customVariables: customMessage ? { customMessage } : undefined,
|
|
});
|
|
|
|
if (res.success) {
|
|
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
|
|
} else {
|
|
toast.error(res.error || 'Failed to queue emails');
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to send emails');
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!event) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-500">Event not found</p>
|
|
<Link href="/admin/events">
|
|
<Button variant="outline" className="mt-4">
|
|
Back to Events
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const confirmedCount = getTicketsByStatus('confirmed').length;
|
|
const pendingCount = getTicketsByStatus('pending').length;
|
|
const checkedInCount = getTicketsByStatus('checked_in').length;
|
|
const cancelledCount = getTicketsByStatus('cancelled').length;
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4 mb-6">
|
|
<Link href="/admin/events">
|
|
<button className="p-2 hover:bg-gray-100 rounded-btn">
|
|
<ArrowLeftIcon className="w-5 h-5" />
|
|
</button>
|
|
</Link>
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl font-bold text-primary-dark">{event.title}</h1>
|
|
<p className="text-gray-500">{formatDate(event.startDatetime)}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Link href={`/events/${event.id}`} target="_blank">
|
|
<Button variant="outline" size="sm">
|
|
<EyeIcon className="w-4 h-4 mr-2" />
|
|
View Public
|
|
</Button>
|
|
</Link>
|
|
<Link href={`/admin/events?edit=${event.id}`}>
|
|
<Button variant="outline" size="sm">
|
|
<PencilIcon className="w-4 h-4 mr-2" />
|
|
Edit
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
|
<UsersIcon className="w-5 h-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold">{event.capacity}</p>
|
|
<p className="text-sm text-gray-500">Capacity</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
|
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold">{confirmedCount}</p>
|
|
<p className="text-sm text-gray-500">Confirmed</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
|
<ClockIcon className="w-5 h-5 text-yellow-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold">{pendingCount}</p>
|
|
<p className="text-sm text-gray-500">Pending</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
|
<TicketIcon className="w-5 h-5 text-purple-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold">{checkedInCount}</p>
|
|
<p className="text-sm text-gray-500">Checked In</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
|
<CurrencyDollarIcon className="w-5 h-5 text-gray-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold">{formatCurrency(confirmedCount * event.price, event.currency)}</p>
|
|
<p className="text-sm text-gray-500">Revenue</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-secondary-light-gray mb-6">
|
|
<nav className="flex gap-6">
|
|
{(['overview', 'attendees', 'tickets', 'email', 'payments'] as TabType[]).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className={clsx(
|
|
'py-3 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2',
|
|
{
|
|
'border-primary-yellow text-primary-dark': activeTab === tab,
|
|
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab,
|
|
}
|
|
)}
|
|
>
|
|
{tab === 'overview' && <CalendarIcon className="w-4 h-4" />}
|
|
{tab === 'attendees' && <UserGroupIcon className="w-4 h-4" />}
|
|
{tab === 'tickets' && <TicketIcon className="w-4 h-4" />}
|
|
{tab === 'email' && <EnvelopeIcon className="w-4 h-4" />}
|
|
{tab === 'payments' && <CreditCardIcon className="w-4 h-4" />}
|
|
{tab === 'overview' ? 'Overview' : tab === 'attendees' ? `Attendees (${tickets.length})` : tab === 'tickets' ? `Tickets (${confirmedTickets.length})` : tab === 'email' ? 'Send Email' : (locale === 'es' ? 'Pagos' : 'Payments')}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Overview Tab */}
|
|
{activeTab === 'overview' && (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<Card className="p-6">
|
|
<h3 className="font-semibold text-lg mb-4">Event Information</h3>
|
|
<div className="space-y-4">
|
|
<div className="flex items-start gap-3">
|
|
<CalendarIcon className="w-5 h-5 text-gray-400 mt-0.5" />
|
|
<div>
|
|
<p className="font-medium">Date & Time</p>
|
|
<p className="text-gray-600">{formatDate(event.startDatetime)}</p>
|
|
<p className="text-gray-600">{formatTime(event.startDatetime)}{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<MapPinIcon className="w-5 h-5 text-gray-400 mt-0.5" />
|
|
<div>
|
|
<p className="font-medium">Location</p>
|
|
<p className="text-gray-600">{event.location}</p>
|
|
{event.locationUrl && (
|
|
<a href={event.locationUrl} target="_blank" rel="noopener" className="text-blue-600 text-sm 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" />
|
|
<div>
|
|
<p className="font-medium">Price</p>
|
|
<p className="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" />
|
|
<div>
|
|
<p className="font-medium">Capacity</p>
|
|
<p className="text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
|
|
<p className="text-sm text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<h3 className="font-semibold text-lg mb-4">Description</h3>
|
|
<div className="prose prose-sm max-w-none">
|
|
<p className="text-gray-600 whitespace-pre-wrap">{event.description}</p>
|
|
{event.descriptionEs && (
|
|
<>
|
|
<p className="font-medium mt-4">Spanish:</p>
|
|
<p className="text-gray-600 whitespace-pre-wrap">{event.descriptionEs}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{event.bannerUrl && (
|
|
<Card className="p-6 lg:col-span-2">
|
|
<h3 className="font-semibold text-lg mb-4">Event Banner</h3>
|
|
<img
|
|
src={event.bannerUrl}
|
|
alt={event.title}
|
|
className="w-full max-h-64 object-cover rounded-lg"
|
|
/>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Attendees Tab */}
|
|
{activeTab === 'attendees' && (
|
|
<div className="space-y-4">
|
|
{/* Filters & Actions Bar */}
|
|
<Card className="p-4">
|
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
|
<div className="flex flex-col sm:flex-row gap-3 flex-1 w-full sm:w-auto">
|
|
{/* Search */}
|
|
<div className="relative flex-1 max-w-md">
|
|
<MagnifyingGlassIcon className="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name, email, phone..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
{/* Status Filter */}
|
|
<div className="flex items-center gap-2">
|
|
<FunnelIcon className="w-5 h-5 text-gray-400" />
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as any)}
|
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
>
|
|
<option value="all">All Status ({tickets.length})</option>
|
|
<option value="pending">Pending ({getTicketsByStatus('pending').length})</option>
|
|
<option value="confirmed">Confirmed ({getTicketsByStatus('confirmed').length})</option>
|
|
<option value="checked_in">Checked In ({getTicketsByStatus('checked_in').length})</option>
|
|
<option value="cancelled">Cancelled ({getTicketsByStatus('cancelled').length})</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{/* Action Buttons */}
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" onClick={() => setShowManualTicketModal(true)}>
|
|
<EnvelopeIcon className="w-4 h-4 mr-2" />
|
|
Manual Ticket
|
|
</Button>
|
|
<Button onClick={() => setShowAddAtDoorModal(true)}>
|
|
<PlusIcon className="w-4 h-4 mr-2" />
|
|
Add at Door
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/* Filter Results Summary */}
|
|
{(searchQuery || statusFilter !== 'all') && (
|
|
<div className="mt-3 text-sm text-gray-500 flex items-center gap-2">
|
|
<span>Showing {filteredTickets.length} of {tickets.length} attendees</span>
|
|
{(searchQuery || statusFilter !== 'all') && (
|
|
<button
|
|
onClick={() => { setSearchQuery(''); setStatusFilter('all'); }}
|
|
className="text-primary-yellow hover:underline"
|
|
>
|
|
Clear filters
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Attendees Table */}
|
|
<Card className="overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-secondary-gray">
|
|
<tr>
|
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee</th>
|
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Contact</th>
|
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Note</th>
|
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
|
|
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-secondary-light-gray">
|
|
{filteredTickets.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
|
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredTickets.map((ticket) => (
|
|
<tr key={ticket.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4">
|
|
<p className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
|
<p className="text-sm text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
|
|
{ticket.bookingId && (
|
|
<p className="text-xs text-purple-600 mt-1" title={`Booking: ${ticket.bookingId}`}>
|
|
📦 {locale === 'es' ? 'Reserva grupal' : 'Group booking'}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<p className="text-sm">{ticket.attendeeEmail}</p>
|
|
<p className="text-sm text-gray-500">{ticket.attendeePhone}</p>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
{getStatusBadge(ticket.status)}
|
|
{ticket.checkinAt && (
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
{new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
{ticket.adminNote ? (
|
|
<p className="text-sm text-gray-600 max-w-[200px] truncate" title={ticket.adminNote}>
|
|
{ticket.adminNote}
|
|
</p>
|
|
) : (
|
|
<span className="text-sm text-gray-400">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-600">
|
|
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{/* Note button */}
|
|
<button
|
|
onClick={() => handleOpenNoteModal(ticket)}
|
|
className="p-2 hover:bg-gray-100 rounded-btn text-gray-500 hover:text-gray-700"
|
|
title="Add/Edit Note"
|
|
>
|
|
<ChatBubbleLeftIcon className="w-4 h-4" />
|
|
</button>
|
|
|
|
{ticket.status === 'pending' && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleMarkPaid(ticket.id)}
|
|
>
|
|
Mark Paid
|
|
</Button>
|
|
)}
|
|
{ticket.status === 'confirmed' && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleCheckin(ticket.id)}
|
|
>
|
|
Check In
|
|
</Button>
|
|
)}
|
|
{ticket.status === 'checked_in' && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleRemoveCheckin(ticket.id)}
|
|
>
|
|
<ArrowUturnLeftIcon className="w-4 h-4 mr-1" />
|
|
Undo
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tickets Tab */}
|
|
{activeTab === 'tickets' && (
|
|
<div className="space-y-4">
|
|
{/* Search & Filter Bar */}
|
|
<Card className="p-4">
|
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
|
<div className="flex flex-col sm:flex-row gap-3 flex-1 w-full sm:w-auto">
|
|
{/* Search */}
|
|
<div className="relative flex-1 max-w-md">
|
|
<MagnifyingGlassIcon className="w-5 h-5 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-10 pr-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
{/* Status Filter */}
|
|
<div className="flex items-center gap-2">
|
|
<FunnelIcon className="w-5 h-5 text-gray-400" />
|
|
<select
|
|
value={ticketStatusFilter}
|
|
onChange={(e) => setTicketStatusFilter(e.target.value as any)}
|
|
className="px-4 py-2 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 ({getTicketsByStatus('confirmed').length})</option>
|
|
<option value="checked_in">Checked In ({getTicketsByStatus('checked_in').length})</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{(ticketSearchQuery || ticketStatusFilter !== 'all') && (
|
|
<div className="mt-3 text-sm text-gray-500 flex items-center gap-2">
|
|
<span>Showing {filteredConfirmedTickets.length} of {confirmedTickets.length} tickets</span>
|
|
<button
|
|
onClick={() => { setTicketSearchQuery(''); setTicketStatusFilter('all'); }}
|
|
className="text-primary-yellow hover:underline"
|
|
>
|
|
Clear filters
|
|
</button>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Tickets Table */}
|
|
<Card className="overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-secondary-gray">
|
|
<tr>
|
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee Name</th>
|
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Ticket ID</th>
|
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booking ID</th>
|
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Check-in Time</th>
|
|
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-secondary-light-gray">
|
|
{filteredConfirmedTickets.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
|
{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">
|
|
<td className="px-6 py-4">
|
|
<p className="font-medium">
|
|
{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}
|
|
</p>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<code className="text-sm bg-gray-100 px-2 py-1 rounded" title={ticket.id}>
|
|
{ticket.id.slice(0, 8)}...
|
|
</code>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
{ticket.bookingId ? (
|
|
<code className="text-sm bg-purple-50 text-purple-700 px-2 py-1 rounded" title={ticket.bookingId}>
|
|
{ticket.bookingId.slice(0, 8)}...
|
|
</code>
|
|
) : (
|
|
<span className="text-sm text-gray-400">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
{ticket.status === 'confirmed' ? (
|
|
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">
|
|
Valid
|
|
</span>
|
|
) : (
|
|
<span className="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">
|
|
Checked In
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-600">
|
|
{ticket.checkinAt ? (
|
|
new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
timeZone: 'America/Asuncion',
|
|
})
|
|
) : (
|
|
<span className="text-gray-400">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{ticket.status === 'confirmed' && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleCheckin(ticket.id)}
|
|
>
|
|
Check In
|
|
</Button>
|
|
)}
|
|
{ticket.status === 'checked_in' && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleRemoveCheckin(ticket.id)}
|
|
>
|
|
<ArrowUturnLeftIcon className="w-4 h-4 mr-1" />
|
|
Undo
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add at Door Modal */}
|
|
{showAddAtDoorModal && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-md">
|
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
|
<h2 className="text-lg font-bold">Add Attendee at Door</h2>
|
|
<button
|
|
onClick={() => setShowAddAtDoorModal(false)}
|
|
className="p-2 hover:bg-gray-100 rounded-btn"
|
|
>
|
|
<XMarkIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleAddAtDoor} className="p-4 space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm 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-4 py-2 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-sm font-medium mb-1">Last Name (optional)</label>
|
|
<input
|
|
type="text"
|
|
value={addAtDoorForm.lastName}
|
|
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, lastName: e.target.value })}
|
|
className="w-full px-4 py-2 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-sm font-medium mb-1">Email (optional)</label>
|
|
<input
|
|
type="email"
|
|
value={addAtDoorForm.email}
|
|
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, email: e.target.value })}
|
|
className="w-full px-4 py-2 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-sm font-medium mb-1">Phone (optional)</label>
|
|
<input
|
|
type="tel"
|
|
value={addAtDoorForm.phone}
|
|
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, phone: e.target.value })}
|
|
className="w-full px-4 py-2 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-sm font-medium mb-1">Admin Note (optional)</label>
|
|
<textarea
|
|
value={addAtDoorForm.adminNote}
|
|
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, adminNote: e.target.value })}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
rows={2}
|
|
placeholder="Internal note about this attendee..."
|
|
/>
|
|
</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"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" isLoading={submitting} className="flex-1">
|
|
Add Attendee
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Manual Ticket Modal */}
|
|
{showManualTicketModal && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-md">
|
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
|
<div>
|
|
<h2 className="text-lg font-bold">Create Manual Ticket</h2>
|
|
<p className="text-sm text-gray-500">Attendee will receive a confirmation email with their ticket</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowManualTicketModal(false)}
|
|
className="p-2 hover:bg-gray-100 rounded-btn"
|
|
>
|
|
<XMarkIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleManualTicket} className="p-4 space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm 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-4 py-2 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-sm font-medium mb-1">Last Name (optional)</label>
|
|
<input
|
|
type="text"
|
|
value={manualTicketForm.lastName}
|
|
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: e.target.value })}
|
|
className="w-full px-4 py-2 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-sm 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-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
placeholder="email@example.com"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Booking confirmation and ticket will be sent to this email
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Phone (optional)</label>
|
|
<input
|
|
type="tel"
|
|
value={manualTicketForm.phone}
|
|
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: e.target.value })}
|
|
className="w-full px-4 py-2 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-sm font-medium mb-1">Admin Note (optional)</label>
|
|
<textarea
|
|
value={manualTicketForm.adminNote}
|
|
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: e.target.value })}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
rows={2}
|
|
placeholder="Internal note about this attendee..."
|
|
/>
|
|
</div>
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<div className="flex items-start gap-2">
|
|
<EnvelopeIcon className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
|
<div className="text-sm text-blue-800">
|
|
<p className="font-medium">This will send:</p>
|
|
<ul className="list-disc ml-4 mt-1 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"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" isLoading={submitting} className="flex-1">
|
|
<EnvelopeIcon className="w-4 h-4 mr-2" />
|
|
Create & Send
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Note Modal */}
|
|
{showNoteModal && selectedTicket && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-md">
|
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
|
<div>
|
|
<h2 className="text-lg font-bold">Admin Note</h2>
|
|
<p className="text-sm text-gray-500">{selectedTicket.attendeeFirstName} {selectedTicket.attendeeLastName || ''}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }}
|
|
className="p-2 hover:bg-gray-100 rounded-btn"
|
|
>
|
|
<XMarkIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="p-4 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Note</label>
|
|
<textarea
|
|
value={noteText}
|
|
onChange={(e) => setNoteText(e.target.value)}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
rows={4}
|
|
placeholder="Add a private note about this attendee..."
|
|
maxLength={1000}
|
|
/>
|
|
<p className="text-xs 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"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSaveNote} isLoading={submitting} className="flex-1">
|
|
Save Note
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Email Tab */}
|
|
{activeTab === 'email' && (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<Card className="p-6">
|
|
<h3 className="font-semibold text-lg mb-4">Send Email to Attendees</h3>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Email Template</label>
|
|
<select
|
|
value={selectedTemplate}
|
|
onChange={(e) => setSelectedTemplate(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
>
|
|
<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-2">Recipients</label>
|
|
<select
|
|
value={recipientFilter}
|
|
onChange={(e) => setRecipientFilter(e.target.value as any)}
|
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
>
|
|
<option value="all">All Attendees ({tickets.length})</option>
|
|
<option value="confirmed">Confirmed Only ({confirmedCount})</option>
|
|
<option value="pending">Pending Only ({pendingCount})</option>
|
|
<option value="checked_in">Checked In Only ({checkedInCount})</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Custom Message (optional)</label>
|
|
<textarea
|
|
value={customMessage}
|
|
onChange={(e) => setCustomMessage(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
rows={4}
|
|
placeholder="Add a custom message that will be included in the email..."
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
This message will replace the {`{{customMessage}}`} variable in the template.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-3 pt-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handlePreviewEmail}
|
|
disabled={!selectedTemplate}
|
|
>
|
|
<EyeIcon className="w-4 h-4 mr-2" />
|
|
Preview
|
|
</Button>
|
|
<Button
|
|
onClick={handleSendEmail}
|
|
disabled={!selectedTemplate || getFilteredRecipientCount() === 0}
|
|
isLoading={sending}
|
|
>
|
|
<PaperAirplaneIcon className="w-4 h-4 mr-2" />
|
|
Send to {getFilteredRecipientCount()} {getFilteredRecipientCount() === 1 ? 'person' : 'people'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<h3 className="font-semibold text-lg mb-4">Recipient Summary</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-btn">
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
|
<span>Confirmed</span>
|
|
</div>
|
|
<span className="font-semibold">{confirmedCount}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-btn">
|
|
<div className="flex items-center gap-2">
|
|
<ClockIcon className="w-5 h-5 text-yellow-500" />
|
|
<span>Pending Payment</span>
|
|
</div>
|
|
<span className="font-semibold">{pendingCount}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-btn">
|
|
<div className="flex items-center gap-2">
|
|
<TicketIcon className="w-5 h-5 text-blue-500" />
|
|
<span>Checked In</span>
|
|
</div>
|
|
<span className="font-semibold">{checkedInCount}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-btn">
|
|
<div className="flex items-center gap-2">
|
|
<XCircleIcon className="w-5 h-5 text-red-500" />
|
|
<span>Cancelled</span>
|
|
</div>
|
|
<span className="font-semibold">{cancelledCount}</span>
|
|
</div>
|
|
<div className="border-t pt-3 mt-3">
|
|
<div className="flex items-center justify-between font-semibold">
|
|
<span>Total Bookings</span>
|
|
<span>{tickets.length}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Payments Tab */}
|
|
{activeTab === 'payments' && (
|
|
<div className="space-y-6">
|
|
{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 items-center justify-between">
|
|
<div>
|
|
<h3 className="font-semibold text-lg">
|
|
{locale === 'es' ? 'Métodos de Pago del Evento' : 'Event Payment Methods'}
|
|
</h3>
|
|
<p className="text-sm text-gray-500">
|
|
{hasPaymentOverrides
|
|
? (locale === 'es' ? 'Este evento tiene configuración personalizada' : 'This event has 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}>
|
|
<ArrowPathIcon className="w-4 h-4 mr-2" />
|
|
{locale === 'es' ? 'Resetear a Global' : 'Reset to Global'}
|
|
</Button>
|
|
)}
|
|
<Button onClick={handleSavePaymentOptions} isLoading={savingPayments}>
|
|
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* TPago */}
|
|
<Card>
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
|
<CreditCardIcon className="w-5 h-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold">
|
|
{locale === 'es' ? 'TPago / Tarjeta Internacional' : 'TPago / International Card'}
|
|
</h4>
|
|
<p className="text-sm text-gray-500">
|
|
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{globalPaymentOptions && !globalPaymentOptions.tpagoEnabled && (
|
|
<span className="text-xs text-gray-400">
|
|
{locale === 'es' ? '(Deshabilitado globalmente)' : '(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-4 pt-4 border-t">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
|
|
{globalPaymentOptions?.tpagoLink && (
|
|
<span className="text-xs text-gray-400 ml-2">
|
|
({locale === 'es' ? 'Global' : 'Global'}: {globalPaymentOptions.tpagoLink.substring(0, 30)}...)
|
|
</span>
|
|
)}
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={paymentOverrides.tpagoLink ?? ''}
|
|
onChange={(e) => updatePaymentOverride('tpagoLink', e.target.value || null)}
|
|
placeholder={globalPaymentOptions?.tpagoLink || 'https://www.tpago.com.py/links?alias=...'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Instructions (English)
|
|
</label>
|
|
<textarea
|
|
value={paymentOverrides.tpagoInstructions ?? ''}
|
|
onChange={(e) => updatePaymentOverride('tpagoInstructions', e.target.value || null)}
|
|
rows={3}
|
|
placeholder={globalPaymentOptions?.tpagoInstructions || 'Instructions for users...'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Instrucciones (Español)
|
|
</label>
|
|
<textarea
|
|
value={paymentOverrides.tpagoInstructionsEs ?? ''}
|
|
onChange={(e) => updatePaymentOverride('tpagoInstructionsEs', e.target.value || null)}
|
|
rows={3}
|
|
placeholder={globalPaymentOptions?.tpagoInstructionsEs || 'Instrucciones para usuarios...'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-gray-400">
|
|
{locale === 'es'
|
|
? 'Deja vacío para usar la configuración global'
|
|
: 'Leave empty to use global settings'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Bank Transfer */}
|
|
<Card>
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
|
<BuildingLibraryIcon className="w-5 h-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold">
|
|
{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}
|
|
</h4>
|
|
<p className="text-sm text-gray-500">
|
|
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{globalPaymentOptions && !globalPaymentOptions.bankTransferEnabled && (
|
|
<span className="text-xs text-gray-400">
|
|
{locale === 'es' ? '(Deshabilitado globalmente)' : '(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-4 pt-4 border-t">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{locale === 'es' ? 'Nombre del Banco' : 'Bank Name'}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={paymentOverrides.bankName ?? ''}
|
|
onChange={(e) => updatePaymentOverride('bankName', e.target.value || null)}
|
|
placeholder={globalPaymentOptions?.bankName || 'e.g., Banco Itaú'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{locale === 'es' ? 'Titular de la Cuenta' : 'Account Holder'}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={paymentOverrides.bankAccountHolder ?? ''}
|
|
onChange={(e) => updatePaymentOverride('bankAccountHolder', e.target.value || null)}
|
|
placeholder={globalPaymentOptions?.bankAccountHolder || 'e.g., Juan Pérez'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{locale === 'es' ? 'Número de Cuenta' : 'Account Number'}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={paymentOverrides.bankAccountNumber ?? ''}
|
|
onChange={(e) => updatePaymentOverride('bankAccountNumber', e.target.value || null)}
|
|
placeholder={globalPaymentOptions?.bankAccountNumber || 'e.g., 1234567890'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Alias
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={paymentOverrides.bankAlias ?? ''}
|
|
onChange={(e) => updatePaymentOverride('bankAlias', e.target.value || null)}
|
|
placeholder={globalPaymentOptions?.bankAlias || 'e.g., spanglish.pagos'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{locale === 'es' ? 'Teléfono' : 'Phone Number'}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={paymentOverrides.bankPhone ?? ''}
|
|
onChange={(e) => updatePaymentOverride('bankPhone', e.target.value || null)}
|
|
placeholder={globalPaymentOptions?.bankPhone || 'e.g., +595 981 123456'}
|
|
className="w-full px-4 py-2 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-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Additional Notes (English)
|
|
</label>
|
|
<textarea
|
|
value={paymentOverrides.bankNotes ?? ''}
|
|
onChange={(e) => updatePaymentOverride('bankNotes', e.target.value || null)}
|
|
rows={3}
|
|
placeholder={globalPaymentOptions?.bankNotes || 'Additional notes for users...'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Notas Adicionales (Español)
|
|
</label>
|
|
<textarea
|
|
value={paymentOverrides.bankNotesEs ?? ''}
|
|
onChange={(e) => updatePaymentOverride('bankNotesEs', e.target.value || null)}
|
|
rows={3}
|
|
placeholder={globalPaymentOptions?.bankNotesEs || 'Notas adicionales para usuarios...'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-gray-400">
|
|
{locale === 'es'
|
|
? 'Deja vacío para usar la configuración global'
|
|
: 'Leave empty to use global settings'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Bitcoin Lightning */}
|
|
<Card>
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
|
|
<BoltIcon className="w-5 h-5 text-orange-600" />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold">Bitcoin Lightning</h4>
|
|
<p className="text-sm text-gray-500">
|
|
{locale === 'es' ? 'Pago instantáneo - confirmación automática' : 'Instant payment - automatic confirmation'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{globalPaymentOptions && !globalPaymentOptions.lightningEnabled && (
|
|
<span className="text-xs text-gray-400">
|
|
{locale === 'es' ? '(Deshabilitado globalmente)' : '(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-4 border-t mt-4">
|
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
|
<p className="text-sm text-orange-800">
|
|
{locale === 'es'
|
|
? 'Lightning está configurado a través de LNbits. No se puede personalizar por evento.'
|
|
: 'Lightning is configured via LNbits. Cannot be customized per event.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Cash at Door */}
|
|
<Card>
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
|
<BanknotesIcon className="w-5 h-5 text-yellow-600" />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold">
|
|
{locale === 'es' ? 'Efectivo en el Evento' : 'Cash at the Door'}
|
|
</h4>
|
|
<p className="text-sm text-gray-500">
|
|
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{globalPaymentOptions && !globalPaymentOptions.cashEnabled && (
|
|
<span className="text-xs text-gray-400">
|
|
{locale === 'es' ? '(Deshabilitado globalmente)' : '(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-4 pt-4 border-t">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Instructions (English)
|
|
</label>
|
|
<textarea
|
|
value={paymentOverrides.cashInstructions ?? ''}
|
|
onChange={(e) => updatePaymentOverride('cashInstructions', e.target.value || null)}
|
|
rows={3}
|
|
placeholder={globalPaymentOptions?.cashInstructions || 'Instructions for cash payments...'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Instrucciones (Español)
|
|
</label>
|
|
<textarea
|
|
value={paymentOverrides.cashInstructionsEs ?? ''}
|
|
onChange={(e) => updatePaymentOverride('cashInstructionsEs', e.target.value || null)}
|
|
rows={3}
|
|
placeholder={globalPaymentOptions?.cashInstructionsEs || 'Instrucciones para pagos en efectivo...'}
|
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-gray-400">
|
|
{locale === 'es'
|
|
? 'Deja vacío para usar la configuración global'
|
|
: 'Leave empty to use global settings'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Summary Card */}
|
|
<Card>
|
|
<div className="p-6">
|
|
<h4 className="font-semibold text-lg mb-4">
|
|
{locale === 'es' ? 'Resumen de Métodos Activos' : 'Active Methods Summary'}
|
|
</h4>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="flex items-center gap-2">
|
|
{getEffectivePaymentOption('tpagoEnabled') ? (
|
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
|
) : (
|
|
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
|
)}
|
|
<span className={getEffectivePaymentOption('tpagoEnabled') ? 'text-gray-900' : 'text-gray-400'}>
|
|
TPago
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{getEffectivePaymentOption('bankTransferEnabled') ? (
|
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
|
) : (
|
|
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
|
)}
|
|
<span className={getEffectivePaymentOption('bankTransferEnabled') ? 'text-gray-900' : 'text-gray-400'}>
|
|
{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{getEffectivePaymentOption('lightningEnabled') ? (
|
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
|
) : (
|
|
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
|
)}
|
|
<span className={getEffectivePaymentOption('lightningEnabled') ? 'text-gray-900' : 'text-gray-400'}>
|
|
Lightning
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{getEffectivePaymentOption('cashEnabled') ? (
|
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
|
) : (
|
|
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
|
)}
|
|
<span className={getEffectivePaymentOption('cashEnabled') ? 'text-gray-900' : 'text-gray-400'}>
|
|
{locale === 'es' ? 'Efectivo' : 'Cash'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{hasPaymentOverrides && (
|
|
<p className="text-xs text-gray-500 mt-4 flex items-center gap-1">
|
|
<span className="inline-block w-2 h-2 bg-primary-yellow rounded-full"></span>
|
|
{locale === 'es'
|
|
? 'Este evento usa configuración personalizada que sobrescribe la global'
|
|
: 'This event uses custom settings that override global defaults'}
|
|
</p>
|
|
)}
|
|
</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-lg font-bold">Email Preview</h2>
|
|
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)}>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 overflow-auto">
|
|
<iframe
|
|
srcDoc={previewHtml}
|
|
className="w-full h-full min-h-[500px]"
|
|
title="Email Preview"
|
|
/>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|