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:
@@ -913,10 +913,12 @@ export default function BookingPage() {
|
||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{event.availableSeats} {t('events.details.spotsLeft')}</span>
|
||||
</div>
|
||||
{!event.externalBookingEnabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{event.availableSeats} {t('events.details.spotsLeft')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span className="font-bold text-lg">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
XCircleIcon,
|
||||
TicketIcon,
|
||||
ArrowPathIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export default function BookingSuccessPage() {
|
||||
@@ -224,6 +225,20 @@ export default function BookingSuccessPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Download Ticket Button */}
|
||||
{isPaid && (
|
||||
<div className="mb-6">
|
||||
<a
|
||||
href={`/api/tickets/${ticketId}/pdf`}
|
||||
download
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-5 h-5" />
|
||||
{locale === 'es' ? 'Descargar Ticket' : 'Download Ticket'}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link href="/events">
|
||||
|
||||
@@ -41,14 +41,14 @@ export default function CommunityPage() {
|
||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
|
||||
{/* WhatsApp Card */}
|
||||
{whatsappUrl && (
|
||||
<Card className="p-8 text-center card-hover">
|
||||
<Card className="p-8 text-center card-hover h-full flex flex-col">
|
||||
<div className="w-20 h-20 mx-auto bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-10 h-10 text-green-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-semibold">{t('community.whatsapp.title')}</h3>
|
||||
<p className="mt-3 text-gray-600">{t('community.whatsapp.description')}</p>
|
||||
<p className="mt-3 text-gray-600 flex-grow">{t('community.whatsapp.description')}</p>
|
||||
<a
|
||||
href={whatsappUrl}
|
||||
target="_blank"
|
||||
@@ -64,14 +64,14 @@ export default function CommunityPage() {
|
||||
|
||||
{/* Telegram Card */}
|
||||
{telegramUrl && (
|
||||
<Card className="p-8 text-center card-hover">
|
||||
<Card className="p-8 text-center card-hover h-full flex flex-col">
|
||||
<div className="w-20 h-20 mx-auto bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-10 h-10 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-semibold">{t('community.telegram.title')}</h3>
|
||||
<p className="mt-3 text-gray-600">{t('community.telegram.description')}</p>
|
||||
<p className="mt-3 text-gray-600 flex-grow">{t('community.telegram.description')}</p>
|
||||
<a
|
||||
href={telegramUrl}
|
||||
target="_blank"
|
||||
@@ -87,19 +87,19 @@ export default function CommunityPage() {
|
||||
|
||||
{/* Instagram Card */}
|
||||
{instagramUrl && (
|
||||
<Card className="p-8 text-center card-hover">
|
||||
<Card className="p-8 text-center card-hover h-full flex flex-col">
|
||||
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
|
||||
<CameraIcon className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-semibold">{t('community.instagram.title')}</h3>
|
||||
<p className="mt-3 text-gray-600">{t('community.instagram.description')}</p>
|
||||
<p className="mt-3 text-gray-600 flex-grow">{t('community.instagram.description')}</p>
|
||||
<a
|
||||
href={instagramUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-6"
|
||||
>
|
||||
<Button variant="secondary">
|
||||
<Button>
|
||||
{t('community.instagram.button')}
|
||||
</Button>
|
||||
</a>
|
||||
@@ -108,14 +108,14 @@ export default function CommunityPage() {
|
||||
|
||||
{/* TikTok Card */}
|
||||
{tiktokUrl && (
|
||||
<Card className="p-8 text-center card-hover">
|
||||
<Card className="p-8 text-center card-hover h-full flex flex-col">
|
||||
<div className="w-20 h-20 mx-auto bg-black rounded-full flex items-center justify-center">
|
||||
<svg className="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-semibold">{t('community.tiktok.title')}</h3>
|
||||
<p className="mt-3 text-gray-600">{t('community.tiktok.description')}</p>
|
||||
<p className="mt-3 text-gray-600 flex-grow">{t('community.tiktok.description')}</p>
|
||||
<a
|
||||
href={tiktokUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -13,10 +13,10 @@ export default function HeroSection() {
|
||||
<div className="container-page py-16 md:py-24">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-primary-dark leading-tight text-balance">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold leading-tight text-balance" style={{ color: '#002F44' }}>
|
||||
{t('home.hero.title')}
|
||||
</h1>
|
||||
<p className="mt-6 text-xl text-gray-600">
|
||||
<p className="mt-6 text-xl" style={{ color: '#002F44' }}>
|
||||
{t('home.hero.subtitle')}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function NewsletterSection() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<section className="section-padding bg-primary-dark text-white">
|
||||
<section className="section-padding text-white" style={{ backgroundColor: '#002F44' }}>
|
||||
<div className="container-page">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<SparklesIcon className="w-12 h-12 mx-auto text-primary-yellow" />
|
||||
|
||||
@@ -62,10 +62,10 @@ export default function NextEventSection() {
|
||||
<h3 className="text-2xl font-bold text-primary-dark">
|
||||
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-gray-600">
|
||||
{locale === 'es' && nextEvent.descriptionEs
|
||||
? nextEvent.descriptionEs
|
||||
: nextEvent.description}
|
||||
<p className="mt-3 text-gray-600 whitespace-pre-line">
|
||||
{locale === 'es'
|
||||
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
|
||||
: (nextEvent.shortDescription || nextEvent.description)}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
@@ -93,9 +93,11 @@ export default function NextEventSection() {
|
||||
? t('events.details.free')
|
||||
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
{!nextEvent.externalBookingEnabled && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button size="lg" className="mt-6">
|
||||
{t('common.moreInfo')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user