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

@@ -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">

View File

@@ -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">

View File

@@ -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"

View File

@@ -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">

View File

@@ -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" />

View File

@@ -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')}

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">

View File

@@ -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

View File

@@ -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 = () => {

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>
);
}

View 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>
);
}

View File

@@ -57,7 +57,7 @@ export default function LinktreePage() {
: null;
return (
<div className="min-h-screen bg-gradient-to-b from-primary-dark via-gray-900 to-primary-dark">
<div className="min-h-screen" style={{ background: 'linear-gradient(to bottom, #002F44, #001a28, #002F44)' }}>
<div className="max-w-md mx-auto px-4 py-8 pb-16">
{/* Profile Header */}
<div className="text-center mb-8">
@@ -102,9 +102,11 @@ export default function LinktreePage() {
? t('events.details.free')
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
</span>
<span className="text-sm text-gray-400">
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
</span>
{!nextEvent.externalBookingEnabled && (
<span className="text-sm text-gray-400">
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
</span>
)}
</div>
<div className="mt-4 bg-primary-yellow text-primary-dark font-semibold py-3 px-4 rounded-xl text-center transition-all duration-200 group-hover:bg-yellow-400">

View File

@@ -31,13 +31,9 @@ export default function Footer() {
className="h-10 w-auto"
/>
</Link>
<p className="mt-3 text-gray-600 max-w-md">
<p className="mt-3 max-w-md" style={{ color: '#002F44' }}>
{t('footer.tagline')}
</p>
{/* Local SEO text */}
<p className="mt-2 text-sm text-gray-500">
Language Exchange Events in Asunción, Paraguay
</p>
</div>
{/* Quick Links */}
@@ -84,7 +80,7 @@ export default function Footer() {
{/* Social */}
{socialLinks.length > 0 && (
<div>
<h3 className="font-semibold text-primary-dark mb-4">
<h3 className="font-semibold mb-4" style={{ color: '#002F44' }}>
{t('footer.social')}
</h3>
<div className="flex flex-wrap gap-3">
@@ -112,13 +108,14 @@ export default function Footer() {
<Link
key={link.slug}
href={`/legal/${link.slug}`}
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
className="hover:opacity-70 transition-colors text-sm"
style={{ color: '#002F44' }}
>
{locale === 'es' ? link.es : link.en}
</Link>
))}
</div>
<div className="text-center text-gray-500 text-sm">
<div className="text-center text-sm" style={{ color: '#002F44' }}>
{t('footer.copyright', { year: currentYear })}
</div>
</div>

View File

@@ -3,6 +3,7 @@
import Link from 'next/link';
import Image from 'next/image';
import { useState } from 'react';
import { usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import LanguageToggle from '@/components/LanguageToggle';
@@ -10,6 +11,37 @@ import Button from '@/components/ui/Button';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname();
const isActive = pathname === href || (href !== '/' && pathname.startsWith(href));
return (
<Link
href={href}
className="font-medium transition-colors"
style={{ color: isActive ? '#FBB82B' : '#002F44' }}
>
{children}
</Link>
);
}
function MobileNavLink({ href, children, onClick }: { href: string; children: React.ReactNode; onClick: () => void }) {
const pathname = usePathname();
const isActive = pathname === href || (href !== '/' && pathname.startsWith(href));
return (
<Link
href={href}
className="px-4 py-2 hover:bg-gray-50 rounded-lg font-medium"
style={{ color: isActive ? '#FBB82B' : '#002F44' }}
onClick={onClick}
>
{children}
</Link>
);
}
export default function Header() {
const { t } = useLanguage();
const { user, isAdmin, logout } = useAuth();
@@ -41,13 +73,9 @@ export default function Header() {
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-gray-700 hover:text-primary-dark font-medium transition-colors"
>
<NavLink key={link.href} href={link.href}>
{link.label}
</Link>
</NavLink>
))}
</div>
@@ -115,14 +143,13 @@ export default function Header() {
>
<div className="flex flex-col gap-2 pt-4">
{navLinks.map((link) => (
<Link
<MobileNavLink
key={link.href}
href={link.href}
className="px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg font-medium"
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</Link>
</MobileNavLink>
))}
<div className="border-t border-gray-100 mt-2 pt-4 px-4">

View File

@@ -86,8 +86,15 @@ export const ticketsApi = {
return fetchApi<{ tickets: Ticket[] }>(`/api/tickets?${query}`);
},
// Validate ticket by QR code (for scanner)
validate: (code: string, eventId?: string) =>
fetchApi<TicketValidationResult>('/api/tickets/validate', {
method: 'POST',
body: JSON.stringify({ code, eventId }),
}),
checkin: (id: string) =>
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/checkin`, {
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
method: 'POST',
}),
@@ -141,6 +148,9 @@ export const ticketsApi = {
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
`/api/lnbits/status/${ticketId}`
),
// Get PDF download URL (returns the URL, not the PDF itself)
getPdfUrl: (id: string) => `${API_BASE}/api/tickets/${id}/pdf`,
};
// Contacts API
@@ -413,6 +423,8 @@ export interface Event {
titleEs?: string;
description: string;
descriptionEs?: string;
shortDescription?: string;
shortDescriptionEs?: string;
startDatetime: string;
endDatetime?: string;
location: string;
@@ -441,6 +453,7 @@ export interface Ticket {
preferredLanguage?: string;
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
checkinAt?: string;
checkedInByAdminId?: string;
qrCode: string;
adminNote?: string;
createdAt: string;
@@ -449,6 +462,29 @@ export interface Ticket {
user?: User;
}
export interface TicketValidationResult {
valid: boolean;
status: 'valid' | 'already_checked_in' | 'pending_payment' | 'cancelled' | 'invalid' | 'wrong_event';
canCheckIn: boolean;
ticket?: {
id: string;
qrCode: string;
attendeeName: string;
attendeeEmail?: string;
attendeePhone?: string;
status: string;
checkinAt?: string;
checkedInBy?: string;
};
event?: {
id: string;
title: string;
startDatetime: string;
location: string;
};
error?: string;
}
export interface Payment {
id: string;
ticketId: string;
@@ -892,3 +928,42 @@ export const dashboardApi = {
unlinkGoogle: () =>
fetchApi<{ message: string }>('/api/dashboard/unlink-google', { method: 'POST' }),
};
// ==================== Site Settings API ====================
export interface SiteSettings {
id?: string;
timezone: string;
siteName: string;
siteDescription?: string | null;
siteDescriptionEs?: string | null;
contactEmail?: string | null;
contactPhone?: string | null;
facebookUrl?: string | null;
instagramUrl?: string | null;
twitterUrl?: string | null;
linkedinUrl?: string | null;
maintenanceMode: boolean;
maintenanceMessage?: string | null;
maintenanceMessageEs?: string | null;
updatedAt?: string;
updatedBy?: string;
}
export interface TimezoneOption {
value: string;
label: string;
}
export const siteSettingsApi = {
get: () => fetchApi<{ settings: SiteSettings }>('/api/site-settings'),
update: (data: Partial<SiteSettings>) =>
fetchApi<{ settings: SiteSettings; message: string }>('/api/site-settings', {
method: 'PUT',
body: JSON.stringify(data),
}),
getTimezones: () =>
fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'),
};