first commit

This commit is contained in:
Michaël
2026-01-29 14:13:11 -03:00
commit 2302748c87
105 changed files with 93301 additions and 0 deletions

View File

@@ -0,0 +1,426 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
TicketIcon,
CheckCircleIcon,
XCircleIcon,
CurrencyDollarIcon,
UserIcon,
EnvelopeIcon,
PhoneIcon,
FunnelIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
interface TicketWithDetails extends Omit<Ticket, 'payment'> {
event?: Event;
payment?: {
id: string;
ticketId?: string;
provider: string;
amount: number;
currency: string;
status: string;
reference?: string;
createdAt?: string;
updatedAt?: string;
};
}
export default function AdminBookingsPage() {
const { locale } = useLanguage();
const [tickets, setTickets] = useState<TicketWithDetails[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState<string | null>(null);
// Filters
const [selectedEvent, setSelectedEvent] = useState<string>('');
const [selectedStatus, setSelectedStatus] = useState<string>('');
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [ticketsRes, eventsRes] = await Promise.all([
ticketsApi.getAll(),
eventsApi.getAll(),
]);
// Fetch full ticket details with payment info
const ticketsWithDetails = await Promise.all(
ticketsRes.tickets.map(async (ticket) => {
try {
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id);
return fullTicket;
} catch {
return ticket;
}
})
);
setTickets(ticketsWithDetails);
setEvents(eventsRes.events);
} catch (error) {
toast.error('Failed to load bookings');
} finally {
setLoading(false);
}
};
const handleMarkPaid = async (ticketId: string) => {
setProcessing(ticketId);
try {
await ticketsApi.markPaid(ticketId);
toast.success('Payment marked as received');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to mark payment');
} finally {
setProcessing(null);
}
};
const handleCheckin = async (ticketId: string) => {
setProcessing(ticketId);
try {
await ticketsApi.checkin(ticketId);
toast.success('Check-in successful');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to check in');
} finally {
setProcessing(null);
}
};
const handleCancel = async (ticketId: string) => {
if (!confirm('Are you sure you want to cancel this booking?')) return;
setProcessing(ticketId);
try {
await ticketsApi.cancel(ticketId);
toast.success('Booking cancelled');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to cancel');
} finally {
setProcessing(null);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
case 'checked_in':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'paid':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed':
case 'cancelled':
return 'bg-red-100 text-red-800';
case 'refunded':
return 'bg-purple-100 text-purple-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getPaymentMethodLabel = (provider: string) => {
switch (provider) {
case 'bancard':
return 'TPago / Card';
case 'lightning':
return 'Bitcoin Lightning';
case 'cash':
return 'Cash at Event';
default:
return provider;
}
};
// Filter tickets
const filteredTickets = tickets.filter((ticket) => {
if (selectedEvent && ticket.eventId !== selectedEvent) return false;
if (selectedStatus && ticket.status !== selectedStatus) return false;
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
return true;
});
// Sort by created date (newest first)
const sortedTickets = [...filteredTickets].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
// Stats
const stats = {
total: tickets.length,
pending: tickets.filter(t => t.status === 'pending').length,
confirmed: tickets.filter(t => t.status === 'confirmed').length,
checkedIn: tickets.filter(t => t.status === 'checked_in').length,
cancelled: tickets.filter(t => t.status === 'cancelled').length,
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
};
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>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">Manage Bookings</h1>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-primary-dark">{stats.total}</p>
<p className="text-sm text-gray-500">Total</p>
</Card>
<Card className="p-4 text-center border-l-4 border-yellow-400">
<p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
<p className="text-sm text-gray-500">Pending</p>
</Card>
<Card className="p-4 text-center border-l-4 border-green-400">
<p className="text-2xl font-bold text-green-600">{stats.confirmed}</p>
<p className="text-sm text-gray-500">Confirmed</p>
</Card>
<Card className="p-4 text-center border-l-4 border-blue-400">
<p className="text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
<p className="text-sm text-gray-500">Checked In</p>
</Card>
<Card className="p-4 text-center border-l-4 border-red-400">
<p className="text-2xl font-bold text-red-600">{stats.cancelled}</p>
<p className="text-sm text-gray-500">Cancelled</p>
</Card>
<Card className="p-4 text-center border-l-4 border-orange-400">
<p className="text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
<p className="text-sm text-gray-500">Pending Payment</p>
</Card>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex items-center gap-2 mb-4">
<FunnelIcon className="w-5 h-5 text-gray-500" />
<span className="font-medium">Filters</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select
value={selectedEvent}
onChange={(e) => setSelectedEvent(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="checked_in">Checked In</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
<select
value={selectedPaymentStatus}
onChange={(e) => setSelectedPaymentStatus(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Payment Statuses</option>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
<option value="refunded">Refunded</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
</Card>
{/* Bookings List */}
<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">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Payment</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">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">
{sortedTickets.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
No bookings found.
</td>
</tr>
) : (
sortedTickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4 text-gray-400" />
<span className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<EnvelopeIcon className="w-4 h-4" />
<span>{ticket.attendeeEmail || 'N/A'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<PhoneIcon className="w-4 h-4" />
<span>{ticket.attendeePhone || 'N/A'}</span>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className="text-sm">
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
</span>
</td>
<td className="px-6 py-4">
<div className="space-y-1">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
{ticket.payment?.status || 'pending'}
</span>
<p className="text-sm text-gray-500">
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
</p>
{ticket.payment && (
<p className="text-sm font-medium">
{ticket.payment.amount?.toLocaleString()} {ticket.payment.currency}
</p>
)}
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
{ticket.status}
</span>
{ticket.qrCode && (
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(ticket.createdAt)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{/* Mark as Paid (for pending payments) */}
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleMarkPaid(ticket.id)}
isLoading={processing === ticket.id}
className="text-green-600 hover:bg-green-50"
>
<CurrencyDollarIcon className="w-4 h-4 mr-1" />
Mark Paid
</Button>
)}
{/* Check-in (for confirmed tickets) */}
{ticket.status === 'confirmed' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleCheckin(ticket.id)}
isLoading={processing === ticket.id}
className="text-blue-600 hover:bg-blue-50"
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
Check In
</Button>
)}
{/* Cancel (for pending/confirmed) */}
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
<Button
size="sm"
variant="ghost"
onClick={() => handleCancel(ticket.id)}
isLoading={processing === ticket.id}
className="text-red-600 hover:bg-red-50"
>
<XCircleIcon className="w-4 h-4 mr-1" />
Cancel
</Button>
)}
{ticket.status === 'checked_in' && (
<span className="text-sm text-green-600 flex items-center gap-1">
<CheckCircleIcon className="w-4 h-4" />
Attended
</span>
)}
{ticket.status === 'cancelled' && (
<span className="text-sm text-gray-400">Cancelled</span>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { contactsApi, Contact } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function AdminContactsPage() {
const { t, locale } = useLanguage();
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string>('');
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
useEffect(() => {
loadContacts();
}, [statusFilter]);
const loadContacts = async () => {
try {
const { contacts } = await contactsApi.getAll(statusFilter || undefined);
setContacts(contacts);
} catch (error) {
toast.error('Failed to load contacts');
} finally {
setLoading(false);
}
};
const handleStatusChange = async (id: string, status: string) => {
try {
await contactsApi.updateStatus(id, status);
toast.success('Status updated');
loadContacts();
if (selectedContact?.id === id) {
setSelectedContact({ ...selectedContact, status: status as any });
}
} catch (error) {
toast.error('Failed to update status');
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
new: 'badge-info',
read: 'badge-warning',
replied: 'badge-success',
};
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
};
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>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.nav.contacts')}</h1>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">All</option>
<option value="new">New</option>
<option value="read">Read</option>
<option value="replied">Replied</option>
</select>
</div>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Messages List */}
<div className="lg:col-span-1">
<Card className="divide-y divide-secondary-light-gray max-h-[600px] overflow-y-auto">
{contacts.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<EnvelopeIcon className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>No messages</p>
</div>
) : (
contacts.map((contact) => (
<button
key={contact.id}
onClick={() => {
setSelectedContact(contact);
if (contact.status === 'new') {
handleStatusChange(contact.id, 'read');
}
}}
className={`w-full text-left p-4 hover:bg-gray-50 transition-colors ${
selectedContact?.id === contact.id ? 'bg-secondary-gray' : ''
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
{contact.status === 'new' ? (
<EnvelopeIcon className="w-4 h-4 text-secondary-blue" />
) : (
<EnvelopeOpenIcon className="w-4 h-4 text-gray-400" />
)}
<span className={`font-medium text-sm ${contact.status === 'new' ? 'text-primary-dark' : 'text-gray-600'}`}>
{contact.name}
</span>
</div>
{getStatusBadge(contact.status)}
</div>
<p className="mt-1 text-xs text-gray-500">{contact.email}</p>
<p className="mt-1 text-sm text-gray-600 truncate">{contact.message}</p>
<p className="mt-2 text-xs text-gray-400">{formatDate(contact.createdAt)}</p>
</button>
))
)}
</Card>
</div>
{/* Message Detail */}
<div className="lg:col-span-2">
<Card className="p-6 min-h-[400px]">
{selectedContact ? (
<div>
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-xl font-bold">{selectedContact.name}</h2>
<a
href={`mailto:${selectedContact.email}`}
className="text-secondary-blue hover:underline"
>
{selectedContact.email}
</a>
</div>
<div className="flex items-center gap-2">
{selectedContact.status !== 'replied' && (
<Button
size="sm"
variant="outline"
onClick={() => handleStatusChange(selectedContact.id, 'replied')}
>
<CheckIcon className="w-4 h-4 mr-1" />
Mark as Replied
</Button>
)}
<a href={`mailto:${selectedContact.email}`}>
<Button size="sm">
Reply
</Button>
</a>
</div>
</div>
<div className="border-t border-secondary-light-gray pt-6">
<p className="text-sm text-gray-500 mb-2">
Received: {formatDate(selectedContact.createdAt)}
</p>
<div className="prose prose-sm max-w-none">
<p className="whitespace-pre-wrap text-gray-700">{selectedContact.message}</p>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-500">
<div className="text-center">
<EnvelopeIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p>Select a message to view</p>
</div>
</div>
)}
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,961 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
EnvelopeIcon,
PencilIcon,
DocumentDuplicateIcon,
EyeIcon,
PaperAirplaneIcon,
ClockIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
type TabType = 'templates' | 'logs' | 'compose';
const DRAFT_STORAGE_KEY = 'spanglish-email-draft';
interface EmailDraft {
eventId: string;
templateSlug: string;
customSubject: string;
customBody: string;
recipientFilter: 'all' | 'confirmed' | 'pending' | 'checked_in';
savedAt: string;
}
export default function AdminEmailsPage() {
const { t, locale } = useLanguage();
const [activeTab, setActiveTab] = useState<TabType>('templates');
const [loading, setLoading] = useState(true);
// Templates state
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [showTemplateForm, setShowTemplateForm] = useState(false);
const [saving, setSaving] = useState(false);
// Logs state
const [logs, setLogs] = useState<EmailLog[]>([]);
const [logsOffset, setLogsOffset] = useState(0);
const [logsTotal, setLogsTotal] = useState(0);
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
// Stats state
const [stats, setStats] = useState<EmailStats | null>(null);
// Preview state
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
const [previewSubject, setPreviewSubject] = useState<string>('');
// Template form state
const [templateForm, setTemplateForm] = useState({
name: '',
slug: '',
subject: '',
subjectEs: '',
bodyHtml: '',
bodyHtmlEs: '',
bodyText: '',
bodyTextEs: '',
description: '',
isActive: true,
});
// Compose/Draft state
const [events, setEvents] = useState<any[]>([]);
const [composeForm, setComposeForm] = useState<EmailDraft>({
eventId: '',
templateSlug: '',
customSubject: '',
customBody: '',
recipientFilter: 'confirmed',
savedAt: '',
});
const [hasDraft, setHasDraft] = useState(false);
const [sending, setSending] = useState(false);
const [showRecipientPreview, setShowRecipientPreview] = useState(false);
const [previewRecipients, setPreviewRecipients] = useState<any[]>([]);
useEffect(() => {
loadData();
loadEvents();
loadDraft();
}, []);
const loadEvents = async () => {
try {
const res = await fetch('/api/events', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) {
const data = await res.json();
setEvents(data.events || []);
}
} catch (error) {
console.error('Failed to load events');
}
};
const loadDraft = () => {
try {
const saved = localStorage.getItem(DRAFT_STORAGE_KEY);
if (saved) {
const draft = JSON.parse(saved) as EmailDraft;
setComposeForm(draft);
setHasDraft(true);
}
} catch (error) {
console.error('Failed to load draft');
}
};
const saveDraft = () => {
try {
const draft: EmailDraft = {
...composeForm,
savedAt: new Date().toISOString(),
};
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
setHasDraft(true);
toast.success('Draft saved');
} catch (error) {
toast.error('Failed to save draft');
}
};
const clearDraft = () => {
localStorage.removeItem(DRAFT_STORAGE_KEY);
setComposeForm({
eventId: '',
templateSlug: '',
customSubject: '',
customBody: '',
recipientFilter: 'confirmed',
savedAt: '',
});
setHasDraft(false);
};
const loadRecipientPreview = async () => {
if (!composeForm.eventId) {
toast.error('Please select an event');
return;
}
try {
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) {
const data = await res.json();
let attendees = data.attendees || [];
// Apply filter
if (composeForm.recipientFilter !== 'all') {
attendees = attendees.filter((a: any) => a.status === composeForm.recipientFilter);
}
setPreviewRecipients(attendees);
setShowRecipientPreview(true);
}
} catch (error) {
toast.error('Failed to load recipients');
}
};
const handleSendEmail = async () => {
if (!composeForm.eventId || !composeForm.templateSlug) {
toast.error('Please select an event and template');
return;
}
if (!confirm(`Are you sure you want to send this email to ${previewRecipients.length} recipients?`)) {
return;
}
setSending(true);
try {
const res = await emailsApi.sendToEvent(composeForm.eventId, {
templateSlug: composeForm.templateSlug,
recipientFilter: composeForm.recipientFilter,
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
});
if (res.success || res.sentCount > 0) {
toast.success(`Sent ${res.sentCount} emails successfully`);
if (res.failedCount > 0) {
toast.error(`${res.failedCount} emails failed`);
}
clearDraft();
setShowRecipientPreview(false);
} else {
toast.error('Failed to send emails');
}
} catch (error: any) {
toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
}
};
useEffect(() => {
if (activeTab === 'logs') {
loadLogs();
}
}, [activeTab, logsOffset]);
const loadData = async () => {
try {
const [templatesRes, statsRes] = await Promise.all([
emailsApi.getTemplates(),
emailsApi.getStats(),
]);
setTemplates(templatesRes.templates);
setStats(statsRes.stats);
} catch (error) {
toast.error('Failed to load email data');
} finally {
setLoading(false);
}
};
const loadLogs = async () => {
try {
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset });
setLogs(res.logs);
setLogsTotal(res.pagination.total);
} catch (error) {
toast.error('Failed to load email logs');
}
};
const resetTemplateForm = () => {
setTemplateForm({
name: '',
slug: '',
subject: '',
subjectEs: '',
bodyHtml: '',
bodyHtmlEs: '',
bodyText: '',
bodyTextEs: '',
description: '',
isActive: true,
});
setEditingTemplate(null);
};
const handleEditTemplate = (template: EmailTemplate) => {
setTemplateForm({
name: template.name,
slug: template.slug,
subject: template.subject,
subjectEs: template.subjectEs || '',
bodyHtml: template.bodyHtml,
bodyHtmlEs: template.bodyHtmlEs || '',
bodyText: template.bodyText || '',
bodyTextEs: template.bodyTextEs || '',
description: template.description || '',
isActive: template.isActive,
});
setEditingTemplate(template);
setShowTemplateForm(true);
};
const handleSaveTemplate = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const data = {
...templateForm,
subjectEs: templateForm.subjectEs || undefined,
bodyHtmlEs: templateForm.bodyHtmlEs || undefined,
bodyText: templateForm.bodyText || undefined,
bodyTextEs: templateForm.bodyTextEs || undefined,
description: templateForm.description || undefined,
};
if (editingTemplate) {
await emailsApi.updateTemplate(editingTemplate.id, data);
toast.success('Template updated');
} else {
await emailsApi.createTemplate(data);
toast.success('Template created');
}
setShowTemplateForm(false);
resetTemplateForm();
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to save template');
} finally {
setSaving(false);
}
};
const handlePreviewTemplate = async (template: EmailTemplate) => {
try {
const res = await emailsApi.preview({
templateSlug: template.slug,
variables: {
attendeeName: 'John Doe',
attendeeEmail: 'john@example.com',
ticketId: 'TKT-ABC123',
eventTitle: 'Spanglish Night - January Edition',
eventDate: 'January 28, 2026',
eventTime: '7:00 PM',
eventLocation: 'Casa Cultural, Asunción',
eventLocationUrl: 'https://maps.google.com',
eventPrice: '50,000 PYG',
paymentAmount: '50,000 PYG',
paymentMethod: 'Lightning',
paymentReference: 'PAY-XYZ789',
paymentDate: 'January 28, 2026',
customMessage: 'This is a preview message.',
},
locale,
});
setPreviewSubject(res.subject);
setPreviewHtml(res.bodyHtml);
} catch (error) {
toast.error('Failed to preview template');
}
};
const handleDeleteTemplate = async (id: string) => {
if (!confirm('Are you sure you want to delete this template?')) return;
try {
await emailsApi.deleteTemplate(id);
toast.success('Template deleted');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to delete template');
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'sent':
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
case 'failed':
return <XCircleIcon className="w-5 h-5 text-red-500" />;
case 'pending':
return <ClockIcon className="w-5 h-5 text-yellow-500" />;
case 'bounced':
return <ExclamationTriangleIcon className="w-5 h-5 text-orange-500" />;
default:
return <ClockIcon className="w-5 h-5 text-gray-500" />;
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
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>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">Email Center</h1>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 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">
<EnvelopeIcon className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats.total}</p>
<p className="text-sm text-gray-500">Total Sent</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">{stats.sent}</p>
<p className="text-sm text-gray-500">Delivered</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">{stats.pending}</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-red-100 rounded-full flex items-center justify-center">
<XCircleIcon className="w-5 h-5 text-red-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats.failed}</p>
<p className="text-sm text-gray-500">Failed</p>
</div>
</div>
</Card>
</div>
)}
{/* Tabs */}
<div className="border-b border-secondary-light-gray mb-6">
<nav className="flex gap-6">
{(['templates', 'compose', 'logs'] 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 relative',
{
'border-primary-yellow text-primary-dark': activeTab === tab,
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab,
}
)}
>
{tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'}
{tab === 'compose' && hasDraft && (
<span className="absolute -top-1 -right-2 w-2 h-2 bg-primary-yellow rounded-full" />
)}
</button>
))}
</nav>
</div>
{/* Templates Tab */}
{activeTab === 'templates' && (
<div>
<div className="flex justify-between items-center mb-4">
<p className="text-gray-600">Manage email templates for booking confirmations, receipts, and updates.</p>
<Button onClick={() => { resetTemplateForm(); setShowTemplateForm(true); }}>
Create Template
</Button>
</div>
<div className="grid gap-4">
{templates.map((template) => (
<Card key={template.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">{template.name}</h3>
{template.isSystem && (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">System</span>
)}
{!template.isActive && (
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded">Inactive</span>
)}
</div>
<p className="text-sm text-gray-500 mt-1">{template.slug}</p>
<p className="text-sm text-gray-600 mt-2">{template.description || 'No description'}</p>
<p className="text-sm font-medium mt-2">Subject: {template.subject}</p>
{template.variables && template.variables.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{template.variables.slice(0, 5).map((v: any) => (
<span key={v.name} className="text-xs bg-primary-yellow/20 text-primary-dark px-2 py-0.5 rounded">
{`{{${v.name}}}`}
</span>
))}
{template.variables.length > 5 && (
<span className="text-xs text-gray-500">+{template.variables.length - 5} more</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handlePreviewTemplate(template)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="Preview"
>
<EyeIcon className="w-5 h-5" />
</button>
<button
onClick={() => handleEditTemplate(template)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="Edit"
>
<PencilIcon className="w-5 h-5" />
</button>
{!template.isSystem && (
<button
onClick={() => handleDeleteTemplate(template.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Delete"
>
<XCircleIcon className="w-5 h-5" />
</button>
)}
</div>
</div>
</Card>
))}
</div>
</div>
)}
{/* Compose Tab */}
{activeTab === 'compose' && (
<div>
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold">Compose Email to Event Attendees</h2>
<div className="flex items-center gap-2">
{hasDraft && (
<span className="text-xs text-gray-500">
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString() : ''}
</span>
)}
<Button variant="outline" size="sm" onClick={saveDraft}>
Save Draft
</Button>
{hasDraft && (
<Button variant="ghost" size="sm" onClick={clearDraft}>
Clear Draft
</Button>
)}
</div>
</div>
<div className="space-y-4">
{/* Event Selection */}
<div>
<label className="block text-sm font-medium mb-1">Select Event *</label>
<select
value={composeForm.eventId}
onChange={(e) => setComposeForm({ ...composeForm, eventId: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="">Choose an event</option>
{events.filter(e => e.status === 'published').map((event) => (
<option key={event.id} value={event.id}>
{event.title} - {new Date(event.startDatetime).toLocaleDateString()}
</option>
))}
</select>
</div>
{/* Recipient Filter */}
<div>
<label className="block text-sm font-medium mb-1">Recipients</label>
<select
value={composeForm.recipientFilter}
onChange={(e) => setComposeForm({ ...composeForm, recipientFilter: e.target.value as any })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="all">All attendees</option>
<option value="confirmed">Confirmed only</option>
<option value="pending">Pending only</option>
<option value="checked_in">Checked in only</option>
</select>
</div>
{/* Template Selection */}
<div>
<label className="block text-sm font-medium mb-1">Email Template *</label>
<select
value={composeForm.templateSlug}
onChange={(e) => setComposeForm({ ...composeForm, templateSlug: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="">Choose a template</option>
{templates.filter(t => t.isActive).map((template) => (
<option key={template.id} value={template.slug}>
{template.name}
</option>
))}
</select>
</div>
{/* Custom Message */}
<div>
<label className="block text-sm font-medium mb-1">
Custom Message (optional)
</label>
<textarea
value={composeForm.customBody}
onChange={(e) => setComposeForm({ ...composeForm, customBody: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
rows={4}
placeholder="Add a custom message that will be included in the email..."
/>
<p className="text-xs text-gray-500 mt-1">
This will be available as {'{{customMessage}}'} in the template
</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-secondary-light-gray">
<Button
onClick={loadRecipientPreview}
disabled={!composeForm.eventId}
>
Preview Recipients
</Button>
</div>
</div>
</Card>
{/* Recipient Preview Modal */}
{showRecipientPreview && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-secondary-light-gray">
<h2 className="text-lg font-bold">Recipient Preview</h2>
<p className="text-sm text-gray-500">
{previewRecipients.length} recipient(s) will receive this email
</p>
</div>
<div className="flex-1 overflow-y-auto p-4">
{previewRecipients.length === 0 ? (
<p className="text-center text-gray-500 py-8">
No recipients match your filter criteria
</p>
) : (
<div className="space-y-2">
{previewRecipients.map((recipient: any) => (
<div
key={recipient.id}
className="flex items-center justify-between p-3 bg-secondary-gray rounded-btn"
>
<div>
<p className="font-medium text-sm">{recipient.attendeeFirstName} {recipient.attendeeLastName || ''}</p>
<p className="text-xs text-gray-500">{recipient.attendeeEmail}</p>
</div>
<span className={clsx('badge text-xs', {
'badge-success': recipient.status === 'confirmed',
'badge-warning': recipient.status === 'pending',
'badge-info': recipient.status === 'checked_in',
'badge-gray': recipient.status === 'cancelled',
})}>
{recipient.status}
</span>
</div>
))}
</div>
)}
</div>
<div className="p-4 border-t border-secondary-light-gray flex gap-3">
<Button
onClick={handleSendEmail}
isLoading={sending}
disabled={previewRecipients.length === 0}
>
Send to {previewRecipients.length} Recipients
</Button>
<Button variant="outline" onClick={() => setShowRecipientPreview(false)}>
Cancel
</Button>
</div>
</Card>
</div>
)}
</div>
)}
{/* Logs Tab */}
{activeTab === 'logs' && (
<div>
<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">Status</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Recipient</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Subject</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Sent</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">
{logs.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No emails sent yet
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-2">
{getStatusIcon(log.status)}
<span className="capitalize text-sm">{log.status}</span>
</div>
</td>
<td className="px-6 py-4">
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
<p className="text-sm text-gray-500">{log.recipientEmail}</p>
</td>
<td className="px-6 py-4 max-w-xs">
<p className="text-sm truncate">{log.subject}</p>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(log.sentAt || log.createdAt)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setSelectedLog(log)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="View Email"
>
<EyeIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{logsTotal > 20 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary-light-gray">
<p className="text-sm text-gray-600">
Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={logsOffset === 0}
onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}
>
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={logsOffset + 20 >= logsTotal}
onClick={() => setLogsOffset(logsOffset + 20)}
>
<ChevronRightIcon className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
)}
{/* Template Form Modal */}
{showTemplateForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold mb-6">
{editingTemplate ? 'Edit Template' : 'Create Template'}
</h2>
<form onSubmit={handleSaveTemplate} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Template Name"
value={templateForm.name}
onChange={(e) => setTemplateForm({ ...templateForm, name: e.target.value })}
required
placeholder="e.g., Booking Confirmation"
/>
<Input
label="Slug (unique identifier)"
value={templateForm.slug}
onChange={(e) => setTemplateForm({ ...templateForm, slug: e.target.value })}
required
disabled={editingTemplate?.isSystem}
placeholder="e.g., booking-confirmation"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Subject (English)"
value={templateForm.subject}
onChange={(e) => setTemplateForm({ ...templateForm, subject: e.target.value })}
required
placeholder="e.g., Your Spanglish ticket is confirmed"
/>
<Input
label="Subject (Spanish)"
value={templateForm.subjectEs}
onChange={(e) => setTemplateForm({ ...templateForm, subjectEs: e.target.value })}
placeholder="e.g., Tu entrada de Spanglish está confirmada"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Body HTML (English)</label>
<textarea
value={templateForm.bodyHtml}
onChange={(e) => setTemplateForm({ ...templateForm, bodyHtml: 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 font-mono text-sm"
rows={8}
required
placeholder="<h2>Your Booking is Confirmed!</h2>..."
/>
<p className="text-xs text-gray-500 mt-1">
Use {`{{variableName}}`} for dynamic content. Common variables: attendeeName, eventTitle, eventDate, ticketId
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Body HTML (Spanish)</label>
<textarea
value={templateForm.bodyHtmlEs}
onChange={(e) => setTemplateForm({ ...templateForm, bodyHtmlEs: 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 font-mono text-sm"
rows={6}
placeholder="<h2>¡Tu Reserva está Confirmada!</h2>..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={templateForm.description}
onChange={(e) => setTemplateForm({ ...templateForm, description: 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={2}
placeholder="What is this template used for?"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={templateForm.isActive}
onChange={(e) => setTemplateForm({ ...templateForm, isActive: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="isActive" className="text-sm">Template is active</label>
</div>
<div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving}>
{editingTemplate ? 'Update Template' : 'Create Template'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
>
Cancel
</Button>
</div>
</form>
</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">
<div>
<h2 className="text-lg font-bold">Email Preview</h2>
<p className="text-sm text-gray-500">Subject: {previewSubject}</p>
</div>
<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>
)}
{/* Log Detail Modal */}
{selectedLog && (
<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">
<div>
<h2 className="text-lg font-bold">Email Details</h2>
<div className="flex items-center gap-2 mt-1">
{getStatusIcon(selectedLog.status)}
<span className="capitalize text-sm">{selectedLog.status}</span>
{selectedLog.errorMessage && (
<span className="text-sm text-red-500">- {selectedLog.errorMessage}</span>
)}
</div>
</div>
<Button variant="outline" size="sm" onClick={() => setSelectedLog(null)}>
Close
</Button>
</div>
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
<p><strong>To:</strong> {selectedLog.recipientName} &lt;{selectedLog.recipientEmail}&gt;</p>
<p><strong>Subject:</strong> {selectedLog.subject}</p>
<p><strong>Sent:</strong> {formatDate(selectedLog.sentAt || selectedLog.createdAt)}</p>
</div>
<div className="flex-1 overflow-auto">
{selectedLog.bodyHtml ? (
<iframe
srcDoc={selectedLog.bodyHtml}
className="w-full h-full min-h-[400px]"
title="Email Content"
/>
) : (
<div className="p-4 text-gray-500">Email content not available</div>
)}
</div>
</Card>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,538 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, mediaApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, ArrowUpTrayIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
export default function AdminEventsPage() {
const { t, locale } = useLanguage();
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState<{
title: string;
titleEs: string;
description: string;
descriptionEs: string;
startDatetime: string;
endDatetime: string;
location: string;
locationUrl: string;
price: number;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl: string;
}>({
title: '',
titleEs: '',
description: '',
descriptionEs: '',
startDatetime: '',
endDatetime: '',
location: '',
locationUrl: '',
price: 0,
currency: 'PYG',
capacity: 50,
status: 'draft',
bannerUrl: '',
});
useEffect(() => {
loadEvents();
}, []);
const loadEvents = async () => {
try {
const { events } = await eventsApi.getAll();
setEvents(events);
} catch (error) {
toast.error('Failed to load events');
} finally {
setLoading(false);
}
};
const resetForm = () => {
setFormData({
title: '',
titleEs: '',
description: '',
descriptionEs: '',
startDatetime: '',
endDatetime: '',
location: '',
locationUrl: '',
price: 0,
currency: 'PYG',
capacity: 50,
status: 'draft' as const,
bannerUrl: '',
});
setEditingEvent(null);
};
const handleEdit = (event: Event) => {
setFormData({
title: event.title,
titleEs: event.titleEs || '',
description: event.description,
descriptionEs: event.descriptionEs || '',
startDatetime: event.startDatetime.slice(0, 16),
endDatetime: event.endDatetime?.slice(0, 16) || '',
location: event.location,
locationUrl: event.locationUrl || '',
price: event.price,
currency: event.currency,
capacity: event.capacity,
status: event.status,
bannerUrl: event.bannerUrl || '',
});
setEditingEvent(event);
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const eventData = {
title: formData.title,
titleEs: formData.titleEs || undefined,
description: formData.description,
descriptionEs: formData.descriptionEs || undefined,
startDatetime: new Date(formData.startDatetime).toISOString(),
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
location: formData.location,
locationUrl: formData.locationUrl || undefined,
price: formData.price,
currency: formData.currency,
capacity: formData.capacity,
status: formData.status,
bannerUrl: formData.bannerUrl || undefined,
};
if (editingEvent) {
await eventsApi.update(editingEvent.id, eventData);
toast.success('Event updated');
} else {
await eventsApi.create(eventData);
toast.success('Event created');
}
setShowForm(false);
resetForm();
loadEvents();
} catch (error: any) {
toast.error(error.message || 'Failed to save event');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this event?')) return;
try {
await eventsApi.delete(id);
toast.success('Event deleted');
loadEvents();
} catch (error) {
toast.error('Failed to delete event');
}
};
const handleStatusChange = async (event: Event, status: Event['status']) => {
try {
await eventsApi.update(event.id, { status });
toast.success('Status updated');
loadEvents();
} catch (error) {
toast.error('Failed to update status');
}
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const result = await mediaApi.upload(file, editingEvent?.id, 'event');
// Use proxied path so it works through Next.js rewrites
setFormData({ ...formData, bannerUrl: result.url });
toast.success('Image uploaded successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to upload image');
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
draft: 'badge-gray',
published: 'badge-success',
cancelled: 'badge-danger',
completed: 'badge-info',
archived: 'badge-gray',
};
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
};
const handleDuplicate = async (event: Event) => {
try {
await eventsApi.duplicate(event.id);
toast.success('Event duplicated successfully');
loadEvents();
} catch (error) {
toast.error('Failed to duplicate event');
}
};
const handleArchive = async (event: Event) => {
try {
await eventsApi.update(event.id, { status: 'archived' });
toast.success('Event archived');
loadEvents();
} catch (error) {
toast.error('Failed to archive event');
}
};
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>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
<Button onClick={() => { resetForm(); setShowForm(true); }}>
<PlusIcon className="w-5 h-5 mr-2" />
{t('admin.events.create')}
</Button>
</div>
{/* Event Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold mb-6">
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Title (English)"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
<Input
label="Title (Spanish)"
value={formData.titleEs}
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description (English)</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: 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={3}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
<textarea
value={formData.descriptionEs}
onChange={(e) => setFormData({ ...formData, descriptionEs: 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={3}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Start Date & Time"
type="datetime-local"
value={formData.startDatetime}
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })}
required
/>
<Input
label="End Date & Time"
type="datetime-local"
value={formData.endDatetime}
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })}
/>
</div>
<Input
label="Location"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
required
/>
<Input
label="Location URL (Google Maps)"
type="url"
value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
label="Price"
type="number"
min="0"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
/>
<div>
<label className="block text-sm font-medium mb-1">Currency</label>
<select
value={formData.currency}
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="PYG">PYG</option>
<option value="USD">USD</option>
</select>
</div>
<Input
label="Capacity"
type="number"
min="1"
value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="cancelled">Cancelled</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
</div>
{/* Image Upload */}
<div>
<label className="block text-sm font-medium mb-1">Event Banner Image</label>
<div className="mt-2">
{formData.bannerUrl ? (
<div className="relative">
<img
src={formData.bannerUrl}
alt="Event banner"
className="w-full h-40 object-cover rounded-btn"
/>
<button
type="button"
onClick={() => setFormData({ ...formData, bannerUrl: '' })}
className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded-full hover:bg-red-600"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-secondary-light-gray rounded-btn p-8 text-center cursor-pointer hover:border-primary-yellow transition-colors"
>
{uploading ? (
<div className="flex flex-col items-center">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
<p className="mt-2 text-sm text-gray-500">Uploading...</p>
</div>
) : (
<div className="flex flex-col items-center">
<PhotoIcon className="w-12 h-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-600">Click to upload event image</p>
<p className="text-xs text-gray-400">JPEG, PNG, GIF, WebP (max 5MB)</p>
</div>
)}
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
onChange={handleImageUpload}
className="hidden"
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving}>
{editingEvent ? 'Update Event' : 'Create Event'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => { setShowForm(false); resetForm(); }}
>
Cancel
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Events 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">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Date</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Capacity</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</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">
{events.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No events found. Create your first event!
</td>
</tr>
) : (
events.map((event) => (
<tr key={event.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={event.title}
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
<PhotoIcon className="w-6 h-6 text-gray-400" />
</div>
)}
<div>
<p className="font-medium">{event.title}</p>
<p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(event.startDatetime)}
</td>
<td className="px-6 py-4 text-sm">
{event.bookedCount || 0} / {event.capacity}
</td>
<td className="px-6 py-4">
{getStatusBadge(event.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-1">
{event.status === 'draft' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleStatusChange(event, 'published')}
>
Publish
</Button>
)}
<Link
href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"
title="Manage Event"
>
<EyeIcon className="w-4 h-4" />
</Link>
<button
onClick={() => handleEdit(event)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="Edit"
>
<PencilIcon className="w-4 h-4" />
</button>
<button
onClick={() => handleDuplicate(event)}
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
title="Duplicate"
>
<DocumentDuplicateIcon className="w-4 h-4" />
</button>
{event.status !== 'archived' && (
<button
onClick={() => handleArchive(event)}
className="p-2 hover:bg-gray-100 text-gray-600 rounded-btn"
title="Archive"
>
<ArchiveBoxIcon className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDelete(event.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,305 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { mediaApi, Media } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
PhotoIcon,
TrashIcon,
ArrowUpTrayIcon,
XMarkIcon,
MagnifyingGlassIcon,
LinkIcon,
CheckIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function AdminGalleryPage() {
const { locale } = useLanguage();
const [media, setMedia] = useState<Media[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [selectedImage, setSelectedImage] = useState<Media | null>(null);
const [filter, setFilter] = useState<string>('');
const [copiedId, setCopiedId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadMedia();
}, [filter]);
const loadMedia = async () => {
try {
// We need to call the media API - let's add it if it doesn't exist
const res = await fetch('/api/media', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) {
const data = await res.json();
setMedia(data.media || []);
}
} catch (error) {
toast.error('Failed to load media');
} finally {
setLoading(false);
}
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
let successCount = 0;
let failCount = 0;
for (const file of Array.from(files)) {
try {
await mediaApi.upload(file, undefined, 'gallery');
successCount++;
} catch (error) {
failCount++;
}
}
if (successCount > 0) {
toast.success(`${successCount} image(s) uploaded successfully`);
}
if (failCount > 0) {
toast.error(`${failCount} image(s) failed to upload`);
}
loadMedia();
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this image?')) return;
try {
await mediaApi.delete(id);
toast.success('Image deleted');
loadMedia();
if (selectedImage?.id === id) {
setSelectedImage(null);
}
} catch (error) {
toast.error('Failed to delete image');
}
};
const copyUrl = async (url: string, id: string) => {
const fullUrl = window.location.origin + url;
try {
await navigator.clipboard.writeText(fullUrl);
setCopiedId(id);
toast.success('URL copied');
setTimeout(() => setCopiedId(null), 2000);
} catch (error) {
toast.error('Failed to copy URL');
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const filteredMedia = media.filter(m => {
if (!filter) return true;
if (filter === 'gallery') return m.relatedType === 'gallery' || !m.relatedType;
if (filter === 'event') return m.relatedType === 'event';
return true;
});
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>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">Gallery Management</h1>
<Button onClick={() => fileInputRef.current?.click()} isLoading={uploading}>
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
Upload Images
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
multiple
onChange={handleUpload}
className="hidden"
/>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<Card className="p-4 text-center">
<p className="text-3xl font-bold text-primary-dark">{media.length}</p>
<p className="text-sm text-gray-500">Total Images</p>
</Card>
<Card className="p-4 text-center">
<p className="text-3xl font-bold text-blue-600">
{media.filter(m => m.relatedType === 'event').length}
</p>
<p className="text-sm text-gray-500">Event Images</p>
</Card>
<Card className="p-4 text-center">
<p className="text-3xl font-bold text-green-600">
{media.filter(m => m.relatedType === 'gallery' || !m.relatedType).length}
</p>
<p className="text-sm text-gray-500">Gallery Images</p>
</Card>
</div>
{/* Filter */}
<Card className="p-4 mb-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Filter:</label>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Images</option>
<option value="gallery">Gallery Only</option>
<option value="event">Event Banners</option>
</select>
</div>
</Card>
{/* Gallery Grid */}
{filteredMedia.length === 0 ? (
<Card className="p-12 text-center">
<PhotoIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<h3 className="text-lg font-semibold text-gray-600 mb-2">No images yet</h3>
<p className="text-gray-500 mb-4">Upload images to build your gallery</p>
<Button onClick={() => fileInputRef.current?.click()}>
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
Upload First Image
</Button>
</Card>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredMedia.map((item) => (
<Card key={item.id} className="group relative overflow-hidden aspect-square">
<img
src={item.fileUrl}
alt=""
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform"
onClick={() => setSelectedImage(item)}
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
onClick={() => setSelectedImage(item)}
className="p-2 bg-white rounded-full hover:bg-gray-100"
title="View"
>
<MagnifyingGlassIcon className="w-5 h-5" />
</button>
<button
onClick={() => copyUrl(item.fileUrl, item.id)}
className="p-2 bg-white rounded-full hover:bg-gray-100"
title="Copy URL"
>
{copiedId === item.id ? (
<CheckIcon className="w-5 h-5 text-green-600" />
) : (
<LinkIcon className="w-5 h-5" />
)}
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 bg-white rounded-full hover:bg-red-100 text-red-600"
title="Delete"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
{item.relatedType && (
<div className="absolute top-2 left-2">
<span className={`text-xs px-2 py-1 rounded ${
item.relatedType === 'event' ? 'bg-blue-500 text-white' : 'bg-green-500 text-white'
}`}>
{item.relatedType === 'event' ? 'Event' : 'Gallery'}
</span>
</div>
)}
</Card>
))}
</div>
)}
{/* Image Preview Modal */}
{selectedImage && (
<div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedImage(null)}
>
<button
className="absolute top-4 right-4 text-white hover:text-gray-300"
onClick={() => setSelectedImage(null)}
>
<XMarkIcon className="w-8 h-8" />
</button>
<div
className="max-w-4xl max-h-[80vh] flex flex-col items-center"
onClick={(e) => e.stopPropagation()}
>
<img
src={selectedImage.fileUrl}
alt=""
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
<div className="mt-4 bg-white rounded-lg p-4 w-full max-w-md">
<p className="text-sm text-gray-500 mb-2">
Uploaded: {formatDate(selectedImage.createdAt)}
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={window.location.origin + selectedImage.fileUrl}
readOnly
className="flex-1 px-3 py-2 text-sm border rounded-btn bg-gray-50"
/>
<Button
size="sm"
onClick={() => copyUrl(selectedImage.fileUrl, selectedImage.id)}
>
{copiedId === selectedImage.id ? 'Copied!' : 'Copy'}
</Button>
</div>
<div className="flex gap-2 mt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => handleDelete(selectedImage.id)}
>
<TrashIcon className="w-4 h-4 mr-2" />
Delete
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import LanguageToggle from '@/components/LanguageToggle';
import Button from '@/components/ui/Button';
import {
HomeIcon,
CalendarIcon,
TicketIcon,
UsersIcon,
CreditCardIcon,
EnvelopeIcon,
InboxIcon,
PhotoIcon,
Cog6ToothIcon,
ArrowLeftOnRectangleIcon,
Bars3Icon,
XMarkIcon,
BanknotesIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useState } from 'react';
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const { t, locale } = useLanguage();
const { user, isAdmin, isLoading, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
if (!isLoading && (!user || !isAdmin)) {
router.push('/login');
}
}, [user, isAdmin, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
if (!user || !isAdmin) {
return null;
}
const navigation = [
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
];
const handleLogout = () => {
logout();
router.push('/');
};
return (
<div className="min-h-screen bg-secondary-gray">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={clsx(
'fixed top-0 left-0 z-50 h-full w-64 bg-white shadow-lg transform transition-transform duration-300 lg:transform-none',
{
'translate-x-0': sidebarOpen,
'-translate-x-full lg:translate-x-0': !sidebarOpen,
}
)}
>
<div className="flex flex-col h-full">
{/* Logo */}
<div className="p-6 border-b border-secondary-light-gray">
<Link href="/" className="flex items-center gap-2">
<span className="text-xl font-bold font-heading text-primary-dark">
Span<span className="text-primary-yellow">glish</span>
</span>
</Link>
<p className="text-xs text-gray-500 mt-1">{t('admin.nav.dashboard')}</p>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-btn transition-colors',
{
'bg-primary-yellow text-primary-dark font-medium': isActive,
'text-gray-700 hover:bg-gray-100': !isActive,
}
)}
>
<item.icon className="w-5 h-5" />
{item.name}
</Link>
);
})}
</nav>
{/* User section */}
<div className="p-4 border-t border-secondary-light-gray">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
<span className="font-semibold text-primary-dark">
{user.name.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium text-sm">{user.name}</p>
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-btn transition-colors"
>
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
{t('nav.logout')}
</button>
</div>
</div>
</aside>
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<header className="sticky top-0 z-30 bg-white shadow-sm">
<div className="flex items-center justify-between px-4 py-4">
<button
className="lg:hidden p-2 rounded-btn hover:bg-gray-100"
onClick={() => setSidebarOpen(true)}
>
<Bars3Icon className="w-6 h-6" />
</button>
<div className="flex items-center gap-4 ml-auto">
<LanguageToggle />
<Link href="/">
<Button variant="outline" size="sm">
View Site
</Button>
</Link>
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { adminApi, DashboardData } from '@/lib/api';
import Card from '@/components/ui/Card';
import {
UsersIcon,
CalendarIcon,
TicketIcon,
CurrencyDollarIcon,
EnvelopeIcon,
UserGroupIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
export default function AdminDashboardPage() {
const { t, locale } = useLanguage();
const { user } = useAuth();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
adminApi.getDashboard()
.then(({ dashboard }) => setData(dashboard))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const statCards = data ? [
{
label: t('admin.dashboard.stats.users'),
value: data.stats.totalUsers,
icon: UsersIcon,
color: 'bg-blue-100 text-blue-600',
href: '/admin/users',
},
{
label: t('admin.dashboard.stats.events'),
value: data.stats.totalEvents,
icon: CalendarIcon,
color: 'bg-purple-100 text-purple-600',
href: '/admin/events',
},
{
label: t('admin.dashboard.stats.tickets'),
value: data.stats.confirmedTickets,
icon: TicketIcon,
color: 'bg-green-100 text-green-600',
href: '/admin/tickets',
},
{
label: t('admin.dashboard.stats.revenue'),
value: `${data.stats.totalRevenue.toLocaleString()} PYG`,
icon: CurrencyDollarIcon,
color: 'bg-yellow-100 text-yellow-600',
href: '/admin/payments',
},
] : [];
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>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.dashboard.title')}</h1>
<p className="text-gray-600">
{t('admin.dashboard.welcome')}, {user?.name}
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat) => (
<Link key={stat.label} href={stat.href}>
<Card className="p-6 hover:shadow-card-hover transition-shadow">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="mt-2 text-2xl font-bold text-primary-dark">{stat.value}</p>
</div>
<div className={`w-12 h-12 rounded-btn flex items-center justify-center ${stat.color}`}>
<stat.icon className="w-6 h-6" />
</div>
</div>
</Card>
</Link>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Alerts */}
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
<div className="space-y-3">
{/* Low capacity warnings */}
{data?.upcomingEvents
.filter(event => {
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
return percentFull >= 80 && availableSeats > 0;
})
.map(event => {
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
return (
<Link
key={event.id}
href="/admin/events"
className="flex items-center justify-between p-3 bg-orange-50 rounded-btn hover:bg-orange-100 transition-colors"
>
<div className="flex items-center gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
<div>
<span className="text-sm font-medium">{event.title}</span>
<p className="text-xs text-gray-500">Only {availableSeats} spots left ({percentFull}% full)</p>
</div>
</div>
<span className="badge badge-warning">Low capacity</span>
</Link>
);
})}
{/* Sold out events */}
{data?.upcomingEvents
.filter(event => (event.availableSeats ?? (event.capacity - (event.bookedCount || 0))) === 0)
.map(event => (
<Link
key={event.id}
href="/admin/events"
className="flex items-center justify-between p-3 bg-red-50 rounded-btn hover:bg-red-100 transition-colors"
>
<div className="flex items-center gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-600" />
<div>
<span className="text-sm font-medium">{event.title}</span>
<p className="text-xs text-gray-500">Event is sold out!</p>
</div>
</div>
<span className="badge badge-danger">Sold out</span>
</Link>
))}
{data && data.stats.pendingPayments > 0 && (
<Link
href="/admin/payments"
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
>
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
<span className="text-sm">Pending payments</span>
</div>
<span className="badge badge-warning">{data.stats.pendingPayments}</span>
</Link>
)}
{data && data.stats.newContacts > 0 && (
<Link
href="/admin/contacts"
className="flex items-center justify-between p-3 bg-blue-50 rounded-btn hover:bg-blue-100 transition-colors"
>
<div className="flex items-center gap-3">
<EnvelopeIcon className="w-5 h-5 text-blue-600" />
<span className="text-sm">New messages</span>
</div>
<span className="badge badge-info">{data.stats.newContacts}</span>
</Link>
)}
{/* No alerts */}
{data &&
data.stats.pendingPayments === 0 &&
data.stats.newContacts === 0 &&
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
)}
</div>
</Card>
{/* Upcoming Events */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-lg">Upcoming Events</h2>
<Link href="/admin/events" className="text-sm text-secondary-blue hover:underline">
{t('common.viewAll')}
</Link>
</div>
{data?.upcomingEvents.length === 0 ? (
<p className="text-gray-500 text-sm">No upcoming events</p>
) : (
<div className="space-y-3">
{data?.upcomingEvents.slice(0, 5).map((event) => (
<Link
key={event.id}
href={`/admin/events`}
className="flex items-center justify-between p-3 bg-secondary-gray rounded-btn hover:bg-gray-200 transition-colors"
>
<div>
<p className="font-medium text-sm">{event.title}</p>
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
</div>
<span className="text-sm text-gray-600">
{event.bookedCount || 0}/{event.capacity}
</span>
</Link>
))}
</div>
)}
</Card>
{/* Quick Stats */}
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-4 bg-secondary-gray rounded-btn">
<UserGroupIcon className="w-8 h-8 mx-auto text-gray-400" />
<p className="mt-2 text-2xl font-bold">{data?.stats.totalSubscribers || 0}</p>
<p className="text-xs text-gray-500">Subscribers</p>
</div>
<div className="text-center p-4 bg-secondary-gray rounded-btn">
<TicketIcon className="w-8 h-8 mx-auto text-gray-400" />
<p className="mt-2 text-2xl font-bold">{data?.stats.totalTickets || 0}</p>
<p className="text-xs text-gray-500">Total Bookings</p>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,461 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { paymentOptionsApi, PaymentOptionsConfig } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
CreditCardIcon,
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
CheckCircleIcon,
XCircleIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function PaymentOptionsPage() {
const { t, locale } = useLanguage();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [options, setOptions] = useState<PaymentOptionsConfig>({
tpagoEnabled: false,
tpagoLink: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
lightningEnabled: true,
cashEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
});
useEffect(() => {
loadOptions();
}, []);
const loadOptions = async () => {
try {
setLoading(true);
const { paymentOptions } = await paymentOptionsApi.getGlobal();
setOptions(paymentOptions);
} catch (error) {
console.error('Failed to load payment options:', error);
toast.error('Failed to load payment options');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
await paymentOptionsApi.updateGlobal(options);
toast.success('Payment options saved successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to save payment options');
} finally {
setSaving(false);
}
};
const updateOption = <K extends keyof PaymentOptionsConfig>(
key: K,
value: PaymentOptionsConfig[K]
) => {
setOptions((prev) => ({ ...prev, [key]: value }));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-primary-dark">
{locale === 'es' ? 'Opciones de Pago' : 'Payment Options'}
</h1>
<p className="text-gray-600 mt-1">
{locale === 'es'
? 'Configura los métodos de pago disponibles para todos los eventos'
: 'Configure payment methods available for all events'}
</p>
</div>
<Button onClick={handleSave} isLoading={saving}>
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
</div>
{/* TPago / International Card */}
<Card className="mb-6">
<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>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'TPago / Tarjeta Internacional' : 'TPago / International Card'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
</p>
</div>
</div>
<button
onClick={() => updateOption('tpagoEnabled', !options.tpagoEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.tpagoEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.tpagoEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.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'}
</label>
<Input
value={options.tpagoLink || ''}
onChange={(e) => updateOption('tpagoLink', e.target.value || null)}
placeholder="https://www.tpago.com.py/links?alias=..."
/>
</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={options.tpagoInstructions || ''}
onChange={(e) => updateOption('tpagoInstructions', e.target.value || null)}
rows={3}
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="Instructions for users..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instrucciones (Español)
</label>
<textarea
value={options.tpagoInstructionsEs || ''}
onChange={(e) => updateOption('tpagoInstructionsEs', e.target.value || null)}
rows={3}
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="Instrucciones para usuarios..."
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Bank Transfer */}
<Card className="mb-6">
<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>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
</p>
</div>
</div>
<button
onClick={() => updateOption('bankTransferEnabled', !options.bankTransferEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.bankTransferEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.bankTransferEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.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
value={options.bankName || ''}
onChange={(e) => updateOption('bankName', e.target.value || null)}
placeholder="e.g., Banco Itaú"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Titular de la Cuenta' : 'Account Holder'}
</label>
<Input
value={options.bankAccountHolder || ''}
onChange={(e) => updateOption('bankAccountHolder', e.target.value || null)}
placeholder="e.g., Juan Pérez"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Número de Cuenta' : 'Account Number'}
</label>
<Input
value={options.bankAccountNumber || ''}
onChange={(e) => updateOption('bankAccountNumber', e.target.value || null)}
placeholder="e.g., 1234567890"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Alias
</label>
<Input
value={options.bankAlias || ''}
onChange={(e) => updateOption('bankAlias', e.target.value || null)}
placeholder="e.g., spanglish.pagos"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Teléfono' : 'Phone Number'}
</label>
<Input
value={options.bankPhone || ''}
onChange={(e) => updateOption('bankPhone', e.target.value || null)}
placeholder="e.g., +595 981 123456"
/>
</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={options.bankNotes || ''}
onChange={(e) => updateOption('bankNotes', e.target.value || null)}
rows={3}
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="Additional notes for users..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas Adicionales (Español)
</label>
<textarea
value={options.bankNotesEs || ''}
onChange={(e) => updateOption('bankNotesEs', e.target.value || null)}
rows={3}
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="Notas adicionales para usuarios..."
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Bitcoin Lightning */}
<Card className="mb-6">
<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-orange-100 rounded-full flex items-center justify-center">
<BoltIcon className="w-5 h-5 text-orange-600" />
</div>
<div>
<h3 className="font-semibold text-lg">Bitcoin Lightning</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago instantáneo - confirmación automática' : 'Instant payment - automatic confirmation'}
</p>
</div>
</div>
<button
onClick={() => updateOption('lightningEnabled', !options.lightningEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.lightningEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.lightningEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.lightningEnabled && (
<div className="pt-4 border-t">
<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 las variables de entorno de LNbits. Verifica que LNBITS_URL y LNBITS_API_KEY estén configurados correctamente.'
: 'Lightning is configured via LNbits environment variables. Make sure LNBITS_URL and LNBITS_API_KEY are properly set.'}
</p>
</div>
</div>
)}
</div>
</Card>
{/* Cash at Door */}
<Card className="mb-6">
<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>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Efectivo en el Evento' : 'Cash at the Door'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
</p>
</div>
</div>
<button
onClick={() => updateOption('cashEnabled', !options.cashEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.cashEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.cashEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.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={options.cashInstructions || ''}
onChange={(e) => updateOption('cashInstructions', e.target.value || null)}
rows={3}
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="Instructions for cash payments..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instrucciones (Español)
</label>
<textarea
value={options.cashInstructionsEs || ''}
onChange={(e) => updateOption('cashInstructionsEs', e.target.value || null)}
rows={3}
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="Instrucciones para pagos en efectivo..."
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Summary */}
<Card>
<div className="p-6">
<h3 className="font-semibold text-lg mb-4">
{locale === 'es' ? 'Resumen de Métodos Activos' : 'Active Methods Summary'}
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex items-center gap-2">
{options.tpagoEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.tpagoEnabled ? 'text-gray-900' : 'text-gray-400'}>
TPago
</span>
</div>
<div className="flex items-center gap-2">
{options.bankTransferEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.bankTransferEnabled ? 'text-gray-900' : 'text-gray-400'}>
{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}
</span>
</div>
<div className="flex items-center gap-2">
{options.lightningEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.lightningEnabled ? 'text-gray-900' : 'text-gray-400'}>
Lightning
</span>
</div>
<div className="flex items-center gap-2">
{options.cashEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.cashEnabled ? 'text-gray-900' : 'text-gray-400'}>
{locale === 'es' ? 'Efectivo' : 'Cash'}
</span>
</div>
</div>
</div>
</Card>
{/* Save button (bottom) */}
<div className="mt-6 flex justify-end">
<Button onClick={handleSave} isLoading={saving} size="lg">
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,744 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
CheckCircleIcon,
ArrowPathIcon,
ArrowDownTrayIcon,
DocumentArrowDownIcon,
XCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
ChatBubbleLeftIcon,
BoltIcon,
BanknotesIcon,
BuildingLibraryIcon,
CreditCardIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
type Tab = 'pending_approval' | 'all';
export default function AdminPaymentsPage() {
const { t, locale } = useLanguage();
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
const [statusFilter, setStatusFilter] = useState<string>('');
const [providerFilter, setProviderFilter] = useState<string>('');
// Modal state
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
const [noteText, setNoteText] = useState('');
const [processing, setProcessing] = useState(false);
// Export state
const [showExportModal, setShowExportModal] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportData, setExportData] = useState<{ payments: ExportedPayment[]; summary: FinancialSummary } | null>(null);
const [exportFilters, setExportFilters] = useState({
startDate: '',
endDate: '',
eventId: '',
});
useEffect(() => {
loadData();
}, [statusFilter, providerFilter]);
const loadData = async () => {
try {
setLoading(true);
const [pendingRes, allRes, eventsRes] = await Promise.all([
paymentsApi.getPendingApproval(),
paymentsApi.getAll({
status: statusFilter || undefined,
provider: providerFilter || undefined
}),
eventsApi.getAll(),
]);
setPendingApprovalPayments(pendingRes.payments);
setPayments(allRes.payments);
setEvents(eventsRes.events);
} catch (error) {
toast.error('Failed to load payments');
} finally {
setLoading(false);
}
};
const handleApprove = async (payment: PaymentWithDetails) => {
setProcessing(true);
try {
await paymentsApi.approve(payment.id, noteText);
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
setSelectedPayment(null);
setNoteText('');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to approve payment');
} finally {
setProcessing(false);
}
};
const handleReject = async (payment: PaymentWithDetails) => {
setProcessing(true);
try {
await paymentsApi.reject(payment.id, noteText);
toast.success(locale === 'es' ? 'Pago rechazado' : 'Payment rejected');
setSelectedPayment(null);
setNoteText('');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to reject payment');
} finally {
setProcessing(false);
}
};
const handleConfirmPayment = async (id: string) => {
try {
await paymentsApi.approve(id);
toast.success('Payment confirmed');
loadData();
} catch (error) {
toast.error('Failed to confirm payment');
}
};
const handleRefund = async (id: string) => {
if (!confirm('Are you sure you want to process this refund?')) return;
try {
await paymentsApi.refund(id);
toast.success('Refund processed');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to process refund');
}
};
const handleExport = async () => {
setExporting(true);
try {
const data = await adminApi.exportFinancial({
startDate: exportFilters.startDate || undefined,
endDate: exportFilters.endDate || undefined,
eventId: exportFilters.eventId || undefined,
});
setExportData(data);
} catch (error) {
toast.error('Failed to generate export');
} finally {
setExporting(false);
}
};
const downloadCSV = () => {
if (!exportData) return;
const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'Event Title', 'Event Date'];
const rows = exportData.payments.map(p => [
p.paymentId,
p.amount,
p.currency,
p.provider,
p.status,
p.reference || '',
p.paidAt || '',
p.createdAt,
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
p.attendeeEmail || '',
p.eventTitle,
p.eventDate,
]);
const csvContent = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `financial-export-${new Date().toISOString().split('T')[0]}.csv`;
link.click();
toast.success('CSV downloaded');
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatCurrency = (amount: number, currency: string) => {
return `${amount.toLocaleString()} ${currency}`;
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700',
pending_approval: 'bg-yellow-100 text-yellow-700',
paid: 'bg-green-100 text-green-700',
refunded: 'bg-blue-100 text-blue-700',
failed: 'bg-red-100 text-red-700',
cancelled: 'bg-gray-100 text-gray-700',
};
const labels: Record<string, string> = {
pending: locale === 'es' ? 'Pendiente' : 'Pending',
pending_approval: locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval',
paid: locale === 'es' ? 'Pagado' : 'Paid',
refunded: locale === 'es' ? 'Reembolsado' : 'Refunded',
failed: locale === 'es' ? 'Fallido' : 'Failed',
cancelled: locale === 'es' ? 'Cancelado' : 'Cancelled',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || 'bg-gray-100 text-gray-700'}`}>
{labels[status] || status}
</span>
);
};
const getProviderIcon = (provider: string) => {
const icons: Record<string, typeof BoltIcon> = {
lightning: BoltIcon,
cash: BanknotesIcon,
bank_transfer: BuildingLibraryIcon,
tpago: CreditCardIcon,
bancard: CreditCardIcon,
};
const Icon = icons[provider] || CreditCardIcon;
return <Icon className="w-4 h-4" />;
};
const getProviderLabel = (provider: string) => {
const labels: Record<string, string> = {
cash: locale === 'es' ? 'Efectivo' : 'Cash',
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
lightning: 'Lightning',
tpago: 'TPago',
bancard: 'Bancard',
};
return labels[provider] || provider;
};
// Calculate totals
const totalPending = payments
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
.reduce((sum, p) => sum + p.amount, 0);
const totalPaid = payments
.filter(p => p.status === 'paid')
.reduce((sum, p) => sum + p.amount, 0);
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>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
<Button onClick={() => setShowExportModal(true)}>
<DocumentArrowDownIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Exportar Datos' : 'Export Data'}
</Button>
</div>
{/* Approval Detail Modal */}
{selectedPayment && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg p-6">
<h2 className="text-xl font-bold mb-4">
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
</h2>
<div className="space-y-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">{locale === 'es' ? 'Monto' : 'Amount'}</p>
<p className="font-bold text-lg">{formatCurrency(selectedPayment.amount, selectedPayment.currency)}</p>
</div>
<div>
<p className="text-gray-500">{locale === 'es' ? 'Método' : 'Method'}</p>
<p className="font-medium flex items-center gap-2">
{getProviderIcon(selectedPayment.provider)}
{getProviderLabel(selectedPayment.provider)}
</p>
</div>
</div>
</div>
{selectedPayment.ticket && (
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-2">{locale === 'es' ? 'Asistente' : 'Attendee'}</h4>
<p className="font-bold">
{selectedPayment.ticket.attendeeFirstName} {selectedPayment.ticket.attendeeLastName}
</p>
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeeEmail}</p>
{selectedPayment.ticket.attendeePhone && (
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
)}
</div>
)}
{selectedPayment.event && (
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-2">{locale === 'es' ? 'Evento' : 'Event'}</h4>
<p className="font-bold">{selectedPayment.event.title}</p>
<p className="text-sm text-gray-600">{formatDate(selectedPayment.event.startDatetime)}</p>
</div>
)}
{selectedPayment.userMarkedPaidAt && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<ClockIcon className="w-4 h-4" />
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Nota interna (opcional)' : 'Internal note (optional)'}
</label>
<textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={2}
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={locale === 'es' ? 'Agregar nota...' : 'Add a note...'}
/>
</div>
</div>
<div className="flex gap-3">
<Button
onClick={() => handleApprove(selectedPayment)}
isLoading={processing}
className="flex-1"
>
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Aprobar' : 'Approve'}
</Button>
<Button
variant="outline"
onClick={() => handleReject(selectedPayment)}
isLoading={processing}
className="flex-1 border-red-300 text-red-600 hover:bg-red-50"
>
<XCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Rechazar' : 'Reject'}
</Button>
</div>
<button
onClick={() => { setSelectedPayment(null); setNoteText(''); }}
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</button>
</Card>
</div>
)}
{/* Export Modal */}
{showExportModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold mb-6">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
{!exportData ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={locale === 'es' ? 'Fecha Inicio' : 'Start Date'}
type="date"
value={exportFilters.startDate}
onChange={(e) => setExportFilters({ ...exportFilters, startDate: e.target.value })}
/>
<Input
label={locale === 'es' ? 'Fecha Fin' : 'End Date'}
type="date"
value={exportFilters.endDate}
onChange={(e) => setExportFilters({ ...exportFilters, endDate: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Evento (opcional)' : 'Event (optional)'}</label>
<select
value={exportFilters.eventId}
onChange={(e) => setExportFilters({ ...exportFilters, eventId: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="">{locale === 'es' ? 'Todos los Eventos' : 'All Events'}</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div className="flex gap-3 pt-4">
<Button onClick={handleExport} isLoading={exporting}>
{locale === 'es' ? 'Generar Reporte' : 'Generate Report'}
</Button>
<Button variant="outline" onClick={() => setShowExportModal(false)}>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
</div>
</div>
) : (
<div className="space-y-6">
{/* Summary */}
<div className="bg-secondary-gray rounded-btn p-4">
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Resumen Financiero' : 'Financial Summary'}</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p>
<p className="text-xl font-bold text-green-600">{exportData.summary.totalPaid.toLocaleString()} PYG</p>
<p className="text-xs text-gray-500">{exportData.summary.paidCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
</div>
<div>
<p className="text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
<p className="text-xl font-bold text-yellow-600">{exportData.summary.totalPending.toLocaleString()} PYG</p>
<p className="text-xs text-gray-500">{exportData.summary.pendingCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
</div>
<div>
<p className="text-gray-500">{locale === 'es' ? 'Total Reembolsado' : 'Total Refunded'}</p>
<p className="text-xl font-bold text-blue-600">{exportData.summary.totalRefunded.toLocaleString()} PYG</p>
<p className="text-xs text-gray-500">{exportData.summary.refundedCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
</div>
<div>
<p className="text-gray-500">{locale === 'es' ? 'Total Registros' : 'Total Records'}</p>
<p className="text-xl font-bold">{exportData.summary.totalPayments}</p>
</div>
</div>
</div>
{/* By Provider */}
<div className="bg-secondary-gray rounded-btn p-4">
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
<div>
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p>
</div>
<div>
<p className="text-gray-500">Lightning</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.lightning?.toLocaleString() || 0} PYG</p>
</div>
<div>
<p className="text-gray-500">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</p>
<p className="text-lg font-bold">{(exportData.summary.byProvider as any).bank_transfer?.toLocaleString() || 0} PYG</p>
</div>
<div>
<p className="text-gray-500">TPago</p>
<p className="text-lg font-bold">{(exportData.summary.byProvider as any).tpago?.toLocaleString() || 0} PYG</p>
</div>
<div>
<p className="text-gray-500">Bancard</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
</div>
</div>
</div>
<div className="flex gap-3">
<Button onClick={downloadCSV}>
<ArrowDownTrayIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
</Button>
<Button variant="outline" onClick={() => setExportData(null)}>
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
</Button>
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }}>
{locale === 'es' ? 'Cerrar' : 'Close'}
</Button>
</div>
</div>
)}
</Card>
</div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<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">
<ExclamationTriangleIcon className="w-5 h-5 text-yellow-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p>
<p className="text-xl font-bold text-yellow-600">{pendingApprovalPayments.length}</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">
<ClockIcon className="w-5 h-5 text-gray-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</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-sm text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p>
<p className="text-xl font-bold text-green-600">{formatCurrency(totalPaid, 'PYG')}</p>
</div>
</div>
</Card>
<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">
<BoltIcon className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagos' : 'Total Payments'}</p>
<p className="text-xl font-bold">{payments.length}</p>
</div>
</div>
</Card>
</div>
{/* Tabs */}
<div className="border-b mb-6">
<nav className="flex gap-4">
<button
onClick={() => setActiveTab('pending_approval')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'pending_approval'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}
{pendingApprovalPayments.length > 0 && (
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">
{pendingApprovalPayments.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('all')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'all'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{locale === 'es' ? 'Todos los Pagos' : 'All Payments'}
</button>
</nav>
</div>
{/* Pending Approval Tab */}
{activeTab === 'pending_approval' && (
<>
{pendingApprovalPayments.length === 0 ? (
<Card className="p-12 text-center">
<CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" />
<p className="text-gray-500">
{locale === 'es'
? 'No hay pagos pendientes de aprobación'
: 'No payments pending approval'}
</p>
</Card>
) : (
<div className="space-y-4">
{pendingApprovalPayments.map((payment) => (
<Card key={payment.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
{getProviderIcon(payment.provider)}
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<p className="font-bold text-lg">{formatCurrency(payment.amount, payment.currency)}</p>
{getStatusBadge(payment.status)}
</div>
{payment.ticket && (
<p className="text-sm font-medium">
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
</p>
)}
{payment.event && (
<p className="text-sm text-gray-500">{payment.event.title}</p>
)}
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
<span className="flex items-center gap-1">
{getProviderIcon(payment.provider)}
{getProviderLabel(payment.provider)}
</span>
{payment.userMarkedPaidAt && (
<span className="flex items-center gap-1">
<ClockIcon className="w-3 h-3" />
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
</span>
)}
</div>
</div>
</div>
<Button onClick={() => setSelectedPayment(payment)}>
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
</div>
</Card>
))}
</div>
)}
</>
)}
{/* All Payments Tab */}
{activeTab === 'all' && (
<>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
<select
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
<option value="lightning">Lightning</option>
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
<option value="bank_transfer">{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}</option>
<option value="tpago">TPago</option>
</select>
</div>
</div>
</Card>
{/* Payments 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">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Evento' : 'Event'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Monto' : 'Amount'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Método' : 'Method'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Fecha' : 'Date'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{payments.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}
</td>
</tr>
) : (
payments.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
{payment.ticket ? (
<div>
<p className="font-medium text-sm">
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
</p>
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
</div>
) : (
<span className="text-gray-400 text-sm">-</span>
)}
</td>
<td className="px-6 py-4">
{payment.event ? (
<p className="text-sm">{payment.event.title}</p>
) : (
<span className="text-gray-400 text-sm">-</span>
)}
</td>
<td className="px-6 py-4 font-medium">
{formatCurrency(payment.amount, payment.currency)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
{getProviderIcon(payment.provider)}
{getProviderLabel(payment.provider)}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(payment.createdAt)}
</td>
<td className="px-6 py-4">
{getStatusBadge(payment.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
<Button
size="sm"
onClick={() => setSelectedPayment(payment)}
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
)}
{payment.status === 'paid' && (
<Button
size="sm"
variant="outline"
onClick={() => handleRefund(payment.id)}
>
<ArrowPathIcon className="w-4 h-4 mr-1" />
{t('admin.payments.refund')}
</Button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,407 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { CheckCircleIcon, XCircleIcon, PlusIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
export default function AdminTicketsPage() {
const { t, locale } = useLanguage();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [selectedEvent, setSelectedEvent] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
// Manual ticket creation state
const [showCreateForm, setShowCreateForm] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm, setCreateForm] = useState({
eventId: '',
firstName: '',
lastName: '',
email: '',
phone: '',
preferredLanguage: 'en' as 'en' | 'es',
autoCheckin: false,
adminNote: '',
});
useEffect(() => {
Promise.all([
ticketsApi.getAll(),
eventsApi.getAll(),
])
.then(([ticketsRes, eventsRes]) => {
setTickets(ticketsRes.tickets);
setEvents(eventsRes.events);
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const loadTickets = async () => {
try {
const params: any = {};
if (selectedEvent) params.eventId = selectedEvent;
if (statusFilter) params.status = statusFilter;
const { tickets } = await ticketsApi.getAll(params);
setTickets(tickets);
} catch (error) {
toast.error('Failed to load tickets');
}
};
useEffect(() => {
if (!loading) {
loadTickets();
}
}, [selectedEvent, statusFilter]);
const handleCheckin = async (id: string) => {
try {
await ticketsApi.checkin(id);
toast.success('Check-in successful');
loadTickets();
} catch (error: any) {
toast.error(error.message || 'Check-in failed');
}
};
const handleCancel = async (id: string) => {
if (!confirm('Are you sure you want to cancel this ticket?')) return;
try {
await ticketsApi.cancel(id);
toast.success('Ticket cancelled');
loadTickets();
} catch (error) {
toast.error('Failed to cancel ticket');
}
};
const handleConfirm = async (id: string) => {
try {
await ticketsApi.updateStatus(id, 'confirmed');
toast.success('Ticket confirmed');
loadTickets();
} catch (error) {
toast.error('Failed to confirm ticket');
}
};
const handleCreateTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!createForm.eventId) {
toast.error('Please select an event');
return;
}
setCreating(true);
try {
await ticketsApi.adminCreate({
eventId: createForm.eventId,
firstName: createForm.firstName,
lastName: createForm.lastName || undefined,
email: createForm.email,
phone: createForm.phone,
preferredLanguage: createForm.preferredLanguage,
autoCheckin: createForm.autoCheckin,
adminNote: createForm.adminNote || undefined,
});
toast.success('Ticket created successfully');
setShowCreateForm(false);
setCreateForm({
eventId: '',
firstName: '',
lastName: '',
email: '',
phone: '',
preferredLanguage: 'en',
autoCheckin: false,
adminNote: '',
});
loadTickets();
} catch (error: any) {
toast.error(error.message || 'Failed to create ticket');
} finally {
setCreating(false);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
pending: 'badge-warning',
confirmed: 'badge-success',
cancelled: 'badge-danger',
checked_in: 'badge-info',
};
const labels: Record<string, string> = {
pending: t('admin.tickets.status.pending'),
confirmed: t('admin.tickets.status.confirmed'),
cancelled: t('admin.tickets.status.cancelled'),
checked_in: t('admin.tickets.status.checkedIn'),
};
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>;
};
const getEventName = (eventId: string) => {
const event = events.find(e => e.id === eventId);
return event?.title || 'Unknown Event';
};
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>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1>
<Button onClick={() => setShowCreateForm(true)}>
<PlusIcon className="w-5 h-5 mr-2" />
Create Ticket
</Button>
</div>
{/* Manual Ticket Creation Modal */}
{showCreateForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg p-6">
<h2 className="text-xl font-bold mb-6">Create Ticket Manually</h2>
<form onSubmit={handleCreateTicket} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Event *</label>
<select
value={createForm.eventId}
onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
required
>
<option value="">Select an event</option>
{events.filter(e => e.status === 'published').map((event) => (
<option key={event.id} value={event.id}>
{event.title} ({event.availableSeats} spots left)
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="First Name *"
value={createForm.firstName}
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })}
required
placeholder="First name"
/>
<Input
label="Last Name (optional)"
value={createForm.lastName}
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })}
placeholder="Last name"
/>
</div>
<Input
label="Email (optional)"
type="email"
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
placeholder="attendee@email.com"
/>
<Input
label="Phone (optional)"
value={createForm.phone}
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })}
placeholder="+595 XXX XXX XXX"
/>
<div>
<label className="block text-sm font-medium mb-1">Preferred Language</label>
<select
value={createForm.preferredLanguage}
onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="en">English</option>
<option value="es">Spanish</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Admin Note</label>
<textarea
value={createForm.adminNote}
onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
rows={2}
placeholder="Internal note about this booking (optional)"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoCheckin"
checked={createForm.autoCheckin}
onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="autoCheckin" className="text-sm">
Automatically check in (mark as present)
</label>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800">
Note: This creates a ticket with cash payment marked as paid. Use this for walk-ins at the door. Email and phone are optional for door entries.
</div>
<div className="flex gap-3 pt-4">
<Button type="submit" isLoading={creating}>
Create Ticket
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowCreateForm(false)}
>
Cancel
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-medium mb-1">Event</label>
<select
value={selectedEvent}
onChange={(e) => setSelectedEvent(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px]"
>
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="checked_in">Checked In</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</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">Ticket</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</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">
{tickets.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No tickets found
</td>
</tr>
) : (
tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div>
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
<p className="text-xs text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
</div>
</td>
<td className="px-6 py-4 text-sm">
{getEventName(ticket.eventId)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(ticket.createdAt)}
</td>
<td className="px-6 py-4">
{getStatusBadge(ticket.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{ticket.status === 'pending' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleConfirm(ticket.id)}
>
Confirm
</Button>
)}
{ticket.status === 'confirmed' && (
<Button
size="sm"
onClick={() => handleCheckin(ticket.id)}
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
{t('admin.tickets.checkin')}
</Button>
)}
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
<button
onClick={() => handleCancel(ticket.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Cancel"
>
<XCircleIcon className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { usersApi, User } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { TrashIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function AdminUsersPage() {
const { t, locale } = useLanguage();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [roleFilter, setRoleFilter] = useState<string>('');
useEffect(() => {
loadUsers();
}, [roleFilter]);
const loadUsers = async () => {
try {
const { users } = await usersApi.getAll(roleFilter || undefined);
setUsers(users);
} catch (error) {
toast.error('Failed to load users');
} finally {
setLoading(false);
}
};
const handleRoleChange = async (userId: string, newRole: string) => {
try {
await usersApi.update(userId, { role: newRole as any });
toast.success('Role updated');
loadUsers();
} catch (error) {
toast.error('Failed to update role');
}
};
const handleDelete = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await usersApi.delete(userId);
toast.success('User deleted');
loadUsers();
} catch (error: any) {
toast.error(error.message || 'Failed to delete user');
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const getRoleBadge = (role: string) => {
const styles: Record<string, string> = {
admin: 'badge-danger',
organizer: 'badge-info',
staff: 'badge-warning',
marketing: 'badge-success',
user: 'badge-gray',
};
return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>;
};
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>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">All Roles</option>
<option value="admin">{t('admin.users.roles.admin')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option>
<option value="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option>
<option value="user">{t('admin.users.roles.user')}</option>
</select>
</div>
</div>
</Card>
{/* Users 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">User</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">Role</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Joined</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">
{users.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No users found
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
<span className="font-semibold text-primary-dark">
{user.name.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{user.phone || '-'}
</td>
<td className="px-6 py-4">
<select
value={user.role}
onChange={(e) => handleRoleChange(user.id, e.target.value)}
className="px-2 py-1 rounded border border-secondary-light-gray text-sm"
>
<option value="user">{t('admin.users.roles.user')}</option>
<option value="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option>
<option value="admin">{t('admin.users.roles.admin')}</option>
</select>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(user.createdAt)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleDelete(user.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}