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:
@@ -25,6 +25,8 @@ export default function AdminEventsPage() {
|
||||
titleEs: string;
|
||||
description: string;
|
||||
descriptionEs: string;
|
||||
shortDescription: string;
|
||||
shortDescriptionEs: string;
|
||||
startDatetime: string;
|
||||
endDatetime: string;
|
||||
location: string;
|
||||
@@ -41,6 +43,8 @@ export default function AdminEventsPage() {
|
||||
titleEs: '',
|
||||
description: '',
|
||||
descriptionEs: '',
|
||||
shortDescription: '',
|
||||
shortDescriptionEs: '',
|
||||
startDatetime: '',
|
||||
endDatetime: '',
|
||||
location: '',
|
||||
@@ -75,6 +79,8 @@ export default function AdminEventsPage() {
|
||||
titleEs: '',
|
||||
description: '',
|
||||
descriptionEs: '',
|
||||
shortDescription: '',
|
||||
shortDescriptionEs: '',
|
||||
startDatetime: '',
|
||||
endDatetime: '',
|
||||
location: '',
|
||||
@@ -90,14 +96,27 @@ export default function AdminEventsPage() {
|
||||
setEditingEvent(null);
|
||||
};
|
||||
|
||||
// Convert ISO UTC string to local datetime-local format (YYYY-MM-DDTHH:MM)
|
||||
const isoToLocalDatetime = (isoString: string): string => {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const handleEdit = (event: Event) => {
|
||||
setFormData({
|
||||
title: event.title,
|
||||
titleEs: event.titleEs || '',
|
||||
description: event.description,
|
||||
descriptionEs: event.descriptionEs || '',
|
||||
startDatetime: event.startDatetime.slice(0, 16),
|
||||
endDatetime: event.endDatetime?.slice(0, 16) || '',
|
||||
shortDescription: event.shortDescription || '',
|
||||
shortDescriptionEs: event.shortDescriptionEs || '',
|
||||
startDatetime: isoToLocalDatetime(event.startDatetime),
|
||||
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||
location: event.location,
|
||||
locationUrl: event.locationUrl || '',
|
||||
price: event.price,
|
||||
@@ -134,6 +153,8 @@ export default function AdminEventsPage() {
|
||||
titleEs: formData.titleEs || undefined,
|
||||
description: formData.description,
|
||||
descriptionEs: formData.descriptionEs || undefined,
|
||||
shortDescription: formData.shortDescription || undefined,
|
||||
shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||
startDatetime: new Date(formData.startDatetime).toISOString(),
|
||||
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
||||
location: formData.location,
|
||||
@@ -288,6 +309,33 @@ export default function AdminEventsPage() {
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Short Description (English)</label>
|
||||
<textarea
|
||||
value={formData.shortDescription}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2}
|
||||
maxLength={300}
|
||||
placeholder="Brief summary for SEO and cards (max 300 chars)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300 characters</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
|
||||
<textarea
|
||||
value={formData.shortDescriptionEs}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2}
|
||||
maxLength={300}
|
||||
placeholder="Resumen breve para SEO y tarjetas (máx 300 caracteres)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300 characters</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
BanknotesIcon,
|
||||
QrCodeIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
@@ -59,12 +60,14 @@ export default function AdminLayout({
|
||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
|
||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
|
||||
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon },
|
||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
|
||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
|
||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
|
||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
|
||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
|
||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
|
||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
|
||||
562
frontend/src/app/admin/scanner/page.tsx
Normal file
562
frontend/src/app/admin/scanner/page.tsx
Normal 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 "Start Camera" 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>
|
||||
);
|
||||
}
|
||||
376
frontend/src/app/admin/settings/page.tsx
Normal file
376
frontend/src/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { siteSettingsApi, SiteSettings, TimezoneOption } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
GlobeAltIcon,
|
||||
ClockIcon,
|
||||
EnvelopeIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
CheckCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [timezones, setTimezones] = useState<TimezoneOption[]>([]);
|
||||
|
||||
const [settings, setSettings] = useState<SiteSettings>({
|
||||
timezone: 'America/Asuncion',
|
||||
siteName: 'Spanglish',
|
||||
siteDescription: null,
|
||||
siteDescriptionEs: null,
|
||||
contactEmail: null,
|
||||
contactPhone: null,
|
||||
facebookUrl: null,
|
||||
instagramUrl: null,
|
||||
twitterUrl: null,
|
||||
linkedinUrl: null,
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: null,
|
||||
maintenanceMessageEs: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [settingsRes, timezonesRes] = await Promise.all([
|
||||
siteSettingsApi.get(),
|
||||
siteSettingsApi.getTimezones(),
|
||||
]);
|
||||
setSettings(settingsRes.settings);
|
||||
setTimezones(timezonesRes.timezones);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await siteSettingsApi.update(settings);
|
||||
setSettings(response.settings);
|
||||
toast.success(locale === 'es' ? 'Configuración guardada' : 'Settings saved');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = <K extends keyof SiteSettings>(key: K, value: SiteSettings[K]) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-dark flex items-center gap-3">
|
||||
<Cog6ToothIcon className="w-7 h-7" />
|
||||
{locale === 'es' ? 'Configuración del Sitio' : 'Site Settings'}
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
{locale === 'es'
|
||||
? 'Configura las opciones generales del sitio web'
|
||||
: 'Configure general website settings'}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleSave} isLoading={saving}>
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Timezone Settings */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<ClockIcon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Zona Horaria' : 'Timezone'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Zona horaria para mostrar las fechas de eventos'
|
||||
: 'Timezone used for displaying event dates'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Zona Horaria del Sitio' : 'Site Timezone'}
|
||||
</label>
|
||||
<select
|
||||
value={settings.timezone}
|
||||
onChange={(e) => updateSetting('timezone', e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
{timezones.map((tz) => (
|
||||
<option key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{locale === 'es'
|
||||
? 'Esta zona horaria se usará como referencia para las fechas de eventos.'
|
||||
: 'This timezone will be used as reference for event dates.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Site Information */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<GlobeAltIcon className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Información del Sitio' : 'Site Information'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Información básica del sitio web'
|
||||
: 'Basic website information'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="max-w-md">
|
||||
<Input
|
||||
label={locale === 'es' ? 'Nombre del Sitio' : 'Site Name'}
|
||||
value={settings.siteName}
|
||||
onChange={(e) => updateSetting('siteName', e.target.value)}
|
||||
placeholder="Spanglish"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Descripción (Inglés)' : 'Description (English)'}
|
||||
</label>
|
||||
<textarea
|
||||
value={settings.siteDescription || ''}
|
||||
onChange={(e) => updateSetting('siteDescription', e.target.value || null)}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Brief site description..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Descripción (Español)' : 'Description (Spanish)'}
|
||||
</label>
|
||||
<textarea
|
||||
value={settings.siteDescriptionEs || ''}
|
||||
onChange={(e) => updateSetting('siteDescriptionEs', e.target.value || null)}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Descripción breve del sitio..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Contact Information */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<EnvelopeIcon className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Información de Contacto' : 'Contact Information'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Datos de contacto del sitio'
|
||||
: 'Site contact details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={locale === 'es' ? 'Email de Contacto' : 'Contact Email'}
|
||||
type="email"
|
||||
value={settings.contactEmail || ''}
|
||||
onChange={(e) => updateSetting('contactEmail', e.target.value || null)}
|
||||
placeholder="contact@example.com"
|
||||
/>
|
||||
<Input
|
||||
label={locale === 'es' ? 'Teléfono de Contacto' : 'Contact Phone'}
|
||||
type="tel"
|
||||
value={settings.contactPhone || ''}
|
||||
onChange={(e) => updateSetting('contactPhone', e.target.value || null)}
|
||||
placeholder="+595 981 123456"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-secondary-light-gray">
|
||||
<h4 className="font-medium mb-4">
|
||||
{locale === 'es' ? 'Redes Sociales' : 'Social Media Links'}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Facebook URL"
|
||||
type="url"
|
||||
value={settings.facebookUrl || ''}
|
||||
onChange={(e) => updateSetting('facebookUrl', e.target.value || null)}
|
||||
placeholder="https://facebook.com/..."
|
||||
/>
|
||||
<Input
|
||||
label="Instagram URL"
|
||||
type="url"
|
||||
value={settings.instagramUrl || ''}
|
||||
onChange={(e) => updateSetting('instagramUrl', e.target.value || null)}
|
||||
placeholder="https://instagram.com/..."
|
||||
/>
|
||||
<Input
|
||||
label="Twitter URL"
|
||||
type="url"
|
||||
value={settings.twitterUrl || ''}
|
||||
onChange={(e) => updateSetting('twitterUrl', e.target.value || null)}
|
||||
placeholder="https://twitter.com/..."
|
||||
/>
|
||||
<Input
|
||||
label="LinkedIn URL"
|
||||
type="url"
|
||||
value={settings.linkedinUrl || ''}
|
||||
onChange={(e) => updateSetting('linkedinUrl', e.target.value || null)}
|
||||
placeholder="https://linkedin.com/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Maintenance Mode */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<WrenchScrewdriverIcon className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Modo de Mantenimiento' : 'Maintenance Mode'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Habilita el modo de mantenimiento cuando necesites hacer cambios'
|
||||
: 'Enable maintenance mode when you need to make changes'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg mb-4">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{locale === 'es' ? 'Modo de Mantenimiento' : 'Maintenance Mode'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{settings.maintenanceMode
|
||||
? (locale === 'es' ? 'El sitio está en mantenimiento' : 'The site is under maintenance')
|
||||
: (locale === 'es' ? 'El sitio está activo' : 'The site is live')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateSetting('maintenanceMode', !settings.maintenanceMode)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
settings.maintenanceMode ? 'bg-orange-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
settings.maintenanceMode ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings.maintenanceMode && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-orange-800 text-sm font-medium flex items-center gap-2">
|
||||
<WrenchScrewdriverIcon className="w-4 h-4" />
|
||||
{locale === 'es'
|
||||
? '¡Advertencia! El modo de mantenimiento está activo.'
|
||||
: 'Warning! Maintenance mode is active.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Mensaje de Mantenimiento (Inglés)' : 'Maintenance Message (English)'}
|
||||
</label>
|
||||
<textarea
|
||||
value={settings.maintenanceMessage || ''}
|
||||
onChange={(e) => updateSetting('maintenanceMessage', e.target.value || null)}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="We are currently performing maintenance. Please check back soon."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Mensaje de Mantenimiento (Español)' : 'Maintenance Message (Spanish)'}
|
||||
</label>
|
||||
<textarea
|
||||
value={settings.maintenanceMessageEs || ''}
|
||||
onChange={(e) => updateSetting('maintenanceMessageEs', e.target.value || null)}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Estamos realizando mantenimiento. Por favor vuelve pronto."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save Button at Bottom */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} isLoading={saving} size="lg">
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Guardar Todos los Cambios' : 'Save All Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user