Add ticket system with QR scanner and PDF generation

- Add ticket validation and check-in API endpoints
- Add PDF ticket generation with QR codes (pdfkit)
- Add admin QR scanner page with camera support
- Add admin site settings page
- Update email templates with PDF ticket download link
- Add checked_in_by_admin_id field for audit tracking
- Update booking success page with ticket download
- Various UI improvements to events and booking pages
This commit is contained in:
Michilis
2026-02-02 00:45:12 +00:00
parent b0cbaa60f0
commit 9410e83b89
28 changed files with 1930 additions and 85 deletions

View File

@@ -120,7 +120,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl"></span>
<div>
<p className="font-medium">{t('events.details.time')}</p>
<p className="text-gray-600" suppressHydrationWarning>{formatTime(event.startDatetime)}</p>
<p className="text-gray-600" suppressHydrationWarning>
{formatTime(event.startDatetime)}
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
</p>
</div>
</div>
@@ -142,15 +145,17 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
</div>
</div>
<div className="flex items-start gap-3">
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</p>
{!event.externalBookingEnabled && (
<div className="flex items-start gap-3">
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</p>
</div>
</div>
</div>
)}
</div>
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
@@ -213,9 +218,11 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
</Button>
)}
<p className="mt-4 text-center text-sm text-gray-500">
{event.availableSeats} {t('events.details.spotsLeft')}
</p>
{!event.externalBookingEnabled && (
<p className="mt-4 text-center text-sm text-gray-500">
{event.availableSeats} {t('events.details.spotsLeft')}
</p>
)}
</Card>
</div>
</div>

View File

@@ -11,6 +11,8 @@ interface Event {
titleEs?: string;
description: string;
descriptionEs?: string;
shortDescription?: string;
shortDescriptionEs?: string;
startDatetime: string;
endDatetime?: string;
location: string;
@@ -47,10 +49,12 @@ export async function generateMetadata({ params }: { params: { id: string } }):
}
const title = event.title;
// Use the beginning of the event description, truncated to ~155 chars for SEO
const description = event.description.length > 155
? event.description.slice(0, 152).trim() + '...'
: event.description;
// Use short description if available, otherwise fall back to truncated full description
const description = event.shortDescription
? event.shortDescription
: (event.description.length > 155
? event.description.slice(0, 152).trim() + '...'
: event.description);
// Convert relative banner URL to absolute URL for SEO
const imageUrl = event.bannerUrl

View File

@@ -135,12 +135,14 @@ export default function EventsPage() {
<MapPinIcon className="w-4 h-4" />
<span className="truncate">{event.location}</span>
</div>
<div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" />
<span>
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</span>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" />
<span>
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</span>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">