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:
root
2026-01-30 21:05:25 +00:00
parent d0ea55dc5b
commit 47ba754f05
40 changed files with 2659 additions and 420 deletions

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