Add full SEO optimization for Spanglish social and language events
- Add comprehensive metadata to root layout with Open Graph, Twitter cards - Create dynamic sitemap.ts for all pages and events - Create robots.ts with proper allow/disallow rules - Add JSON-LD Event structured data to event detail pages - Add page-specific metadata to events, community, contact, FAQ pages - Add FAQ structured data schema - Update footer with local SEO text for Asunción, Paraguay - Add web manifest for mobile SEO - Create 404 page with proper noindex - Optimize image alt text and add lazy loading - Add NEXT_PUBLIC_SITE_URL env variable - Add about/ folder to gitignore
This commit is contained in:
492
frontend/src/app/(public)/booking/[ticketId]/page.tsx
Normal file
492
frontend/src/app/(public)/booking/[ticketId]/page.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
TicketIcon,
|
||||
CreditCardIcon,
|
||||
BuildingLibraryIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
CurrencyDollarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error';
|
||||
|
||||
export default function BookingPaymentPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const { locale } = useLanguage();
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [paymentConfig, setPaymentConfig] = useState<PaymentOptionsConfig | null>(null);
|
||||
const [step, setStep] = useState<PaymentStep>('loading');
|
||||
const [markingPaid, setMarkingPaid] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const ticketId = params.ticketId as string;
|
||||
const requestedStep = searchParams.get('step');
|
||||
|
||||
// Fetch ticket and payment config
|
||||
useEffect(() => {
|
||||
if (!ticketId) return;
|
||||
|
||||
const loadBookingData = async () => {
|
||||
try {
|
||||
// Get ticket with event and payment info
|
||||
const { ticket: ticketData } = await ticketsApi.getById(ticketId);
|
||||
|
||||
if (!ticketData) {
|
||||
setError('Booking not found');
|
||||
setStep('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setTicket(ticketData);
|
||||
|
||||
// Only proceed for manual payment methods
|
||||
const paymentMethod = ticketData.payment?.provider;
|
||||
if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) {
|
||||
// Not a manual payment method, redirect to success page or show appropriate state
|
||||
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
|
||||
setStep('confirmed');
|
||||
} else {
|
||||
setError('This booking does not support manual payment confirmation.');
|
||||
setStep('error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get payment config for the event
|
||||
if (ticketData.eventId) {
|
||||
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId);
|
||||
setPaymentConfig(paymentOptions);
|
||||
}
|
||||
|
||||
// Determine which step to show based on payment status
|
||||
const paymentStatus = ticketData.payment?.status;
|
||||
|
||||
if (paymentStatus === 'paid' || ticketData.status === 'confirmed') {
|
||||
setStep('confirmed');
|
||||
} else if (paymentStatus === 'pending_approval') {
|
||||
setStep('pending_approval');
|
||||
} else if (paymentStatus === 'pending') {
|
||||
setStep('manual_payment');
|
||||
} else {
|
||||
setError('Unable to determine payment status');
|
||||
setStep('error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error loading booking:', err);
|
||||
setError(err.message || 'Failed to load booking');
|
||||
setStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
loadBookingData();
|
||||
}, [ticketId]);
|
||||
|
||||
// Handle "I Have Paid" button click
|
||||
const handleMarkPaymentSent = async () => {
|
||||
if (!ticket) return;
|
||||
|
||||
// Check if already marked as paid
|
||||
if (ticket.payment?.status === 'pending_approval' || ticket.payment?.status === 'paid') {
|
||||
toast(locale === 'es'
|
||||
? 'El pago ya fue marcado como enviado.'
|
||||
: 'Payment has already been marked as sent.',
|
||||
{ icon: 'ℹ️' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setMarkingPaid(true);
|
||||
try {
|
||||
await ticketsApi.markPaymentSent(ticket.id);
|
||||
|
||||
// Update local state
|
||||
setTicket(prev => prev ? {
|
||||
...prev,
|
||||
payment: prev.payment ? {
|
||||
...prev.payment,
|
||||
status: 'pending_approval',
|
||||
userMarkedPaidAt: new Date().toISOString(),
|
||||
} : prev.payment,
|
||||
} : null);
|
||||
|
||||
setStep('pending_approval');
|
||||
toast.success(locale === 'es'
|
||||
? 'Pago marcado como enviado. Esperando aprobación.'
|
||||
: 'Payment marked as sent. Waiting for approval.');
|
||||
} catch (error: any) {
|
||||
// Handle idempotency - if already processed, show appropriate message
|
||||
if (error.message?.includes('already been processed')) {
|
||||
toast(locale === 'es'
|
||||
? 'El pago ya fue procesado anteriormente.'
|
||||
: 'Payment has already been processed.',
|
||||
{ icon: 'ℹ️' }
|
||||
);
|
||||
// Refresh ticket data
|
||||
const { ticket: refreshedTicket } = await ticketsApi.getById(ticket.id);
|
||||
setTicket(refreshedTicket);
|
||||
if (refreshedTicket.payment?.status === 'pending_approval') {
|
||||
setStep('pending_approval');
|
||||
} else if (refreshedTicket.payment?.status === 'paid') {
|
||||
setStep('confirmed');
|
||||
}
|
||||
} else {
|
||||
toast.error(error.message || 'Failed to mark payment as sent');
|
||||
}
|
||||
} finally {
|
||||
setMarkingPaid(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (step === 'loading') {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
<p className="mt-4 text-gray-600">
|
||||
{locale === 'es' ? 'Cargando tu reserva...' : 'Loading your booking...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (step === 'error') {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-6">
|
||||
<XCircleIcon className="w-10 h-10 text-red-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? 'Reserva no encontrada' : 'Booking Not Found'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{error || (locale === 'es'
|
||||
? 'No pudimos encontrar tu reserva.'
|
||||
: 'We could not find your booking.')}
|
||||
</p>
|
||||
<Link href="/events">
|
||||
<Button>{locale === 'es' ? 'Ver Eventos' : 'Browse Events'}</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Confirmed state
|
||||
if (step === 'confirmed' && ticket) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircleIcon className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? '¡Reserva Confirmada!' : 'Booking Confirmed!'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{locale === 'es'
|
||||
? 'Tu pago ha sido verificado. ¡Te esperamos en el evento!'
|
||||
: 'Your payment has been verified. See you at the event!'}
|
||||
</p>
|
||||
|
||||
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<TicketIcon className="w-6 h-6 text-primary-yellow" />
|
||||
<span className="font-mono text-lg font-bold">{ticket.qrCode}</span>
|
||||
</div>
|
||||
|
||||
{ticket.event && (
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
||||
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link href="/events">
|
||||
<Button variant="outline">{locale === 'es' ? 'Ver Más Eventos' : 'Browse More Events'}</Button>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<Button>{locale === 'es' ? 'Volver al Inicio' : 'Back to Home'}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pending approval state
|
||||
if (step === 'pending_approval' && ticket) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mx-auto mb-6">
|
||||
<ClockIcon className="w-10 h-10 text-yellow-600" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? '¡Pago en Verificación!' : 'Payment Being Verified!'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{locale === 'es'
|
||||
? 'Estamos verificando tu pago. Recibirás un email de confirmación una vez aprobado.'
|
||||
: 'We are verifying your payment. You will receive a confirmation email once approved.'}
|
||||
</p>
|
||||
|
||||
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<TicketIcon className="w-6 h-6 text-primary-yellow" />
|
||||
<span className="font-mono text-lg font-bold">{ticket.qrCode}</span>
|
||||
</div>
|
||||
|
||||
{ticket.event && (
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
||||
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
{locale === 'es'
|
||||
? 'La verificación del pago puede tomar hasta 24 horas hábiles. Por favor revisa tu email regularmente.'
|
||||
: 'Payment verification may take up to 24 business hours. Please check your email regularly.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link href="/events">
|
||||
<Button variant="outline">{locale === 'es' ? 'Ver Más Eventos' : 'Browse More Events'}</Button>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<Button>{locale === 'es' ? 'Volver al Inicio' : 'Back to Home'}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Manual payment step - show payment details and "I have paid" button
|
||||
if (step === 'manual_payment' && ticket && paymentConfig) {
|
||||
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
||||
const isTpago = ticket.payment?.provider === 'tpago';
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
{/* Event Summary Card */}
|
||||
{ticket.event && (
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<div className="bg-primary-yellow/20 p-4 border-b border-primary-yellow/30">
|
||||
<h2 className="font-bold text-lg text-primary-dark">
|
||||
{locale === 'es' && ticket.event.titleEs ? ticket.event.titleEs : ticket.event.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{formatDate(ticket.event.startDatetime)} - {formatTime(ticket.event.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{ticket.event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span className="font-bold text-lg">
|
||||
{ticket.event.price?.toLocaleString()} {ticket.event.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Payment Details Card */}
|
||||
<Card className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className={`w-16 h-16 rounded-full ${isBankTransfer ? 'bg-green-100' : 'bg-blue-100'} flex items-center justify-center mx-auto mb-4`}>
|
||||
{isBankTransfer ? (
|
||||
<BuildingLibraryIcon className="w-8 h-8 text-green-600" />
|
||||
) : (
|
||||
<CreditCardIcon className="w-8 h-8 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? 'Completa tu Pago' : 'Complete Your Payment'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'Sigue las instrucciones para completar tu pago'
|
||||
: 'Follow the instructions to complete your payment'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Amount to pay */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6 text-center">
|
||||
<p className="text-sm text-gray-500 mb-1">
|
||||
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-primary-dark">
|
||||
{ticket.event?.price?.toLocaleString()} {ticket.event?.currency}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bank Transfer Details */}
|
||||
{isBankTransfer && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{locale === 'es' ? 'Datos Bancarios' : 'Bank Details'}
|
||||
</h3>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
|
||||
{paymentConfig.bankName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Banco' : 'Bank'}:</span>
|
||||
<span className="font-medium">{paymentConfig.bankName}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankAccountHolder && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Titular' : 'Account Holder'}:</span>
|
||||
<span className="font-medium">{paymentConfig.bankAccountHolder}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankAccountNumber && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Nro. Cuenta' : 'Account Number'}:</span>
|
||||
<span className="font-medium font-mono">{paymentConfig.bankAccountNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankAlias && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Alias:</span>
|
||||
<span className="font-medium">{paymentConfig.bankAlias}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankPhone && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Teléfono' : 'Phone'}:</span>
|
||||
<span className="font-medium">{paymentConfig.bankPhone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes) && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TPago Link */}
|
||||
{isTpago && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
|
||||
</h3>
|
||||
{paymentConfig.tpagoLink && (
|
||||
<a
|
||||
href={paymentConfig.tpagoLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="w-5 h-5" />
|
||||
{locale === 'es' ? 'Abrir TPago para Pagar' : 'Open TPago to Pay'}
|
||||
</a>
|
||||
)}
|
||||
{(locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions) && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reference */}
|
||||
<div className="bg-gray-100 rounded-lg p-3 mb-6">
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
{locale === 'es' ? 'Referencia de tu reserva' : 'Your booking reference'}
|
||||
</p>
|
||||
<p className="font-mono font-bold text-lg">{ticket.qrCode}</p>
|
||||
</div>
|
||||
|
||||
{/* I Have Paid Button */}
|
||||
<Button
|
||||
onClick={handleMarkPaymentSent}
|
||||
isLoading={markingPaid}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-gray-500 mt-4">
|
||||
{locale === 'es'
|
||||
? 'Tu reserva será confirmada una vez que verifiquemos el pago'
|
||||
: 'Your booking will be confirmed once we verify the payment'}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl text-center">
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es' ? 'Cargando...' : 'Loading...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user