3 Commits

Author SHA1 Message Date
8564f8af83 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-02-12 02:18:08 +00:00
Michilis
8315029091 fix: resolve ShareButtons hydration error by deferring native share check to client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 02:17:01 +00:00
Michilis
2b2f2cc4ed Admin event: Manual ticket + Tickets tab
- Backend: POST /api/tickets/admin/manual - creates ticket and sends confirmation + ticket email
- Frontend: Manual Ticket button and modal (email required, sends confirmation + ticket)
- New Tickets tab between Attendees and Send Email: confirmed tickets table with search (name/ticket ID), status filter, check-in actions

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 02:44:26 +00:00
4 changed files with 489 additions and 10 deletions

View File

@@ -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');

View File

@@ -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,12 +685,18 @@ export default function AdminEventDetailPage() {
</select> </select>
</div> </div>
</div> </div>
{/* Add at Door Button */} {/* Action Buttons */}
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setShowManualTicketModal(true)}>
<EnvelopeIcon className="w-4 h-4 mr-2" />
Manual Ticket
</Button>
<Button onClick={() => setShowAddAtDoorModal(true)}> <Button onClick={() => setShowAddAtDoorModal(true)}>
<PlusIcon className="w-4 h-4 mr-2" /> <PlusIcon className="w-4 h-4 mr-2" />
Add at Door Add at Door
</Button> </Button>
</div> </div>
</div>
{/* Filter Results Summary */} {/* Filter Results Summary */}
{(searchQuery || statusFilter !== 'all') && ( {(searchQuery || statusFilter !== 'all') && (
<div className="mt-3 text-sm text-gray-500 flex items-center gap-2"> <div className="mt-3 text-sm text-gray-500 flex items-center gap-2">
@@ -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">

View File

@@ -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"

View File

@@ -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}`