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:
@@ -225,16 +225,18 @@ export default function BookingPage() {
|
||||
newErrors.firstName = t('booking.form.errors.firstNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.lastName.trim() || formData.lastName.length < 2) {
|
||||
newErrors.lastName = t('booking.form.errors.lastNameRequired');
|
||||
// lastName is optional - only validate if provided
|
||||
if (formData.lastName.trim() && formData.lastName.length < 2) {
|
||||
newErrors.lastName = t('booking.form.errors.lastNameTooShort');
|
||||
}
|
||||
|
||||
if (!formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = t('booking.form.errors.emailInvalid');
|
||||
}
|
||||
|
||||
if (!formData.phone.trim() || formData.phone.length < 6) {
|
||||
newErrors.phone = t('booking.form.errors.phoneRequired');
|
||||
// phone is optional - only validate if provided
|
||||
if (formData.phone.trim() && formData.phone.length < 6) {
|
||||
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
||||
}
|
||||
|
||||
// RUC validation (optional field - only validate if filled)
|
||||
@@ -915,14 +917,22 @@ export default function BookingPage() {
|
||||
error={errors.firstName}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t('booking.form.lastName')}
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||
placeholder={t('booking.form.lastNamePlaceholder')}
|
||||
error={errors.lastName}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('booking.form.lastName')}
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">
|
||||
({locale === 'es' ? 'Opcional' : 'Optional'})
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||
placeholder={t('booking.form.lastNamePlaceholder')}
|
||||
error={errors.lastName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -938,14 +948,20 @@ export default function BookingPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('booking.form.phone')}
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">
|
||||
({locale === 'es' ? 'Opcional' : 'Optional'})
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
label={t('booking.form.phone')}
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
placeholder={t('booking.form.phonePlaceholder')}
|
||||
error={errors.phone}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/app/(public)/community/layout.tsx
Normal file
18
frontend/src/app/(public)/community/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Join Our Language Exchange Community',
|
||||
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
||||
openGraph: {
|
||||
title: 'Join Our Language Exchange Community – Spanglish',
|
||||
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function CommunityLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
18
frontend/src/app/(public)/contact/layout.tsx
Normal file
18
frontend/src/app/(public)/contact/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contact Us',
|
||||
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
||||
openGraph: {
|
||||
title: 'Contact Us – Spanglish',
|
||||
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ContactLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
199
frontend/src/app/(public)/events/[id]/EventDetailClient.tsx
Normal file
199
frontend/src/app/(public)/events/[id]/EventDetailClient.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
UserGroupIcon,
|
||||
ArrowLeftIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface EventDetailClientProps {
|
||||
eventId: string;
|
||||
initialEvent: Event;
|
||||
}
|
||||
|
||||
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
|
||||
const { t, locale } = useLanguage();
|
||||
const [event, setEvent] = useState<Event>(initialEvent);
|
||||
|
||||
// Refresh event data on client for real-time availability
|
||||
useEffect(() => {
|
||||
eventsApi.getById(eventId)
|
||||
.then(({ event }) => setEvent(event))
|
||||
.catch(console.error);
|
||||
}, [eventId]);
|
||||
|
||||
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',
|
||||
});
|
||||
};
|
||||
|
||||
const isSoldOut = event.availableSeats === 0;
|
||||
const isCancelled = event.status === 'cancelled';
|
||||
const isPastEvent = new Date(event.startDatetime) < new Date();
|
||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<Link
|
||||
href="/events"
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Event Details */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="overflow-hidden">
|
||||
{/* Banner */}
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={`${event.title} - Spanglish language exchange event in Asunción`}
|
||||
className="h-64 w-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
|
||||
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h1 className="text-3xl font-bold text-primary-dark">
|
||||
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
</h1>
|
||||
{isCancelled && (
|
||||
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
|
||||
)}
|
||||
{isSoldOut && !isCancelled && (
|
||||
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.date')}</p>
|
||||
<p className="text-gray-600">{formatDate(event.startDatetime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<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">{formatTime(event.startDatetime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.location')}</p>
|
||||
<p className="text-gray-600">{event.location}</p>
|
||||
{event.locationUrl && (
|
||||
<a
|
||||
href={event.locationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-secondary-blue hover:underline text-sm"
|
||||
>
|
||||
View on map
|
||||
</a>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||
<h2 className="font-semibold text-lg mb-4">About this event</h2>
|
||||
<p className="text-gray-700 whitespace-pre-line">
|
||||
{locale === 'es' && event.descriptionEs
|
||||
? event.descriptionEs
|
||||
: event.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Sharing */}
|
||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||
<ShareButtons
|
||||
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Booking Card */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="p-6 sticky top-24">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
||||
<p className="text-4xl font-bold text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canBook ? (
|
||||
<Link href={`/book/${event.id}`}>
|
||||
<Button className="w-full" size="lg">
|
||||
{t('events.booking.join')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button className="w-full" size="lg" disabled>
|
||||
{isPastEvent
|
||||
? t('events.details.eventEnded')
|
||||
: isSoldOut
|
||||
? t('events.details.soldOut')
|
||||
: t('events.details.cancelled')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +1,147 @@
|
||||
'use client';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import EventDetailClient from './EventDetailClient';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
UserGroupIcon,
|
||||
ArrowLeftIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useLanguage();
|
||||
const [event, setEvent] = useState<Event | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description: string;
|
||||
descriptionEs?: string;
|
||||
startDatetime: string;
|
||||
endDatetime?: string;
|
||||
location: string;
|
||||
locationUrl?: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
||||
bannerUrl?: string;
|
||||
availableSeats?: number;
|
||||
bookedCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (params.id) {
|
||||
eventsApi.getById(params.id as string)
|
||||
.then(({ event }) => setEvent(event))
|
||||
.catch(() => router.push('/events'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [params.id, router]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
async function getEvent(id: string): Promise<Event | null> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events/${id}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.event || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const isSoldOut = event.availableSeats === 0;
|
||||
const isCancelled = event.status === 'cancelled';
|
||||
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
|
||||
const event = await getEvent(params.id);
|
||||
|
||||
if (!event) {
|
||||
return { title: 'Event Not Found' };
|
||||
}
|
||||
|
||||
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const title = `${event.title} – English & Spanish Meetup in Asunción`;
|
||||
const description = `Join Spanglish on ${eventDate} in Asunción. Practice English and Spanish in a relaxed social setting. Limited spots available.`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
images: event.bannerUrl
|
||||
? [{ url: event.bannerUrl, width: 1200, height: 630, alt: event.title }]
|
||||
: [{ url: `${siteUrl}/images/og-image.jpg`, width: 1200, height: 630, alt: 'Spanglish Language Exchange Event' }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: event.bannerUrl ? [event.bannerUrl] : [`${siteUrl}/images/og-image.jpg`],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/events/${event.id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateEventJsonLd(event: Event) {
|
||||
const isPastEvent = new Date(event.startDatetime) < new Date();
|
||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
|
||||
const isCancelled = event.status === 'cancelled';
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Event',
|
||||
name: event.title,
|
||||
description: event.description,
|
||||
startDate: event.startDatetime,
|
||||
endDate: event.endDatetime || event.startDatetime,
|
||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
eventStatus: isCancelled
|
||||
? 'https://schema.org/EventCancelled'
|
||||
: isPastEvent
|
||||
? 'https://schema.org/EventPostponed'
|
||||
: 'https://schema.org/EventScheduled',
|
||||
location: {
|
||||
'@type': 'Place',
|
||||
name: event.location,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Asunción',
|
||||
addressCountry: 'PY',
|
||||
},
|
||||
},
|
||||
organizer: {
|
||||
'@type': 'Organization',
|
||||
name: 'Spanglish',
|
||||
url: siteUrl,
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: event.price,
|
||||
priceCurrency: event.currency,
|
||||
availability: event.availableSeats && event.availableSeats > 0
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/SoldOut',
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
validFrom: new Date().toISOString(),
|
||||
},
|
||||
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({ params }: { params: { id: string } }) {
|
||||
const event = await getEvent(params.id);
|
||||
|
||||
if (!event) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const jsonLd = generateEventJsonLd(event);
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<Link
|
||||
href="/events"
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Event Details */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="overflow-hidden">
|
||||
{/* Banner */}
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={event.title}
|
||||
className="h-64 w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
|
||||
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h1 className="text-3xl font-bold text-primary-dark">
|
||||
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
</h1>
|
||||
{isCancelled && (
|
||||
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
|
||||
)}
|
||||
{isSoldOut && !isCancelled && (
|
||||
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.date')}</p>
|
||||
<p className="text-gray-600">{formatDate(event.startDatetime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<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">{formatTime(event.startDatetime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.location')}</p>
|
||||
<p className="text-gray-600">{event.location}</p>
|
||||
{event.locationUrl && (
|
||||
<a
|
||||
href={event.locationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-secondary-blue hover:underline text-sm"
|
||||
>
|
||||
View on map
|
||||
</a>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||
<h2 className="font-semibold text-lg mb-4">About this event</h2>
|
||||
<p className="text-gray-700 whitespace-pre-line">
|
||||
{locale === 'es' && event.descriptionEs
|
||||
? event.descriptionEs
|
||||
: event.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Sharing */}
|
||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||
<ShareButtons
|
||||
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Booking Card */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="p-6 sticky top-24">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
||||
<p className="text-4xl font-bold text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canBook ? (
|
||||
<Link href={`/book/${event.id}`}>
|
||||
<Button className="w-full" size="lg">
|
||||
{t('events.booking.join')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button className="w-full" size="lg" disabled>
|
||||
{isPastEvent
|
||||
? t('events.details.eventEnded')
|
||||
: isSoldOut
|
||||
? t('events.details.soldOut')
|
||||
: t('events.details.cancelled')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<EventDetailClient eventId={params.id} initialEvent={event} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
18
frontend/src/app/(public)/events/layout.tsx
Normal file
18
frontend/src/app/(public)/events/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Upcoming Language Exchange Events in Asunción',
|
||||
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
|
||||
openGraph: {
|
||||
title: 'Upcoming Language Exchange Events in Asunción – Spanglish',
|
||||
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function EventsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -108,8 +108,9 @@ export default function EventsPage() {
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={event.title}
|
||||
alt={`${event.title} - Spanglish language exchange event in Asunción`}
|
||||
className="h-40 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
|
||||
|
||||
66
frontend/src/app/(public)/faq/layout.tsx
Normal file
66
frontend/src/app/(public)/faq/layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
// FAQ Page structured data
|
||||
const faqSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What is Spanglish?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Who can attend Spanglish events?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How do language exchange events work?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Our events typically last 2-3 hours. You will be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Do I need to speak the language already?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice.',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Frequently Asked Questions',
|
||||
description: 'Find answers to common questions about Spanglish language exchange events in Asunción. Learn about how events work, who can attend, and more.',
|
||||
openGraph: {
|
||||
title: 'Frequently Asked Questions – Spanglish',
|
||||
description: 'Find answers to common questions about Spanglish language exchange events in Asunción.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function FAQLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
siteName: 'Spanglish',
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
alternateLocale: 'es_PY',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
};
|
||||
|
||||
// JSON-LD Organization schema for all public pages
|
||||
const organizationSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Spanglish',
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/images/logo.png`,
|
||||
description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Asunción',
|
||||
addressCountry: 'PY',
|
||||
},
|
||||
sameAs: [
|
||||
process.env.NEXT_PUBLIC_INSTAGRAM_URL,
|
||||
process.env.NEXT_PUBLIC_WHATSAPP_URL,
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_URL,
|
||||
].filter(Boolean),
|
||||
};
|
||||
|
||||
export default function PublicLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -8,6 +43,10 @@ export default function PublicLayout({
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
|
||||
/>
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
|
||||
@@ -13,6 +13,8 @@ export async function generateStaticParams() {
|
||||
return slugs.map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
|
||||
// Generate metadata for SEO
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const legalPage = getLegalPage(params.slug);
|
||||
@@ -24,8 +26,15 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${legalPage.title} | Spanglish`,
|
||||
description: `${legalPage.title} for Spanglish - Language Exchange Community in Paraguay`,
|
||||
title: `${legalPage.title} – Spanglish`,
|
||||
description: `${legalPage.title} for Spanglish language exchange events in Asunción, Paraguay.`,
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/legal/${params.slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Script from 'next/script';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||
import { authApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (config: any) => void;
|
||||
renderButton: (element: HTMLElement | null, options: any) => void;
|
||||
prompt: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t, locale: language } = useLanguage();
|
||||
const { login, loginWithGoogle } = useAuth();
|
||||
const { login } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
|
||||
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||
@@ -42,47 +28,6 @@ function LoginContent() {
|
||||
// Check for redirect after login
|
||||
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||||
|
||||
// Initialize Google Sign-In
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.google) {
|
||||
initializeGoogleSignIn();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initializeGoogleSignIn = () => {
|
||||
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
if (!clientId || !window.google) return;
|
||||
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: clientId,
|
||||
callback: handleGoogleCallback,
|
||||
});
|
||||
|
||||
const buttonElement = document.getElementById('google-signin-button');
|
||||
if (buttonElement) {
|
||||
window.google.accounts.id.renderButton(buttonElement, {
|
||||
type: 'standard',
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: 'continue_with',
|
||||
width: '100%',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleCallback = async (response: { credential: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await loginWithGoogle(response.credential);
|
||||
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
|
||||
router.push(redirectTo);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || (language === 'es' ? 'Error de inicio de sesión' : 'Login failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -122,14 +67,7 @@ function LoginContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src="https://accounts.google.com/gsi/client"
|
||||
strategy="afterInteractive"
|
||||
onLoad={initializeGoogleSignIn}
|
||||
/>
|
||||
|
||||
<div className="section-padding min-h-[70vh] flex items-center">
|
||||
<div className="section-padding min-h-[70vh] flex items-center">
|
||||
<div className="container-page">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
@@ -139,7 +77,11 @@ function LoginContent() {
|
||||
|
||||
<Card className="p-8">
|
||||
{/* Google Sign-In Button */}
|
||||
<div id="google-signin-button" className="mb-4 flex justify-center"></div>
|
||||
<GoogleSignInButton
|
||||
redirectTo={redirectTo}
|
||||
text="continue_with"
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Or Divider */}
|
||||
<div className="relative my-6">
|
||||
@@ -266,7 +208,6 @@ function LoginContent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,25 +94,28 @@ export default function HomePage() {
|
||||
{/* Hero Image Grid */}
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="relative rounded-card h-32 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src="/images/2026-01-29 13.10.08.jpg"
|
||||
alt="Language exchange event"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="relative rounded-card h-32 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src="/images/2026-01-29 13.10.08.jpg"
|
||||
alt="Spanglish language exchange social event in Asunción"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover"
|
||||
loading="eager"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary-yellow/60" />
|
||||
<ChatBubbleLeftRightIcon className="relative z-10 w-16 h-16 text-primary-dark opacity-50" />
|
||||
</div>
|
||||
<div className="relative rounded-card h-48 overflow-hidden">
|
||||
<Image
|
||||
src="/images/2026-01-29 13.10.23.jpg"
|
||||
alt="Group language practice"
|
||||
alt="English and Spanish language practice session in Asunción"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,19 +123,21 @@ export default function HomePage() {
|
||||
<div className="relative rounded-card h-48 overflow-hidden">
|
||||
<Image
|
||||
src="/images/2026-01-29 13.10.16.jpg"
|
||||
alt="Community meetup"
|
||||
alt="Spanglish community meetup in Paraguay"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative rounded-card h-32 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src="/images/2026-01-29 13.09.59.jpg"
|
||||
alt="Language exchange group"
|
||||
alt="Language exchange group practicing English and Spanish"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-secondary-brown/40" />
|
||||
<UserGroupIcon className="relative z-10 w-16 h-16 text-secondary-brown opacity-70" />
|
||||
|
||||
@@ -8,11 +8,12 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const { t, locale: language } = useLanguage();
|
||||
const { register } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -28,8 +29,8 @@ export default function RegisterPage() {
|
||||
|
||||
try {
|
||||
await register(formData);
|
||||
toast.success('Account created successfully!');
|
||||
router.push('/');
|
||||
toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!');
|
||||
router.push('/dashboard');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('auth.errors.emailExists'));
|
||||
} finally {
|
||||
@@ -47,6 +48,25 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<Card className="p-8">
|
||||
{/* Google Sign-In Button */}
|
||||
<GoogleSignInButton
|
||||
redirectTo="/dashboard"
|
||||
text="signup_with"
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Or Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">
|
||||
{language === 'es' ? 'o registrarse con email' : 'or register with email'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
id="name"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, mediaApi, Event } from '@/lib/api';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, ArrowUpTrayIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||
import MediaPicker from '@/components/MediaPicker';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@@ -18,8 +19,6 @@ export default function AdminEventsPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
@@ -166,25 +165,6 @@ export default function AdminEventsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await mediaApi.upload(file, editingEvent?.id, 'event');
|
||||
// Use proxied path so it works through Next.js rewrites
|
||||
setFormData({ ...formData, bannerUrl: result.url });
|
||||
toast.success('Image uploaded successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
@@ -360,53 +340,13 @@ export default function AdminEventsPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event Banner Image</label>
|
||||
<div className="mt-2">
|
||||
{formData.bannerUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formData.bannerUrl}
|
||||
alt="Event banner"
|
||||
className="w-full h-40 object-cover rounded-btn"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, bannerUrl: '' })}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded-full hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-secondary-light-gray rounded-btn p-8 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<p className="mt-2 text-sm text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<PhotoIcon className="w-12 h-12 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">Click to upload event image</p>
|
||||
<p className="text-xs text-gray-400">JPEG, PNG, GIF, WebP (max 5MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Image Upload / Media Picker */}
|
||||
<MediaPicker
|
||||
value={formData.bannerUrl}
|
||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||
relatedId={editingEvent?.id}
|
||||
relatedType="event"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ArrowPathIcon,
|
||||
TicketIcon,
|
||||
Cog6ToothIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -38,6 +40,7 @@ export default function PaymentOptionsPage() {
|
||||
cashEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
allowDuplicateBookings: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -399,6 +402,66 @@ export default function PaymentOptionsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Booking Settings */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Cog6ToothIcon className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Configuración de Reservas' : 'Booking Settings'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es' ? 'Opciones adicionales para el proceso de reserva' : 'Additional options for the booking process'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
{/* Allow Duplicate Bookings */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TicketIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{locale === 'es' ? 'Permitir Reservas Múltiples' : 'Allow Multiple Bookings'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Permitir que un usuario reserve varios tickets para el mismo evento con el mismo email'
|
||||
: 'Allow a user to book multiple tickets for the same event with the same email'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateOption('allowDuplicateBookings', !options.allowDuplicateBookings)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
options.allowDuplicateBookings ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
options.allowDuplicateBookings ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.allowDuplicateBookings && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 ml-8">
|
||||
<p className="text-sm text-yellow-800">
|
||||
{locale === 'es'
|
||||
? '⚠️ Cuando está habilitado, los usuarios pueden crear múltiples reservas para el mismo evento. Esto es útil para reservar en nombre de amigos o familiares.'
|
||||
: '⚠️ When enabled, users can create multiple bookings for the same event. This is useful for booking on behalf of friends or family.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -1,20 +1,91 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { LanguageProvider } from '@/context/LanguageContext';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import PlausibleAnalytics from '@/components/PlausibleAnalytics';
|
||||
import './globals.css';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Spanglish - Language Exchange in Asunción',
|
||||
description: 'Practice English and Spanish with native speakers at our language exchange events in Asunción, Paraguay.',
|
||||
keywords: ['language exchange', 'Spanish', 'English', 'Asunción', 'Paraguay', 'intercambio de idiomas'],
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
default: 'Spanglish – Language Exchange Events in Asunción',
|
||||
template: '%s – Spanglish',
|
||||
},
|
||||
description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
|
||||
keywords: [
|
||||
'language exchange',
|
||||
'Spanglish',
|
||||
'Spanglish social',
|
||||
'English Spanish meetup',
|
||||
'language exchange Asunción',
|
||||
'practice English Asunción',
|
||||
'intercambio de idiomas',
|
||||
'intercambio de idiomas Asunción',
|
||||
'English Spanish Paraguay',
|
||||
'language events Paraguay',
|
||||
'Asunción',
|
||||
'Paraguay',
|
||||
],
|
||||
authors: [{ name: 'Spanglish' }],
|
||||
creator: 'Spanglish',
|
||||
publisher: 'Spanglish',
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Spanglish - Language Exchange in Asunción',
|
||||
description: 'Practice English and Spanish with native speakers at our language exchange events.',
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
|
||||
url: siteUrl,
|
||||
siteName: 'Spanglish',
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
alternateLocale: 'es_ES',
|
||||
alternateLocale: 'es_PY',
|
||||
images: [
|
||||
{
|
||||
url: '/images/og-image.jpg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Spanglish – Language Exchange Events in Asunción, Paraguay',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.',
|
||||
images: ['/images/og-image.jpg'],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
alternates: {
|
||||
canonical: siteUrl,
|
||||
languages: {
|
||||
'en': siteUrl,
|
||||
'es': `${siteUrl}/es`,
|
||||
},
|
||||
},
|
||||
category: 'events',
|
||||
manifest: '/manifest.json',
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#FFD700',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,6 +96,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<PlausibleAnalytics />
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
{children}
|
||||
|
||||
41
frontend/src/app/not-found.tsx
Normal file
41
frontend/src/app/not-found.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Not Found – Spanglish',
|
||||
description: 'The page you are looking for could not be found.',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-gray">
|
||||
<div className="text-center px-4">
|
||||
<h1 className="text-6xl font-bold text-primary-dark mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/events"
|
||||
className="inline-flex items-center justify-center px-6 py-3 border-2 border-primary-dark text-primary-dark font-semibold rounded-btn hover:bg-primary-dark hover:text-white transition-colors"
|
||||
>
|
||||
View Events
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/app/robots.ts
Normal file
38
frontend/src/app/robots.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: [
|
||||
'/',
|
||||
'/events',
|
||||
'/events/*',
|
||||
'/community',
|
||||
'/contact',
|
||||
'/faq',
|
||||
'/legal/*',
|
||||
],
|
||||
disallow: [
|
||||
'/admin',
|
||||
'/admin/*',
|
||||
'/dashboard',
|
||||
'/dashboard/*',
|
||||
'/api',
|
||||
'/api/*',
|
||||
'/book',
|
||||
'/book/*',
|
||||
'/booking',
|
||||
'/booking/*',
|
||||
'/login',
|
||||
'/register',
|
||||
'/auth/*',
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
91
frontend/src/app/sitemap.ts
Normal file
91
frontend/src/app/sitemap.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
async function getPublishedEvents(): Promise<Event[]> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events?status=published`, {
|
||||
next: { revalidate: 3600 }, // Cache for 1 hour
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.events || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// Fetch published events for dynamic event pages
|
||||
const events = await getPublishedEvents();
|
||||
|
||||
// Static pages
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: siteUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/events`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/community`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/contact`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/faq`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
// Legal pages
|
||||
{
|
||||
url: `${siteUrl}/legal/terms-policy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/legal/privacy-policy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/legal/refund-cancelation-policy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
];
|
||||
|
||||
// Dynamic event pages
|
||||
const eventPages: MetadataRoute.Sitemap = events.map((event) => ({
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
lastModified: new Date(event.updatedAt),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
return [...staticPages, ...eventPages];
|
||||
}
|
||||
216
frontend/src/components/GoogleSignInButton.tsx
Normal file
216
frontend/src/components/GoogleSignInButton.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (config: GoogleInitConfig) => void;
|
||||
renderButton: (element: HTMLElement | null, options: GoogleButtonOptions) => void;
|
||||
prompt: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface GoogleInitConfig {
|
||||
client_id: string;
|
||||
callback: (response: GoogleCredentialResponse) => void;
|
||||
auto_select?: boolean;
|
||||
cancel_on_tap_outside?: boolean;
|
||||
}
|
||||
|
||||
interface GoogleButtonOptions {
|
||||
type?: 'standard' | 'icon';
|
||||
theme?: 'outline' | 'filled_blue' | 'filled_black';
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
|
||||
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
|
||||
logo_alignment?: 'left' | 'center';
|
||||
width?: string | number;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
interface GoogleCredentialResponse {
|
||||
credential: string;
|
||||
select_by?: string;
|
||||
}
|
||||
|
||||
interface GoogleSignInButtonProps {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
redirectTo?: string;
|
||||
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function GoogleSignInButton({
|
||||
onSuccess,
|
||||
onError,
|
||||
redirectTo = '/dashboard',
|
||||
text = 'continue_with',
|
||||
className = '',
|
||||
}: GoogleSignInButtonProps) {
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [scriptLoaded, setScriptLoaded] = useState(false);
|
||||
const [scriptError, setScriptError] = useState(false);
|
||||
const { loginWithGoogle } = useAuth();
|
||||
const { locale } = useLanguage();
|
||||
|
||||
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
const handleGoogleCallback = useCallback(
|
||||
async (response: GoogleCredentialResponse) => {
|
||||
if (!response.credential) {
|
||||
const errorMsg = locale === 'es' ? 'No se recibio credencial de Google' : 'No credential received from Google';
|
||||
onError?.(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await loginWithGoogle(response.credential);
|
||||
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
|
||||
onSuccess?.();
|
||||
|
||||
// Use window.location for navigation to ensure clean state
|
||||
if (redirectTo) {
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
|
||||
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
|
||||
onError?.(displayError);
|
||||
toast.error(displayError);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[loginWithGoogle, locale, onSuccess, onError, redirectTo]
|
||||
);
|
||||
|
||||
const initializeGoogleSignIn = useCallback(() => {
|
||||
if (!clientId) {
|
||||
console.warn('Google Client ID not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.google?.accounts?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: clientId,
|
||||
callback: handleGoogleCallback,
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: true,
|
||||
});
|
||||
|
||||
if (buttonRef.current) {
|
||||
// Clear any existing button
|
||||
buttonRef.current.innerHTML = '';
|
||||
|
||||
window.google.accounts.id.renderButton(buttonRef.current, {
|
||||
type: 'standard',
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: text,
|
||||
shape: 'rectangular',
|
||||
logo_alignment: 'left',
|
||||
width: 280,
|
||||
locale: locale === 'es' ? 'es' : 'en',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing Google Sign-In:', error);
|
||||
setScriptError(true);
|
||||
}
|
||||
}, [clientId, handleGoogleCallback, text, locale]);
|
||||
|
||||
// Load Google Sign-In script
|
||||
useEffect(() => {
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script is already loaded
|
||||
if (window.google?.accounts?.id) {
|
||||
setScriptLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script tag already exists
|
||||
const existingScript = document.querySelector('script[src="https://accounts.google.com/gsi/client"]');
|
||||
if (existingScript) {
|
||||
// Script exists but may not be loaded yet
|
||||
existingScript.addEventListener('load', () => setScriptLoaded(true));
|
||||
existingScript.addEventListener('error', () => setScriptError(true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the script
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://accounts.google.com/gsi/client';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => setScriptLoaded(true);
|
||||
script.onerror = () => setScriptError(true);
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
// Cleanup is handled by checking for existing script
|
||||
};
|
||||
}, [clientId]);
|
||||
|
||||
// Initialize when script is loaded
|
||||
useEffect(() => {
|
||||
if (scriptLoaded) {
|
||||
// Small delay to ensure Google object is fully available
|
||||
const timer = setTimeout(initializeGoogleSignIn, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [scriptLoaded, initializeGoogleSignIn]);
|
||||
|
||||
// Don't render if no client ID configured
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scriptError) {
|
||||
return (
|
||||
<div className={`text-center text-sm text-gray-500 py-2 ${className}`}>
|
||||
{locale === 'es'
|
||||
? 'Google Sign-In no disponible'
|
||||
: 'Google Sign-In unavailable'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Google Sign-In Button Container */}
|
||||
<div
|
||||
ref={buttonRef}
|
||||
className="flex justify-center min-h-[44px]"
|
||||
aria-label={locale === 'es' ? 'Iniciar sesion con Google' : 'Sign in with Google'}
|
||||
/>
|
||||
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center rounded">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-secondary-blue" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/MediaPicker.tsx
Normal file
277
frontend/src/components/MediaPicker.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { mediaApi, Media } from '@/lib/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
PhotoIcon,
|
||||
ArrowUpTrayIcon,
|
||||
XMarkIcon,
|
||||
CheckIcon,
|
||||
FolderOpenIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface MediaPickerProps {
|
||||
value?: string;
|
||||
onChange: (url: string) => void;
|
||||
relatedId?: string;
|
||||
relatedType?: string;
|
||||
}
|
||||
|
||||
export default function MediaPicker({ value, onChange, relatedId, relatedType = 'event' }: MediaPickerProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'upload' | 'library'>('upload');
|
||||
const [media, setMedia] = useState<Media[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedMedia, setSelectedMedia] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal && activeTab === 'library') {
|
||||
loadMedia();
|
||||
}
|
||||
}, [showModal, activeTab]);
|
||||
|
||||
const loadMedia = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { media } = await mediaApi.getAll();
|
||||
setMedia(media);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load media library');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await mediaApi.upload(file, relatedId, relatedType);
|
||||
onChange(result.url);
|
||||
toast.success('Image uploaded successfully');
|
||||
setShowModal(false);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFromLibrary = () => {
|
||||
if (selectedMedia) {
|
||||
onChange(selectedMedia);
|
||||
setShowModal(false);
|
||||
setSelectedMedia(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event Banner Image</label>
|
||||
<div className="mt-2">
|
||||
{value ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={value}
|
||||
alt="Event banner"
|
||||
className="w-full h-40 object-cover rounded-btn"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded-full hover:bg-red-600"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-1 border-2 border-dashed border-secondary-light-gray rounded-btn p-6 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<p className="mt-2 text-sm text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<ArrowUpTrayIcon className="w-10 h-10 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">Upload New</p>
|
||||
<p className="text-xs text-gray-400">JPEG, PNG, GIF, WebP</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowModal(true); setActiveTab('library'); }}
|
||||
className="flex-1 border-2 border-dashed border-secondary-light-gray rounded-btn p-6 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<FolderOpenIcon className="w-10 h-10 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">Choose from Library</p>
|
||||
<p className="text-xs text-gray-400">Select existing media</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Media Library Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-btn w-full max-w-4xl max-h-[80vh] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-semibold">Select Image</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowModal(false); setSelectedMedia(null); }}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('upload')}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'upload'
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<ArrowUpTrayIcon className="w-4 h-4 inline-block mr-2" />
|
||||
Upload New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('library')}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'library'
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FolderOpenIcon className="w-4 h-4 inline-block mr-2" />
|
||||
Media Library
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'upload' && (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-secondary-light-gray rounded-btn p-12 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-12 h-12 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<p className="mt-4 text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<PhotoIcon className="w-16 h-16 text-gray-400" />
|
||||
<p className="mt-4 text-lg text-gray-600">Click to upload an image</p>
|
||||
<p className="text-sm text-gray-400 mt-1">JPEG, PNG, GIF, WebP (max 10MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'library' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<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>
|
||||
) : media.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<PhotoIcon className="w-12 h-12 mx-auto text-gray-400" />
|
||||
<p className="mt-2">No images in library</p>
|
||||
<p className="text-sm">Upload an image to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
||||
{media.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedMedia(item.fileUrl)}
|
||||
className={`relative aspect-square rounded-btn overflow-hidden border-2 transition-all ${
|
||||
selectedMedia === item.fileUrl
|
||||
? 'border-primary-yellow ring-2 ring-primary-yellow/30'
|
||||
: 'border-transparent hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.fileUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{selectedMedia === item.fileUrl && (
|
||||
<div className="absolute inset-0 bg-primary-yellow/20 flex items-center justify-center">
|
||||
<div className="bg-primary-yellow rounded-full p-1">
|
||||
<CheckIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
{activeTab === 'library' && (
|
||||
<div className="flex justify-end gap-3 p-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowModal(false); setSelectedMedia(null); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSelectFromLibrary}
|
||||
disabled={!selectedMedia}
|
||||
>
|
||||
Select Image
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/PlausibleAnalytics.tsx
Normal file
28
frontend/src/components/PlausibleAnalytics.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import Script from 'next/script';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function PlausibleAnalytics() {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Get Plausible configuration from environment variables
|
||||
const plausibleUrl = process.env.NEXT_PUBLIC_PLAUSIBLE_URL;
|
||||
const plausibleDomain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN;
|
||||
|
||||
// Don't render on admin pages or if configuration is missing
|
||||
const isAdminPage = pathname?.startsWith('/admin');
|
||||
|
||||
if (isAdminPage || !plausibleUrl || !plausibleDomain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
defer
|
||||
data-domain={plausibleDomain}
|
||||
src={`${plausibleUrl}/js/script.js`}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,10 @@ export default function Footer() {
|
||||
<p className="mt-3 text-gray-600 max-w-md">
|
||||
{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 */}
|
||||
|
||||
@@ -270,6 +270,14 @@ export const paymentOptionsApi = {
|
||||
|
||||
// Media API
|
||||
export const mediaApi = {
|
||||
getAll: (relatedType?: string, relatedId?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (relatedType) params.set('relatedType', relatedType);
|
||||
if (relatedId) params.set('relatedId', relatedId);
|
||||
const query = params.toString();
|
||||
return fetchApi<{ media: Media[] }>(`/api/media${query ? `?${query}` : ''}`);
|
||||
},
|
||||
|
||||
upload: async (file: File, relatedId?: string, relatedType?: string) => {
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('spanglish-token')
|
||||
@@ -488,6 +496,8 @@ export interface PaymentOptionsConfig {
|
||||
cashEnabled: boolean;
|
||||
cashInstructions?: string | null;
|
||||
cashInstructionsEs?: string | null;
|
||||
// Booking settings
|
||||
allowDuplicateBookings?: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
||||
Reference in New Issue
Block a user