509 lines
21 KiB
TypeScript
509 lines
21 KiB
TypeScript
'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 { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||
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) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||
|
||
// 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> {fmtTime(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> {fmtTime(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)} - {fmtTime(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 !== undefined ? formatPrice(ticket.event.price, 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 !== undefined ? formatPrice(ticket.event.price, 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>
|
||
|
||
{/* Manual verification notice */}
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||
<div className="flex gap-3">
|
||
<div className="flex-shrink-0">
|
||
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||
</svg>
|
||
</div>
|
||
<div className="text-sm text-blue-800">
|
||
<p className="font-medium mb-1">
|
||
{locale === 'es' ? 'Verificación manual' : 'Manual verification'}
|
||
</p>
|
||
<p className="text-blue-700">
|
||
{locale === 'es'
|
||
? 'El equipo de Spanglish revisará el pago manualmente. Tu reserva solo será confirmada después de recibir un email de confirmación de nuestra parte.'
|
||
: 'The Spanglish team will review the payment manually. Your booking is only confirmed after you receive a confirmation email from us.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Warning before I Have Paid button */}
|
||
<p className="text-sm text-center text-amber-700 font-medium mb-3">
|
||
{locale === 'es'
|
||
? 'Solo haz clic aquí después de haber completado el pago.'
|
||
: 'Only click this after you have actually completed the payment.'}
|
||
</p>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|