dev #1
@@ -1097,6 +1097,154 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin create manual ticket (sends confirmation email + ticket to attendee)
|
||||||
|
ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
||||||
|
eventId: z.string(),
|
||||||
|
firstName: z.string().min(2),
|
||||||
|
lastName: z.string().optional().or(z.literal('')),
|
||||||
|
email: z.string().email('Valid email is required for manual tickets'),
|
||||||
|
phone: z.string().optional().or(z.literal('')),
|
||||||
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
|
adminNote: z.string().max(1000).optional(),
|
||||||
|
})), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
// Get event
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
||||||
|
);
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check capacity
|
||||||
|
const existingCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, data.eventId),
|
||||||
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((existingCount?.count || 0) >= event.capacity) {
|
||||||
|
return c.json({ error: 'Event is at capacity' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const attendeeEmail = data.email.trim();
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
let user = await dbGet<any>(
|
||||||
|
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
||||||
|
);
|
||||||
|
|
||||||
|
const fullName = data.lastName && data.lastName.trim()
|
||||||
|
? `${data.firstName} ${data.lastName}`.trim()
|
||||||
|
: data.firstName;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const userId = generateId();
|
||||||
|
user = {
|
||||||
|
id: userId,
|
||||||
|
email: attendeeEmail,
|
||||||
|
password: '',
|
||||||
|
name: fullName,
|
||||||
|
phone: data.phone || null,
|
||||||
|
role: 'user',
|
||||||
|
languagePreference: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await (db as any).insert(users).values(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing active ticket for this user and event
|
||||||
|
const existingTicket = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).userId, user.id),
|
||||||
|
eq((tickets as any).eventId, data.eventId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||||
|
return c.json({ error: 'This person already has a ticket for this event' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ticket as confirmed
|
||||||
|
const ticketId = generateId();
|
||||||
|
const qrCode = generateTicketCode();
|
||||||
|
|
||||||
|
const newTicket = {
|
||||||
|
id: ticketId,
|
||||||
|
userId: user.id,
|
||||||
|
eventId: data.eventId,
|
||||||
|
attendeeFirstName: data.firstName,
|
||||||
|
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||||
|
attendeeEmail: attendeeEmail,
|
||||||
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
|
status: 'confirmed',
|
||||||
|
qrCode,
|
||||||
|
checkinAt: null,
|
||||||
|
adminNote: data.adminNote || null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(tickets).values(newTicket);
|
||||||
|
|
||||||
|
// Create payment record (marked as paid - manual entry)
|
||||||
|
const paymentId = generateId();
|
||||||
|
const adminUser = (c as any).get('user');
|
||||||
|
const newPayment = {
|
||||||
|
id: paymentId,
|
||||||
|
ticketId,
|
||||||
|
provider: 'cash',
|
||||||
|
amount: event.price,
|
||||||
|
currency: event.currency,
|
||||||
|
status: 'paid',
|
||||||
|
reference: 'Manual ticket',
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: adminUser?.id || null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(payments).values(newPayment);
|
||||||
|
|
||||||
|
// Send booking confirmation email + ticket (asynchronously)
|
||||||
|
emailService.sendBookingConfirmation(ticketId).then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[Email] Exception sending booking confirmation for manual ticket:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ticket: {
|
||||||
|
...newTicket,
|
||||||
|
event: {
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payment: newPayment,
|
||||||
|
message: 'Manual ticket created and confirmation email sent',
|
||||||
|
}, 201);
|
||||||
|
});
|
||||||
|
|
||||||
// Get all tickets (admin)
|
// Get all tickets (admin)
|
||||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
type TabType = 'overview' | 'attendees' | 'email' | 'payments';
|
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
||||||
|
|
||||||
export default function AdminEventDetailPage() {
|
export default function AdminEventDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -62,6 +62,7 @@ export default function AdminEventDetailPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
||||||
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
||||||
|
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
||||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||||
const [noteText, setNoteText] = useState('');
|
const [noteText, setNoteText] = useState('');
|
||||||
@@ -73,8 +74,19 @@ export default function AdminEventDetailPage() {
|
|||||||
autoCheckin: true,
|
autoCheckin: true,
|
||||||
adminNote: '',
|
adminNote: '',
|
||||||
});
|
});
|
||||||
|
const [manualTicketForm, setManualTicketForm] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
adminNote: '',
|
||||||
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Tickets tab state
|
||||||
|
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
|
||||||
|
const [ticketStatusFilter, setTicketStatusFilter] = useState<'all' | 'confirmed' | 'checked_in'>('all');
|
||||||
|
|
||||||
// Payment options state
|
// Payment options state
|
||||||
const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null);
|
const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null);
|
||||||
const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({});
|
const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({});
|
||||||
@@ -301,6 +313,30 @@ export default function AdminEventDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleManualTicket = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!event) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await ticketsApi.manualCreate({
|
||||||
|
eventId: event.id,
|
||||||
|
firstName: manualTicketForm.firstName,
|
||||||
|
lastName: manualTicketForm.lastName || undefined,
|
||||||
|
email: manualTicketForm.email,
|
||||||
|
phone: manualTicketForm.phone || undefined,
|
||||||
|
adminNote: manualTicketForm.adminNote || undefined,
|
||||||
|
});
|
||||||
|
toast.success('Manual ticket created — confirmation email sent');
|
||||||
|
setShowManualTicketModal(false);
|
||||||
|
setManualTicketForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
|
||||||
|
loadEventData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to create manual ticket');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Filtered tickets for attendees tab
|
// Filtered tickets for attendees tab
|
||||||
const filteredTickets = tickets.filter((ticket) => {
|
const filteredTickets = tickets.filter((ticket) => {
|
||||||
// Status filter
|
// Status filter
|
||||||
@@ -321,6 +357,25 @@ export default function AdminEventDetailPage() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filtered tickets for the Tickets tab (only confirmed/checked_in)
|
||||||
|
const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status));
|
||||||
|
const filteredConfirmedTickets = confirmedTickets.filter((ticket) => {
|
||||||
|
// Status filter
|
||||||
|
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Search filter
|
||||||
|
if (ticketSearchQuery) {
|
||||||
|
const query = ticketSearchQuery.toLowerCase();
|
||||||
|
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
fullName.includes(query) ||
|
||||||
|
ticket.id.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const handlePreviewEmail = async () => {
|
const handlePreviewEmail = async () => {
|
||||||
if (!selectedTemplate) {
|
if (!selectedTemplate) {
|
||||||
toast.error('Please select a template');
|
toast.error('Please select a template');
|
||||||
@@ -503,7 +558,7 @@ export default function AdminEventDetailPage() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-secondary-light-gray mb-6">
|
<div className="border-b border-secondary-light-gray mb-6">
|
||||||
<nav className="flex gap-6">
|
<nav className="flex gap-6">
|
||||||
{(['overview', 'attendees', 'email', 'payments'] as TabType[]).map((tab) => (
|
{(['overview', 'attendees', 'tickets', 'email', 'payments'] as TabType[]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
@@ -517,9 +572,10 @@ export default function AdminEventDetailPage() {
|
|||||||
>
|
>
|
||||||
{tab === 'overview' && <CalendarIcon className="w-4 h-4" />}
|
{tab === 'overview' && <CalendarIcon className="w-4 h-4" />}
|
||||||
{tab === 'attendees' && <UserGroupIcon className="w-4 h-4" />}
|
{tab === 'attendees' && <UserGroupIcon className="w-4 h-4" />}
|
||||||
|
{tab === 'tickets' && <TicketIcon className="w-4 h-4" />}
|
||||||
{tab === 'email' && <EnvelopeIcon className="w-4 h-4" />}
|
{tab === 'email' && <EnvelopeIcon className="w-4 h-4" />}
|
||||||
{tab === 'payments' && <CreditCardIcon className="w-4 h-4" />}
|
{tab === 'payments' && <CreditCardIcon className="w-4 h-4" />}
|
||||||
{tab === 'overview' ? 'Overview' : tab === 'attendees' ? `Attendees (${tickets.length})` : tab === 'email' ? 'Send Email' : (locale === 'es' ? 'Pagos' : 'Payments')}
|
{tab === 'overview' ? 'Overview' : tab === 'attendees' ? `Attendees (${tickets.length})` : tab === 'tickets' ? `Tickets (${confirmedTickets.length})` : tab === 'email' ? 'Send Email' : (locale === 'es' ? 'Pagos' : 'Payments')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -629,11 +685,17 @@ export default function AdminEventDetailPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Add at Door Button */}
|
{/* Action Buttons */}
|
||||||
<Button onClick={() => setShowAddAtDoorModal(true)}>
|
<div className="flex items-center gap-2">
|
||||||
<PlusIcon className="w-4 h-4 mr-2" />
|
<Button variant="outline" onClick={() => setShowManualTicketModal(true)}>
|
||||||
Add at Door
|
<EnvelopeIcon className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
Manual Ticket
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setShowAddAtDoorModal(true)}>
|
||||||
|
<PlusIcon className="w-4 h-4 mr-2" />
|
||||||
|
Add at Door
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Filter Results Summary */}
|
{/* Filter Results Summary */}
|
||||||
{(searchQuery || statusFilter !== 'all') && (
|
{(searchQuery || statusFilter !== 'all') && (
|
||||||
@@ -758,6 +820,150 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tickets Tab */}
|
||||||
|
{activeTab === 'tickets' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search & Filter Bar */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 flex-1 w-full sm:w-auto">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<MagnifyingGlassIcon className="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or ticket ID..."
|
||||||
|
value={ticketSearchQuery}
|
||||||
|
onChange={(e) => setTicketSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FunnelIcon className="w-5 h-5 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={ticketStatusFilter}
|
||||||
|
onChange={(e) => setTicketStatusFilter(e.target.value as any)}
|
||||||
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
>
|
||||||
|
<option value="all">All ({confirmedTickets.length})</option>
|
||||||
|
<option value="confirmed">Valid ({getTicketsByStatus('confirmed').length})</option>
|
||||||
|
<option value="checked_in">Checked In ({getTicketsByStatus('checked_in').length})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(ticketSearchQuery || ticketStatusFilter !== 'all') && (
|
||||||
|
<div className="mt-3 text-sm text-gray-500 flex items-center gap-2">
|
||||||
|
<span>Showing {filteredConfirmedTickets.length} of {confirmedTickets.length} tickets</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { setTicketSearchQuery(''); setTicketStatusFilter('all'); }}
|
||||||
|
className="text-primary-yellow hover:underline"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tickets Table */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-secondary-gray">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee Name</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Ticket ID</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booking ID</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Check-in Time</th>
|
||||||
|
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
|
{filteredConfirmedTickets.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredConfirmedTickets.map((ticket) => (
|
||||||
|
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<p className="font-medium">
|
||||||
|
{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<code className="text-sm bg-gray-100 px-2 py-1 rounded" title={ticket.id}>
|
||||||
|
{ticket.id.slice(0, 8)}...
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{ticket.bookingId ? (
|
||||||
|
<code className="text-sm bg-purple-50 text-purple-700 px-2 py-1 rounded" title={ticket.bookingId}>
|
||||||
|
{ticket.bookingId.slice(0, 8)}...
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{ticket.status === 'confirmed' ? (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">
|
||||||
|
Valid
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">
|
||||||
|
Checked In
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{ticket.checkinAt ? (
|
||||||
|
new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{ticket.status === 'confirmed' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCheckin(ticket.id)}
|
||||||
|
>
|
||||||
|
Check In
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{ticket.status === 'checked_in' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleRemoveCheckin(ticket.id)}
|
||||||
|
>
|
||||||
|
<ArrowUturnLeftIcon className="w-4 h-4 mr-1" />
|
||||||
|
Undo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add at Door Modal */}
|
{/* Add at Door Modal */}
|
||||||
{showAddAtDoorModal && (
|
{showAddAtDoorModal && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
@@ -855,6 +1061,111 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Manual Ticket Modal */}
|
||||||
|
{showManualTicketModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold">Create Manual Ticket</h2>
|
||||||
|
<p className="text-sm text-gray-500">Attendee will receive a confirmation email with their ticket</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowManualTicketModal(false)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleManualTicket} className="p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={manualTicketForm.firstName}
|
||||||
|
onChange={(e) => setManualTicketForm({ ...manualTicketForm, firstName: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="First name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Last Name (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manualTicketForm.lastName}
|
||||||
|
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Last name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={manualTicketForm.email}
|
||||||
|
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Booking confirmation and ticket will be sent to this email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Phone (optional)</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={manualTicketForm.phone}
|
||||||
|
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="+595 981 123456"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Admin Note (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={manualTicketForm.adminNote}
|
||||||
|
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Internal note about this attendee..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<EnvelopeIcon className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium">This will send:</p>
|
||||||
|
<ul className="list-disc ml-4 mt-1 space-y-0.5">
|
||||||
|
<li>Booking confirmation email</li>
|
||||||
|
<li>Ticket with QR code</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowManualTicketModal(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={submitting} className="flex-1">
|
||||||
|
<EnvelopeIcon className="w-4 h-4 mr-2" />
|
||||||
|
Create & Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Note Modal */}
|
{/* Note Modal */}
|
||||||
{showNoteModal && selectedTicket && (
|
{showNoteModal && selectedTicket && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import {
|
import {
|
||||||
ShareIcon,
|
ShareIcon,
|
||||||
@@ -18,6 +18,12 @@ interface ShareButtonsProps {
|
|||||||
export default function ShareButtons({ title, url, description }: ShareButtonsProps) {
|
export default function ShareButtons({ title, url, description }: ShareButtonsProps) {
|
||||||
const { locale } = useLanguage();
|
const { locale } = useLanguage();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [supportsNativeShare, setSupportsNativeShare] = useState(false);
|
||||||
|
|
||||||
|
// Check for native share support only after mount to avoid hydration mismatch
|
||||||
|
useEffect(() => {
|
||||||
|
setSupportsNativeShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function');
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Use provided URL or current page URL
|
// Use provided URL or current page URL
|
||||||
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||||
@@ -133,7 +139,7 @@ export default function ShareButtons({ title, url, description }: ShareButtonsPr
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Native Share (mobile) */}
|
{/* Native Share (mobile) */}
|
||||||
{typeof navigator !== 'undefined' && typeof navigator.share === 'function' && (
|
{supportsNativeShare && (
|
||||||
<button
|
<button
|
||||||
onClick={handleNativeShare}
|
onClick={handleNativeShare}
|
||||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-yellow text-primary-dark hover:bg-primary-yellow/90 transition-colors"
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-yellow text-primary-dark hover:bg-primary-yellow/90 transition-colors"
|
||||||
|
|||||||
@@ -145,6 +145,20 @@ export const ticketsApi = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
manualCreate: (data: {
|
||||||
|
eventId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName?: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
preferredLanguage?: 'en' | 'es';
|
||||||
|
adminNote?: string;
|
||||||
|
}) =>
|
||||||
|
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/manual', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
checkPaymentStatus: (ticketId: string) =>
|
checkPaymentStatus: (ticketId: string) =>
|
||||||
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
||||||
`/api/lnbits/status/${ticketId}`
|
`/api/lnbits/status/${ticketId}`
|
||||||
|
|||||||
Reference in New Issue
Block a user