@@ -1097,6 +1097,154 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
}, 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)
|
||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TabType = 'overview' | 'attendees' | 'email' | 'payments';
|
||||
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
||||
|
||||
export default function AdminEventDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -62,6 +62,7 @@ export default function AdminEventDetailPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
|
||||
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
||||
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
@@ -73,8 +74,19 @@ export default function AdminEventDetailPage() {
|
||||
autoCheckin: true,
|
||||
adminNote: '',
|
||||
});
|
||||
const [manualTicketForm, setManualTicketForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
adminNote: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Tickets tab state
|
||||
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
|
||||
const [ticketStatusFilter, setTicketStatusFilter] = useState<'all' | 'confirmed' | 'checked_in'>('all');
|
||||
|
||||
// Payment options state
|
||||
const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null);
|
||||
const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({});
|
||||
@@ -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
|
||||
const filteredTickets = tickets.filter((ticket) => {
|
||||
// Status filter
|
||||
@@ -321,6 +357,25 @@ export default function AdminEventDetailPage() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Filtered tickets for the Tickets tab (only confirmed/checked_in)
|
||||
const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status));
|
||||
const filteredConfirmedTickets = confirmedTickets.filter((ticket) => {
|
||||
// Status filter
|
||||
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) {
|
||||
return false;
|
||||
}
|
||||
// Search filter
|
||||
if (ticketSearchQuery) {
|
||||
const query = ticketSearchQuery.toLowerCase();
|
||||
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
|
||||
return (
|
||||
fullName.includes(query) ||
|
||||
ticket.id.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const handlePreviewEmail = async () => {
|
||||
if (!selectedTemplate) {
|
||||
toast.error('Please select a template');
|
||||
@@ -503,7 +558,7 @@ export default function AdminEventDetailPage() {
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-secondary-light-gray mb-6">
|
||||
<nav className="flex gap-6">
|
||||
{(['overview', 'attendees', 'email', 'payments'] as TabType[]).map((tab) => (
|
||||
{(['overview', 'attendees', 'tickets', 'email', 'payments'] as TabType[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -517,9 +572,10 @@ export default function AdminEventDetailPage() {
|
||||
>
|
||||
{tab === 'overview' && <CalendarIcon className="w-4 h-4" />}
|
||||
{tab === 'attendees' && <UserGroupIcon className="w-4 h-4" />}
|
||||
{tab === 'tickets' && <TicketIcon className="w-4 h-4" />}
|
||||
{tab === 'email' && <EnvelopeIcon className="w-4 h-4" />}
|
||||
{tab === 'payments' && <CreditCardIcon className="w-4 h-4" />}
|
||||
{tab === 'overview' ? 'Overview' : tab === 'attendees' ? `Attendees (${tickets.length})` : tab === '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>
|
||||
))}
|
||||
</nav>
|
||||
@@ -629,12 +685,18 @@ export default function AdminEventDetailPage() {
|
||||
</select>
|
||||
</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)}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add at Door
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Filter Results Summary */}
|
||||
{(searchQuery || statusFilter !== 'all') && (
|
||||
<div className="mt-3 text-sm text-gray-500 flex items-center gap-2">
|
||||
@@ -758,6 +820,150 @@ export default function AdminEventDetailPage() {
|
||||
</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 */}
|
||||
{showAddAtDoorModal && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Manual Ticket Modal */}
|
||||
{showManualTicketModal && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Create Manual Ticket</h2>
|
||||
<p className="text-sm text-gray-500">Attendee will receive a confirmation email with their ticket</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowManualTicketModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleManualTicket} className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={manualTicketForm.firstName}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, firstName: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="First name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Last Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={manualTicketForm.lastName}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={manualTicketForm.email}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Booking confirmation and ticket will be sent to this email
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Phone (optional)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={manualTicketForm.phone}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="+595 981 123456"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Admin Note (optional)</label>
|
||||
<textarea
|
||||
value={manualTicketForm.adminNote}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2}
|
||||
placeholder="Internal note about this attendee..."
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<EnvelopeIcon className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium">This will send:</p>
|
||||
<ul className="list-disc ml-4 mt-1 space-y-0.5">
|
||||
<li>Booking confirmation email</li>
|
||||
<li>Ticket with QR code</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowManualTicketModal(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1">
|
||||
<EnvelopeIcon className="w-4 h-4 mr-2" />
|
||||
Create & Send
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note Modal */}
|
||||
{showNoteModal && selectedTicket && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import {
|
||||
ShareIcon,
|
||||
@@ -18,6 +18,12 @@ interface ShareButtonsProps {
|
||||
export default function ShareButtons({ title, url, description }: ShareButtonsProps) {
|
||||
const { locale } = useLanguage();
|
||||
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
|
||||
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||
@@ -133,7 +139,7 @@ export default function ShareButtons({ title, url, description }: ShareButtonsPr
|
||||
</button>
|
||||
|
||||
{/* Native Share (mobile) */}
|
||||
{typeof navigator !== 'undefined' && typeof navigator.share === 'function' && (
|
||||
{supportsNativeShare && (
|
||||
<button
|
||||
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"
|
||||
|
||||
@@ -145,6 +145,20 @@ export const ticketsApi = {
|
||||
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) =>
|
||||
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
||||
`/api/lnbits/status/${ticketId}`
|
||||
|
||||
Reference in New Issue
Block a user