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

@@ -0,0 +1,562 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Event, TicketValidationResult } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
QrCodeIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon,
MagnifyingGlassIcon,
VideoCameraIcon,
VideoCameraSlashIcon,
ArrowPathIcon,
ClockIcon,
UserIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
type ScanState = 'idle' | 'scanning' | 'success' | 'error' | 'already_checked_in' | 'pending';
interface ScanResult {
state: ScanState;
validation?: TicketValidationResult;
error?: string;
}
// Scanner component that manages its own DOM
function QRScanner({
onScan,
isActive,
onActiveChange
}: {
onScan: (code: string) => void;
isActive: boolean;
onActiveChange: (active: boolean) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const scannerRef = useRef<any>(null);
const scannerElementId = useRef(`qr-scanner-${Math.random().toString(36).substr(2, 9)}`);
// Create scanner element on mount
useEffect(() => {
if (containerRef.current && !document.getElementById(scannerElementId.current)) {
const scannerDiv = document.createElement('div');
scannerDiv.id = scannerElementId.current;
scannerDiv.style.width = '100%';
containerRef.current.appendChild(scannerDiv);
}
return () => {
// Cleanup scanner on unmount
if (scannerRef.current) {
try {
scannerRef.current.stop().catch(() => {});
} catch (e) {
// Ignore
}
scannerRef.current = null;
}
};
}, []);
// Handle scanner start/stop
useEffect(() => {
let cancelled = false;
const startScanner = async () => {
const elementId = scannerElementId.current;
const element = document.getElementById(elementId);
if (!element) return;
try {
const { Html5Qrcode } = await import('html5-qrcode');
if (cancelled) return;
// Stop existing scanner
if (scannerRef.current) {
try {
await scannerRef.current.stop();
} catch (e) {
// Ignore
}
scannerRef.current = null;
}
if (cancelled) return;
const scanner = new Html5Qrcode(elementId);
scannerRef.current = scanner;
await scanner.start(
{ facingMode: 'environment' },
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1,
},
(decodedText: string) => {
onScan(decodedText);
},
() => {
// QR parsing error - ignore
}
);
} catch (error: any) {
console.error('Scanner error:', error);
if (!cancelled) {
toast.error('Failed to start camera. Please check camera permissions.');
onActiveChange(false);
}
}
};
const stopScanner = async () => {
if (scannerRef.current) {
try {
await scannerRef.current.stop();
} catch (e) {
// Ignore
}
scannerRef.current = null;
}
};
if (isActive) {
startScanner();
} else {
stopScanner();
}
return () => {
cancelled = true;
};
}, [isActive, onScan, onActiveChange]);
return (
<div className="w-full bg-gray-900 rounded-lg overflow-hidden min-h-[300px]">
<div ref={containerRef} className="w-full" />
{!isActive && (
<div className="h-48 flex items-center justify-center text-gray-400 text-center p-8">
<div>
<VideoCameraIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Click &quot;Start Camera&quot; to begin scanning</p>
</div>
</div>
)}
</div>
);
}
// Scan Result Modal
function ScanResultModal({
scanResult,
onCheckin,
onClose,
checkingIn,
formatDateTime,
}: {
scanResult: ScanResult;
onCheckin: () => void;
onClose: () => void;
checkingIn: boolean;
formatDateTime: (dateStr: string) => string;
}) {
if (scanResult.state === 'idle') return null;
const isSuccess = scanResult.state === 'success';
const isAlreadyCheckedIn = scanResult.state === 'already_checked_in';
const isPending = scanResult.state === 'pending';
const isError = scanResult.state === 'error';
// Determine colors based on state
const bgColor = isSuccess ? 'bg-green-500' : isAlreadyCheckedIn ? 'bg-yellow-500' : isPending ? 'bg-orange-500' : 'bg-red-500';
const bgColorLight = isSuccess ? 'bg-green-50' : isAlreadyCheckedIn ? 'bg-yellow-50' : isPending ? 'bg-orange-50' : 'bg-red-50';
const borderColor = isSuccess ? 'border-green-500' : isAlreadyCheckedIn ? 'border-yellow-500' : isPending ? 'border-orange-500' : 'border-red-500';
const textColor = isSuccess ? 'text-green-800' : isAlreadyCheckedIn ? 'text-yellow-800' : isPending ? 'text-orange-800' : 'text-red-800';
const textColorLight = isSuccess ? 'text-green-600' : isAlreadyCheckedIn ? 'text-yellow-600' : isPending ? 'text-orange-600' : 'text-red-600';
const iconBg = isSuccess ? 'bg-green-100' : isAlreadyCheckedIn ? 'bg-yellow-100' : isPending ? 'bg-orange-100' : 'bg-red-100';
const iconColor = isSuccess ? 'text-green-600' : isAlreadyCheckedIn ? 'text-yellow-600' : isPending ? 'text-orange-600' : 'text-red-600';
const StatusIcon = isSuccess ? CheckCircleIcon : isAlreadyCheckedIn ? ExclamationTriangleIcon : isPending ? ClockIcon : XCircleIcon;
const statusTitle = isSuccess ? 'Valid Ticket' : isAlreadyCheckedIn ? 'Already Checked In' : isPending ? 'Payment Pending' : 'Invalid Ticket';
const statusSubtitle = isSuccess ? 'Ready for check-in' :
isAlreadyCheckedIn ? (
scanResult.validation?.ticket?.checkinAt
? `Checked in at ${new Date(scanResult.validation.ticket.checkinAt).toLocaleTimeString()}${scanResult.validation?.ticket?.checkedInBy ? ` by ${scanResult.validation.ticket.checkedInBy}` : ''}`
: 'This ticket was already used'
) :
isPending ? 'Ticket not yet confirmed' :
(scanResult.validation?.error || scanResult.error || 'Ticket not found or cancelled');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className={clsx(
'relative w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200',
bgColorLight,
'border-4',
borderColor
)}>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 p-1 rounded-full bg-white/80 hover:bg-white transition-colors z-10"
>
<XMarkIcon className="w-5 h-5 text-gray-600" />
</button>
{/* Header with color band */}
<div className={clsx('h-2', bgColor)} />
<div className="p-6">
{/* Status Icon & Title */}
<div className="flex items-center gap-4 mb-5">
<div className={clsx('w-16 h-16 rounded-full flex items-center justify-center', iconBg)}>
<StatusIcon className={clsx('w-10 h-10', iconColor)} />
</div>
<div className="flex-1">
<h3 className={clsx('font-bold text-xl', textColor)}>{statusTitle}</h3>
<p className={clsx('text-sm', textColorLight)}>{statusSubtitle}</p>
</div>
</div>
{/* Ticket Details */}
{scanResult.validation?.ticket && (
<div className="bg-white rounded-xl p-4 mb-5 shadow-sm">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center">
<UserIcon className="w-7 h-7 text-gray-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-lg text-gray-900 truncate">
{scanResult.validation.ticket.attendeeName}
</p>
{scanResult.validation.ticket.attendeeEmail && (
<p className="text-sm text-gray-500 truncate">
{scanResult.validation.ticket.attendeeEmail}
</p>
)}
</div>
</div>
{scanResult.validation.event && (
<div className="text-sm text-gray-600 border-t pt-3 mt-3 space-y-1">
<p className="font-semibold text-gray-800">{scanResult.validation.event.title}</p>
<p>{formatDateTime(scanResult.validation.event.startDatetime)}</p>
<p className="text-gray-500">{scanResult.validation.event.location}</p>
</div>
)}
<p className="text-xs text-gray-400 mt-3 font-mono">
Ticket ID: {scanResult.validation.ticket.id.slice(0, 8)}...
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
{isSuccess && scanResult.validation?.canCheckIn && (
<Button
className="flex-1 py-4 text-lg"
onClick={onCheckin}
isLoading={checkingIn}
>
<CheckCircleIcon className="w-6 h-6 mr-2" />
Check In
</Button>
)}
<Button
variant="outline"
onClick={onClose}
className={clsx(
'py-4 text-lg',
isSuccess && scanResult.validation?.canCheckIn ? '' : 'flex-1'
)}
>
<ArrowPathIcon className="w-6 h-6 mr-2" />
Scan Next
</Button>
</div>
</div>
</div>
</div>
);
}
export default function AdminScannerPage() {
const { locale } = useLanguage();
const [events, setEvents] = useState<Event[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>('');
const [loading, setLoading] = useState(true);
// Scanner state
const [cameraActive, setCameraActive] = useState(false);
const [scanResult, setScanResult] = useState<ScanResult>({ state: 'idle' });
const [lastScannedCode, setLastScannedCode] = useState<string>('');
const [checkingIn, setCheckingIn] = useState(false);
// Manual search
const [searchQuery, setSearchQuery] = useState('');
const [searching, setSearching] = useState(false);
// Stats
const [checkinCount, setCheckinCount] = useState(0);
const [recentCheckins, setRecentCheckins] = useState<Array<{ name: string; time: string }>>([]);
// Refs for callbacks
const selectedEventIdRef = useRef<string>('');
const lastScannedCodeRef = useRef<string>('');
// Keep refs in sync
useEffect(() => {
selectedEventIdRef.current = selectedEventId;
}, [selectedEventId]);
useEffect(() => {
lastScannedCodeRef.current = lastScannedCode;
}, [lastScannedCode]);
// Load events
useEffect(() => {
eventsApi.getAll({ status: 'published' })
.then(res => {
setEvents(res.events);
const upcoming = res.events.filter(e => new Date(e.startDatetime) >= new Date());
if (upcoming.length === 1) {
setSelectedEventId(upcoming[0].id);
}
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
// Validate ticket
const validateTicket = useCallback(async (code: string) => {
try {
const result = await ticketsApi.validate(code, selectedEventIdRef.current || undefined);
let state: ScanState = 'idle';
if (result.status === 'valid') {
state = 'success';
} else if (result.status === 'already_checked_in') {
state = 'already_checked_in';
} else if (result.status === 'pending_payment') {
state = 'pending';
} else {
state = 'error';
}
setScanResult({ state, validation: result });
} catch (error: any) {
setScanResult({
state: 'error',
error: error.message || 'Failed to validate ticket'
});
}
}, []);
// Handle QR scan
const handleScan = useCallback((decodedText: string) => {
// Avoid duplicate scans
if (decodedText === lastScannedCodeRef.current) return;
lastScannedCodeRef.current = decodedText;
setLastScannedCode(decodedText);
// Extract ticket ID from URL if present
let code = decodedText;
const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/);
if (urlMatch) {
code = urlMatch[1];
}
validateTicket(code);
}, [validateTicket]);
// Check in ticket
const handleCheckin = async () => {
if (!scanResult.validation?.ticket?.id) return;
setCheckingIn(true);
try {
const result = await ticketsApi.checkin(scanResult.validation.ticket.id);
toast.success(`${result.ticket.attendeeName || 'Guest'} checked in!`);
setCheckinCount(prev => prev + 1);
setRecentCheckins(prev => [
{
name: result.ticket.attendeeName || 'Guest',
time: new Date().toLocaleTimeString()
},
...prev.slice(0, 4),
]);
handleCloseModal();
} catch (error: any) {
toast.error(error.message || 'Check-in failed');
} finally {
setCheckingIn(false);
}
};
// Close modal and reset
const handleCloseModal = () => {
setScanResult({ state: 'idle' });
setLastScannedCode('');
lastScannedCodeRef.current = '';
};
// Manual search
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
setSearching(true);
setScanResult({ state: 'idle' });
await validateTicket(searchQuery.trim());
setSearching(false);
setSearchQuery('');
};
// Format datetime
const formatDateTime = (dateStr: string) => {
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'short',
month: 'short',
day: '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 className="max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-primary-dark flex items-center gap-2">
<QrCodeIcon className="w-7 h-7" />
Ticket Scanner
</h1>
<p className="text-gray-600 mt-1">Scan QR codes to check in attendees</p>
</div>
{/* Event Selector */}
<Card className="p-4 mb-6">
<label className="block text-sm font-medium mb-2">Select Event (optional)</label>
<select
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{event.title} - {formatDateTime(event.startDatetime)}
</option>
))}
</select>
</Card>
{/* Scanner Area */}
<Card className="p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">Camera Scanner</h2>
<Button
variant={cameraActive ? 'outline' : 'primary'}
size="sm"
onClick={() => setCameraActive(!cameraActive)}
>
{cameraActive ? (
<>
<VideoCameraSlashIcon className="w-4 h-4 mr-2" />
Stop Camera
</>
) : (
<>
<VideoCameraIcon className="w-4 h-4 mr-2" />
Start Camera
</>
)}
</Button>
</div>
<QRScanner
isActive={cameraActive}
onScan={handleScan}
onActiveChange={setCameraActive}
/>
</Card>
{/* Manual Search */}
<Card className="p-4 mb-6">
<h2 className="font-semibold mb-3">Manual Search</h2>
<form onSubmit={handleSearch} className="flex gap-2">
<Input
placeholder="Enter ticket ID or QR code..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
<Button type="submit" isLoading={searching}>
<MagnifyingGlassIcon className="w-5 h-5" />
</Button>
</form>
</Card>
{/* Stats */}
<div className="grid grid-cols-2 gap-4">
<Card className="p-4 text-center">
<p className="text-3xl font-bold text-primary-dark">{checkinCount}</p>
<p className="text-sm text-gray-600">Checked in this session</p>
</Card>
<Card className="p-4">
<p className="text-sm font-medium text-gray-600 mb-2">Recent Check-ins</p>
{recentCheckins.length === 0 ? (
<p className="text-xs text-gray-400">No check-ins yet</p>
) : (
<ul className="space-y-1">
{recentCheckins.map((checkin, i) => (
<li key={i} className="text-xs flex justify-between">
<span className="truncate">{checkin.name}</span>
<span className="text-gray-400">{checkin.time}</span>
</li>
))}
</ul>
)}
</Card>
</div>
{/* Scan Result Modal */}
<ScanResultModal
scanResult={scanResult}
onCheckin={handleCheckin}
onClose={handleCloseModal}
checkingIn={checkingIn}
formatDateTime={formatDateTime}
/>
</div>
);
}