first commit

This commit is contained in:
Michaël
2026-01-29 14:13:11 -03:00
commit 2302748c87
105 changed files with 93301 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
'use client';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
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 { authApi } from '@/lib/api';
import toast from 'react-hot-toast';
function ClaimAccountContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { locale: language } = useLanguage();
const { setAuthData } = useAuth();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
});
const token = searchParams.get('token');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) {
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
return;
}
if (formData.password !== formData.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (formData.password.length < 10) {
toast.error(
language === 'es'
? 'La contraseña debe tener al menos 10 caracteres'
: 'Password must be at least 10 characters'
);
return;
}
setLoading(true);
try {
const result = await authApi.confirmClaimAccount(token, { password: formData.password });
setAuthData({ user: result.user, token: result.token });
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
router.push('/dashboard');
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
} finally {
setLoading(false);
}
};
if (!token) {
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
<div className="max-w-md mx-auto">
<Card className="p-8 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">
{language === 'es' ? 'Enlace Inválido' : 'Invalid Link'}
</h2>
<p className="text-gray-600 mb-6">
{language === 'es'
? 'Este enlace de activación no es válido o ha expirado.'
: 'This activation link is invalid or has expired.'}
</p>
<Link href="/login">
<Button>
{language === 'es' ? 'Ir a Iniciar Sesión' : 'Go to Login'}
</Button>
</Link>
</Card>
</div>
</div>
</div>
);
}
return (
<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">
<h1 className="text-3xl font-bold">
{language === 'es' ? 'Activar tu Cuenta' : 'Activate Your Account'}
</h1>
<p className="mt-2 text-gray-600">
{language === 'es'
? 'Configura una contraseña para acceder a tu cuenta'
: 'Set a password to access your account'}
</p>
</div>
<Card className="p-8">
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
{language === 'es'
? 'Tu cuenta fue creada durante una reservación. Establece una contraseña para acceder a tu historial de entradas y más.'
: 'Your account was created during a booking. Set a password to access your ticket history and more.'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Input
id="password"
label={language === 'es' ? 'Contraseña' : 'Password'}
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
<p className="text-xs text-gray-500 -mt-4">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmPassword"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
required
/>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{language === 'es' ? 'Activar Cuenta' : 'Activate Account'}
</Button>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
{language === 'es' ? '¿Ya tienes acceso?' : 'Already have access?'}{' '}
<Link href="/login" className="text-secondary-blue hover:underline font-medium">
{language === 'es' ? 'Iniciar Sesión' : 'Log In'}
</Link>
</p>
</Card>
</div>
</div>
</div>
);
}
function LoadingFallback() {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
</div>
);
}
export default function ClaimAccountPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<ClaimAccountContent />
</Suspense>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { authApi } from '@/lib/api';
import toast from 'react-hot-toast';
export default function ForgotPasswordPage() {
const { locale: language } = useLanguage();
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const [email, setEmail] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await authApi.requestPasswordReset(email);
setSent(true);
toast.success(
language === 'es'
? 'Revisa tu correo para el enlace de restablecimiento'
: 'Check your email for the reset link'
);
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
} finally {
setLoading(false);
}
};
return (
<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">
<h1 className="text-3xl font-bold">
{language === 'es' ? 'Restablecer Contraseña' : 'Reset Password'}
</h1>
<p className="mt-2 text-gray-600">
{language === 'es'
? 'Ingresa tu email para recibir un enlace de restablecimiento'
: 'Enter your email to receive a reset link'}
</p>
</div>
<Card className="p-8">
{sent ? (
<div className="text-center py-4">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">
{language === 'es' ? 'Revisa tu Email' : 'Check Your Email'}
</h3>
<p className="text-gray-600 text-sm mb-4">
{language === 'es'
? `Si existe una cuenta con ${email}, recibirás un enlace para restablecer tu contraseña.`
: `If an account exists with ${email}, you'll receive a password reset link.`}
</p>
<button
onClick={() => setSent(false)}
className="text-secondary-blue hover:underline text-sm"
>
{language === 'es' ? 'Intentar con otro email' : 'Try a different email'}
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<Input
id="email"
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{language === 'es' ? 'Enviar Enlace' : 'Send Reset Link'}
</Button>
</form>
)}
<p className="mt-6 text-center text-sm text-gray-600">
{language === 'es' ? '¿Recordaste tu contraseña?' : 'Remember your password?'}{' '}
<Link href="/login" className="text-secondary-blue hover:underline font-medium">
{language === 'es' ? 'Iniciar Sesión' : 'Log In'}
</Link>
</p>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useEffect, useState, Suspense, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import toast from 'react-hot-toast';
function MagicLinkContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { locale: language } = useLanguage();
const { loginWithMagicLink } = useAuth();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [error, setError] = useState('');
const verificationAttempted = useRef(false);
const token = searchParams.get('token');
useEffect(() => {
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
if (verificationAttempted.current) return;
if (token) {
verificationAttempted.current = true;
verifyToken();
} else {
setStatus('error');
setError(language === 'es' ? 'Token no encontrado' : 'Token not found');
}
}, [token]);
const verifyToken = async () => {
try {
await loginWithMagicLink(token!);
setStatus('success');
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
setTimeout(() => {
router.push('/dashboard');
}, 1500);
} catch (err: any) {
setStatus('error');
setError(err.message || (language === 'es' ? 'Enlace inválido o expirado' : 'Invalid or expired link'));
}
};
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
<div className="max-w-md mx-auto">
<Card className="p-8 text-center">
{status === 'loading' && (
<>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue mx-auto mb-4"></div>
<h2 className="text-xl font-semibold mb-2">
{language === 'es' ? 'Verificando...' : 'Verifying...'}
</h2>
<p className="text-gray-600">
{language === 'es' ? 'Por favor espera' : 'Please wait'}
</p>
</>
)}
{status === 'success' && (
<>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">
{language === 'es' ? '¡Inicio de sesión exitoso!' : 'Login successful!'}
</h2>
<p className="text-gray-600">
{language === 'es' ? 'Redirigiendo...' : 'Redirecting...'}
</p>
</>
)}
{status === 'error' && (
<>
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">
{language === 'es' ? 'Error de Verificación' : 'Verification Error'}
</h2>
<p className="text-gray-600 mb-6">{error}</p>
<Button onClick={() => router.push('/login')}>
{language === 'es' ? 'Ir a Iniciar Sesión' : 'Go to Login'}
</Button>
</>
)}
</Card>
</div>
</div>
</div>
);
}
function LoadingFallback() {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
</div>
);
}
export default function MagicLinkPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<MagicLinkContent />
</Suspense>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { authApi } from '@/lib/api';
import toast from 'react-hot-toast';
function ResetPasswordContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { locale: language } = useLanguage();
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
});
const token = searchParams.get('token');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) {
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
return;
}
if (formData.password !== formData.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (formData.password.length < 10) {
toast.error(
language === 'es'
? 'La contraseña debe tener al menos 10 caracteres'
: 'Password must be at least 10 characters'
);
return;
}
setLoading(true);
try {
await authApi.confirmPasswordReset(token, formData.password);
setSuccess(true);
toast.success(language === 'es' ? 'Contraseña actualizada' : 'Password updated');
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
} finally {
setLoading(false);
}
};
if (!token) {
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
<div className="max-w-md mx-auto">
<Card className="p-8 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">
{language === 'es' ? 'Enlace Inválido' : 'Invalid Link'}
</h2>
<p className="text-gray-600 mb-6">
{language === 'es'
? 'Este enlace de restablecimiento no es válido o ha expirado.'
: 'This reset link is invalid or has expired.'}
</p>
<Link href="/auth/forgot-password">
<Button>
{language === 'es' ? 'Solicitar Nuevo Enlace' : 'Request New Link'}
</Button>
</Link>
</Card>
</div>
</div>
</div>
);
}
return (
<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">
<h1 className="text-3xl font-bold">
{language === 'es' ? 'Nueva Contraseña' : 'New Password'}
</h1>
<p className="mt-2 text-gray-600">
{language === 'es'
? 'Ingresa tu nueva contraseña'
: 'Enter your new password'}
</p>
</div>
<Card className="p-8">
{success ? (
<div className="text-center py-4">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">
{language === 'es' ? '¡Contraseña Actualizada!' : 'Password Updated!'}
</h3>
<p className="text-gray-600 text-sm mb-6">
{language === 'es'
? 'Tu contraseña ha sido cambiada exitosamente.'
: 'Your password has been successfully changed.'}
</p>
<Link href="/login">
<Button>
{language === 'es' ? 'Iniciar Sesión' : 'Log In'}
</Button>
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<Input
id="password"
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
<p className="text-xs text-gray-500 -mt-4">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmPassword"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
required
/>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{language === 'es' ? 'Actualizar Contraseña' : 'Update Password'}
</Button>
</form>
)}
</Card>
</div>
</div>
</div>
);
}
function LoadingFallback() {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
</div>
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<ResetPasswordContent />
</Suspense>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, Ticket } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
CheckCircleIcon,
ClockIcon,
XCircleIcon,
TicketIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
export default function BookingSuccessPage() {
const params = useParams();
const { t, locale } = useLanguage();
const [ticket, setTicket] = useState<Ticket | null>(null);
const [loading, setLoading] = useState(true);
const [polling, setPolling] = useState(false);
const ticketId = params.ticketId as string;
const checkPaymentStatus = async () => {
try {
const { ticket: ticketData } = await ticketsApi.getById(ticketId);
setTicket(ticketData);
// If still pending, continue polling
if (ticketData.status === 'pending' && ticketData.payment?.status === 'pending') {
return false; // Not done yet
}
return true; // Done polling
} catch (error) {
console.error('Error checking payment status:', error);
return true; // Stop polling on error
}
};
useEffect(() => {
if (!ticketId) return;
// Initial load
checkPaymentStatus().finally(() => setLoading(false));
// Poll for payment status every 3 seconds
setPolling(true);
const interval = setInterval(async () => {
const isDone = await checkPaymentStatus();
if (isDone) {
setPolling(false);
clearInterval(interval);
}
}, 3000);
// Stop polling after 5 minutes
const timeout = setTimeout(() => {
setPolling(false);
clearInterval(interval);
}, 5 * 60 * 1000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [ticketId]);
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',
});
};
if (loading) {
return (
<div className="section-padding">
<div className="container-page max-w-2xl 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' ? 'Verificando pago...' : 'Verifying payment...'}
</p>
</div>
</div>
);
}
if (!ticket) {
return (
<div className="section-padding">
<div className="container-page max-w-2xl">
<Card className="p-8 text-center">
<XCircleIcon className="w-16 h-16 text-red-500 mx-auto mb-4" />
<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">
{locale === 'es'
? 'No pudimos encontrar tu reserva. Por favor, contacta con soporte.'
: 'We could not find your booking. Please contact support.'}
</p>
<Link href="/events">
<Button>{locale === 'es' ? 'Ver Eventos' : 'Browse Events'}</Button>
</Link>
</Card>
</div>
</div>
);
}
const isPaid = ticket.status === 'confirmed' || ticket.payment?.status === 'paid';
const isPending = ticket.status === 'pending' && ticket.payment?.status === 'pending';
const isFailed = ticket.payment?.status === 'failed';
return (
<div className="section-padding">
<div className="container-page max-w-2xl">
<Card className="p-8 text-center">
{/* Status Icon */}
{isPaid ? (
<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>
) : isPending ? (
<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>
) : (
<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>
)}
{/* Title */}
<h1 className="text-2xl font-bold text-primary-dark mb-2">
{isPaid
? (locale === 'es' ? '¡Pago Confirmado!' : 'Payment Confirmed!')
: isPending
? (locale === 'es' ? 'Esperando Pago...' : 'Waiting for Payment...')
: (locale === 'es' ? 'Pago Fallido' : 'Payment Failed')
}
</h1>
<p className="text-gray-600 mb-6">
{isPaid
? (locale === 'es'
? 'Tu reserva está confirmada. ¡Te esperamos!'
: 'Your booking is confirmed. See you there!')
: isPending
? (locale === 'es'
? 'Estamos verificando tu pago. Esto puede tomar unos segundos.'
: 'We are verifying your payment. This may take a few seconds.')
: (locale === 'es'
? 'Hubo un problema con tu pago. Por favor, intenta de nuevo.'
: 'There was an issue with your payment. Please try again.')
}
</p>
{/* Polling indicator */}
{polling && isPending && (
<div className="flex items-center justify-center gap-2 text-yellow-600 mb-6">
<ArrowPathIcon className="w-5 h-5 animate-spin" />
<span className="text-sm">
{locale === 'es' ? 'Verificando...' : 'Checking...'}
</span>
</div>
)}
{/* Ticket Details */}
<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>
<div className="text-sm text-gray-600 space-y-2">
{ticket.event && (
<>
<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>
{/* Status Badge */}
<div className="mb-6">
<span className={`inline-block px-4 py-2 rounded-full text-sm font-medium ${
isPaid
? 'bg-green-100 text-green-800'
: isPending
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}>
{isPaid
? (locale === 'es' ? 'Confirmado' : 'Confirmed')
: isPending
? (locale === 'es' ? 'Pendiente de Pago' : 'Pending Payment')
: (locale === 'es' ? 'Pago Fallido' : 'Payment Failed')
}
</span>
</div>
{/* Email note */}
{isPaid && (
<p className="text-sm text-gray-500 mb-6">
{locale === 'es'
? 'Un correo de confirmación ha sido enviado a tu bandeja de entrada.'
: 'A confirmation email has been sent to your inbox.'}
</p>
)}
{/* Actions */}
<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>
);
}

View File

@@ -0,0 +1,153 @@
'use client';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
ChatBubbleLeftRightIcon,
CameraIcon,
UserGroupIcon,
HeartIcon
} from '@heroicons/react/24/outline';
import {
socialConfig,
getWhatsAppUrl,
getInstagramUrl,
getTelegramUrl
} from '@/lib/socialLinks';
export default function CommunityPage() {
const { t } = useLanguage();
// Get social URLs from environment config
const whatsappUrl = getWhatsAppUrl(socialConfig.whatsapp);
const instagramUrl = getInstagramUrl(socialConfig.instagram);
const telegramUrl = getTelegramUrl(socialConfig.telegram);
const guidelines = t('community.guidelines.items') as unknown as string[];
return (
<div className="section-padding">
<div className="container-page">
<div className="text-center max-w-2xl mx-auto">
<h1 className="section-title">{t('community.title')}</h1>
<p className="section-subtitle">{t('community.subtitle')}</p>
</div>
{/* Social Links */}
<div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{/* WhatsApp Card */}
{whatsappUrl && (
<Card className="p-8 text-center card-hover">
<div className="w-20 h-20 mx-auto bg-green-100 rounded-full flex items-center justify-center">
<svg className="w-10 h-10 text-green-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
</div>
<h3 className="mt-6 text-xl font-semibold">{t('community.whatsapp.title')}</h3>
<p className="mt-3 text-gray-600">{t('community.whatsapp.description')}</p>
<a
href={whatsappUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-6"
>
<Button>
{t('community.whatsapp.button')}
</Button>
</a>
</Card>
)}
{/* Telegram Card */}
{telegramUrl && (
<Card className="p-8 text-center card-hover">
<div className="w-20 h-20 mx-auto bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-10 h-10 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
</div>
<h3 className="mt-6 text-xl font-semibold">{t('community.telegram.title')}</h3>
<p className="mt-3 text-gray-600">{t('community.telegram.description')}</p>
<a
href={telegramUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-6"
>
<Button variant="outline">
{t('community.telegram.button')}
</Button>
</a>
</Card>
)}
{/* Instagram Card */}
{instagramUrl && (
<Card className="p-8 text-center card-hover">
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
<CameraIcon className="w-10 h-10 text-white" />
</div>
<h3 className="mt-6 text-xl font-semibold">{t('community.instagram.title')}</h3>
<p className="mt-3 text-gray-600">{t('community.instagram.description')}</p>
<a
href={instagramUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-6"
>
<Button variant="secondary">
{t('community.instagram.button')}
</Button>
</a>
</Card>
)}
</div>
{/* Guidelines */}
<div className="mt-20 max-w-3xl mx-auto">
<Card className="p-8">
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 bg-primary-yellow/20 rounded-full flex items-center justify-center">
<HeartIcon className="w-6 h-6 text-primary-dark" />
</div>
<h2 className="text-2xl font-bold">{t('community.guidelines.title')}</h2>
</div>
<ul className="space-y-4">
{Array.isArray(guidelines) && guidelines.map((item, index) => (
<li key={index} className="flex items-start gap-3">
<span className="w-6 h-6 bg-primary-yellow rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
{index + 1}
</span>
<span className="text-gray-700">{item}</span>
</li>
))}
</ul>
</Card>
</div>
{/* Volunteer Section */}
<div className="mt-20 max-w-3xl mx-auto">
<Card className="p-8 bg-gradient-to-br from-primary-yellow/10 to-secondary-blue/10">
<div className="flex flex-col md:flex-row items-center gap-8">
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center shadow-card flex-shrink-0">
<UserGroupIcon className="w-12 h-12 text-primary-dark" />
</div>
<div className="text-center md:text-left">
<h2 className="text-2xl font-bold">{t('community.volunteer.title')}</h2>
<p className="mt-2 text-gray-600">{t('community.volunteer.description')}</p>
<Link href="/contact" className="inline-block mt-4">
<Button variant="outline">
{t('community.volunteer.button')}
</Button>
</Link>
</div>
</div>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { contactsApi } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline';
import { getSocialLinks, socialIcons, socialConfig } from '@/lib/socialLinks';
import toast from 'react-hot-toast';
export default function ContactPage() {
const { t } = useLanguage();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
message: '',
});
const socialLinks = getSocialLinks();
const emailLink = socialLinks.find(l => l.type === 'email');
const otherLinks = socialLinks.filter(l => l.type !== 'email');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await contactsApi.submit(formData);
toast.success(t('contact.success'));
setFormData({ name: '', email: '', message: '' });
} catch (error) {
toast.error(t('contact.error'));
} finally {
setLoading(false);
}
};
return (
<div className="section-padding">
<div className="container-page">
<div className="text-center max-w-2xl mx-auto">
<h1 className="section-title">{t('contact.title')}</h1>
<p className="section-subtitle">{t('contact.subtitle')}</p>
</div>
<div className="mt-16 grid grid-cols-1 lg:grid-cols-2 gap-12 max-w-5xl mx-auto">
{/* Contact Form */}
<Card className="p-8">
<form onSubmit={handleSubmit} className="space-y-6">
<Input
id="name"
label={t('contact.form.name')}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<Input
id="email"
label={t('contact.form.email')}
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-primary-dark mb-1.5"
>
{t('contact.form.message')}
</label>
<textarea
id="message"
rows={5}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent placeholder:text-gray-400 resize-none"
required
minLength={10}
/>
</div>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{t('contact.form.submit')}
</Button>
</form>
</Card>
{/* Contact Info */}
<div className="space-y-6">
{/* Email Card */}
{emailLink && (
<Card className="p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
{socialIcons.email}
</div>
<div>
<h3 className="font-semibold text-lg">{t('contact.info.email')}</h3>
<a
href={emailLink.url}
className="text-secondary-blue hover:underline"
>
{emailLink.handle}
</a>
</div>
</div>
</Card>
)}
{/* Social Links Card */}
{otherLinks.length > 0 && (
<Card className="p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
<ChatBubbleLeftRightIcon className="w-6 h-6 text-primary-dark" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-lg">{t('contact.info.social')}</h3>
<div className="mt-3 space-y-3">
{otherLinks.map((link) => (
<a
key={link.type}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 text-secondary-blue hover:text-primary-dark transition-colors group"
>
<span className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-100 group-hover:bg-primary-yellow/20 transition-colors">
{socialIcons[link.type]}
</span>
<span className="hover:underline">{link.handle || link.label}</span>
</a>
))}
</div>
</div>
</div>
</Card>
)}
{/* Map placeholder */}
<Card className="h-64 bg-gradient-to-br from-secondary-gray to-secondary-light-gray flex items-center justify-center">
<div className="text-center text-gray-400">
<div className="text-4xl mb-2">📍</div>
<p>Asunción, Paraguay</p>
</div>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,207 @@
'use client';
import { useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { UserPayment } from '@/lib/api';
interface PaymentsTabProps {
payments: UserPayment[];
language: string;
}
export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
const [filter, setFilter] = useState<'all' | 'paid' | 'pending'>('all');
const filteredPayments = payments.filter((payment) => {
if (filter === 'all') return true;
if (filter === 'paid') return payment.status === 'paid';
if (filter === 'pending') return payment.status !== 'paid' && payment.status !== 'refunded';
return true;
});
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const formatCurrency = (amount: number, currency: string = 'PYG') => {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
paid: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
pending_approval: 'bg-orange-100 text-orange-800',
refunded: 'bg-purple-100 text-purple-800',
failed: 'bg-red-100 text-red-800',
};
const labels: Record<string, Record<string, string>> = {
en: {
paid: 'Paid',
pending: 'Pending',
pending_approval: 'Awaiting Approval',
refunded: 'Refunded',
failed: 'Failed',
},
es: {
paid: 'Pagado',
pending: 'Pendiente',
pending_approval: 'Esperando Aprobación',
refunded: 'Reembolsado',
failed: 'Fallido',
},
};
return (
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
{labels[language]?.[status] || status}
</span>
);
};
const getProviderLabel = (provider: string) => {
const labels: Record<string, Record<string, string>> = {
en: {
lightning: 'Lightning (Bitcoin)',
cash: 'Cash',
bank_transfer: 'Bank Transfer',
tpago: 'TPago',
bancard: 'Card',
},
es: {
lightning: 'Lightning (Bitcoin)',
cash: 'Efectivo',
bank_transfer: 'Transferencia Bancaria',
tpago: 'TPago',
bancard: 'Tarjeta',
},
};
return labels[language]?.[provider] || provider;
};
// Summary calculations
const totalPaid = payments
.filter((p) => p.status === 'paid')
.reduce((sum, p) => sum + Number(p.amount), 0);
const totalPending = payments
.filter((p) => p.status !== 'paid' && p.status !== 'refunded')
.reduce((sum, p) => sum + Number(p.amount), 0);
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-4">
<Card className="p-4">
<p className="text-sm text-gray-600 mb-1">
{language === 'es' ? 'Total Pagado' : 'Total Paid'}
</p>
<p className="text-2xl font-bold text-green-600">
{formatCurrency(totalPaid)}
</p>
</Card>
<Card className="p-4">
<p className="text-sm text-gray-600 mb-1">
{language === 'es' ? 'Pendiente' : 'Pending'}
</p>
<p className="text-2xl font-bold text-yellow-600">
{formatCurrency(totalPending)}
</p>
</Card>
</div>
{/* Filter Buttons */}
<div className="flex gap-2">
{(['all', 'paid', 'pending'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-secondary-blue text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{f === 'all' && (language === 'es' ? 'Todos' : 'All')}
{f === 'paid' && (language === 'es' ? 'Pagados' : 'Paid')}
{f === 'pending' && (language === 'es' ? 'Pendientes' : 'Pending')}
</button>
))}
</div>
{/* Payments List */}
{filteredPayments.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{language === 'es' ? 'No hay pagos que mostrar' : 'No payments to display'}
</p>
</Card>
) : (
<div className="space-y-3">
{filteredPayments.map((payment) => (
<Card key={payment.id} className="p-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold">
{formatCurrency(Number(payment.amount), payment.currency)}
</span>
{getStatusBadge(payment.status)}
</div>
<div className="text-sm text-gray-600 space-y-1">
{payment.event && (
<p>
{language === 'es' && payment.event.titleEs
? payment.event.titleEs
: payment.event.title}
</p>
)}
<p>
<span className="font-medium">
{language === 'es' ? 'Método:' : 'Method:'}
</span>{' '}
{getProviderLabel(payment.provider)}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Fecha:' : 'Date:'}
</span>{' '}
{formatDate(payment.createdAt)}
</p>
{payment.reference && (
<p>
<span className="font-medium">
{language === 'es' ? 'Referencia:' : 'Reference:'}
</span>{' '}
{payment.reference}
</p>
)}
</div>
</div>
{payment.invoice && (
<a
href={payment.invoice.pdfUrl || '#'}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
</Button>
</a>
)}
</div>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import { useState, useEffect } from 'react';
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 { dashboardApi, UserProfile } from '@/lib/api';
import toast from 'react-hot-toast';
interface ProfileTabProps {
onUpdate?: () => void;
}
export default function ProfileTab({ onUpdate }: ProfileTabProps) {
const { locale: language } = useLanguage();
const { updateUser, user } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
phone: '',
languagePreference: '',
rucNumber: '',
});
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
try {
const res = await dashboardApi.getProfile();
setProfile(res.profile);
setFormData({
name: res.profile.name || '',
phone: res.profile.phone || '',
languagePreference: res.profile.languagePreference || '',
rucNumber: res.profile.rucNumber || '',
});
} catch (error) {
toast.error(language === 'es' ? 'Error al cargar perfil' : 'Failed to load profile');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const res = await dashboardApi.updateProfile(formData);
toast.success(language === 'es' ? 'Perfil actualizado' : 'Profile updated');
// Update auth context
if (user) {
updateUser({
...user,
name: formData.name,
phone: formData.phone,
languagePreference: formData.languagePreference,
rucNumber: formData.rucNumber,
});
}
if (onUpdate) onUpdate();
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error al actualizar' : 'Update failed'));
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
</div>
);
}
return (
<div className="max-w-2xl space-y-6">
{/* Account Info Card */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Información de la Cuenta' : 'Account Information'}
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between py-2 border-b">
<span className="text-gray-600">Email</span>
<span className="font-medium">{profile?.email}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-gray-600">
{language === 'es' ? 'Estado de Cuenta' : 'Account Status'}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${
profile?.accountStatus === 'active'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{profile?.accountStatus === 'active'
? (language === 'es' ? 'Activo' : 'Active')
: profile?.accountStatus}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-gray-600">
{language === 'es' ? 'Miembro Desde' : 'Member Since'}
</span>
<span className="font-medium">
{profile?.memberSince
? new Date(profile.memberSince).toLocaleDateString(
language === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric' }
)
: '-'}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">
{language === 'es' ? 'Días de Membresía' : 'Membership Days'}
</span>
<span className="font-medium">{profile?.membershipDays || 0}</span>
</div>
</div>
</Card>
{/* Edit Profile Form */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Editar Perfil' : 'Edit Profile'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
id="name"
label={language === 'es' ? 'Nombre' : 'Name'}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<Input
id="phone"
label={language === 'es' ? 'Teléfono' : 'Phone'}
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{language === 'es' ? 'Idioma Preferido' : 'Preferred Language'}
</label>
<select
value={formData.languagePreference}
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary-blue"
>
<option value="">{language === 'es' ? 'Seleccionar' : 'Select'}</option>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<Input
id="rucNumber"
label={language === 'es' ? 'Número de RUC (para facturas)' : 'RUC Number (for invoices)'}
value={formData.rucNumber}
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
placeholder={language === 'es' ? 'Opcional' : 'Optional'}
/>
<p className="text-xs text-gray-500 -mt-2">
{language === 'es'
? 'Tu número de RUC paraguayo para facturas fiscales'
: 'Your Paraguayan RUC number for fiscal invoices'}
</p>
<div className="pt-4">
<Button type="submit" isLoading={saving}>
{language === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
</div>
</form>
</Card>
{/* Email Change Notice */}
<Card className="p-6 bg-gray-50">
<h3 className="text-lg font-semibold mb-2">
{language === 'es' ? 'Cambiar Email' : 'Change Email'}
</h3>
<p className="text-sm text-gray-600 mb-4">
{language === 'es'
? 'Para cambiar tu dirección de email, por favor contacta al soporte.'
: 'To change your email address, please contact support.'}
</p>
<Button variant="outline" size="sm" disabled>
{language === 'es' ? 'Contactar Soporte' : 'Contact Support'}
</Button>
</Card>
</div>
);
}

View File

@@ -0,0 +1,380 @@
'use client';
import { useState, useEffect } from 'react';
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 { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
import toast from 'react-hot-toast';
export default function SecurityTab() {
const { locale: language } = useLanguage();
const { logout } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
const [changingPassword, setChangingPassword] = useState(false);
const [settingPassword, setSettingPassword] = useState(false);
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [newPasswordForm, setNewPasswordForm] = useState({
password: '',
confirmPassword: '',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [profileRes, sessionsRes] = await Promise.all([
dashboardApi.getProfile(),
dashboardApi.getSessions(),
]);
setProfile(profileRes.profile);
setSessions(sessionsRes.sessions);
} catch (error) {
toast.error(language === 'es' ? 'Error al cargar datos' : 'Failed to load data');
} finally {
setLoading(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (passwordForm.newPassword.length < 10) {
toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters');
return;
}
setChangingPassword(true);
try {
await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword);
toast.success(language === 'es' ? 'Contraseña actualizada' : 'Password updated');
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password'));
} finally {
setChangingPassword(false);
}
};
const handleSetPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPasswordForm.password !== newPasswordForm.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (newPasswordForm.password.length < 10) {
toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters');
return;
}
setSettingPassword(true);
try {
await dashboardApi.setPassword(newPasswordForm.password);
toast.success(language === 'es' ? 'Contraseña establecida' : 'Password set');
setNewPasswordForm({ password: '', confirmPassword: '' });
loadData(); // Reload to update profile
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error al establecer contraseña' : 'Failed to set password'));
} finally {
setSettingPassword(false);
}
};
const handleUnlinkGoogle = async () => {
if (!confirm(language === 'es'
? '¿Estás seguro de que quieres desvincular tu cuenta de Google?'
: 'Are you sure you want to unlink your Google account?'
)) {
return;
}
try {
await dashboardApi.unlinkGoogle();
toast.success(language === 'es' ? 'Google desvinculado' : 'Google unlinked');
loadData();
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
}
};
const handleRevokeSession = async (sessionId: string) => {
try {
await dashboardApi.revokeSession(sessionId);
setSessions(sessions.filter((s) => s.id !== sessionId));
toast.success(language === 'es' ? 'Sesión cerrada' : 'Session revoked');
} catch (error) {
toast.error(language === 'es' ? 'Error' : 'Failed');
}
};
const handleRevokeAllSessions = async () => {
if (!confirm(language === 'es'
? '¿Cerrar todas las sesiones? Serás desconectado.'
: 'Log out of all sessions? You will be logged out.'
)) {
return;
}
try {
await dashboardApi.revokeAllSessions();
toast.success(language === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
logout();
} catch (error) {
toast.error(language === 'es' ? 'Error' : 'Failed');
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
</div>
);
}
return (
<div className="max-w-2xl space-y-6">
{/* Authentication Methods */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Métodos de Autenticación' : 'Authentication Methods'}
</h3>
<div className="space-y-4">
{/* Password Status */}
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
}`}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className="font-medium">
{language === 'es' ? 'Contraseña' : 'Password'}
</p>
<p className="text-sm text-gray-500">
{profile?.hasPassword
? (language === 'es' ? 'Configurada' : 'Set')
: (language === 'es' ? 'No configurada' : 'Not set')}
</p>
</div>
</div>
{profile?.hasPassword && (
<span className="text-green-600 text-sm font-medium">
{language === 'es' ? 'Activo' : 'Active'}
</span>
)}
</div>
{/* Google Status */}
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
}`}>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
</div>
<div>
<p className="font-medium">Google</p>
<p className="text-sm text-gray-500">
{profile?.hasGoogleLinked
? (language === 'es' ? 'Vinculado' : 'Linked')
: (language === 'es' ? 'No vinculado' : 'Not linked')}
</p>
</div>
</div>
{profile?.hasGoogleLinked && profile?.hasPassword && (
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
{language === 'es' ? 'Desvincular' : 'Unlink'}
</Button>
)}
</div>
</div>
</Card>
{/* Password Management */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{profile?.hasPassword
? (language === 'es' ? 'Cambiar Contraseña' : 'Change Password')
: (language === 'es' ? 'Establecer Contraseña' : 'Set Password')}
</h3>
{profile?.hasPassword ? (
<form onSubmit={handleChangePassword} className="space-y-4">
<Input
id="currentPassword"
type="password"
label={language === 'es' ? 'Contraseña Actual' : 'Current Password'}
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
required
/>
<Input
id="newPassword"
type="password"
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
required
/>
<p className="text-xs text-gray-500 -mt-2">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmPassword"
type="password"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={changingPassword}>
{language === 'es' ? 'Cambiar Contraseña' : 'Change Password'}
</Button>
</form>
) : (
<form onSubmit={handleSetPassword} className="space-y-4">
<p className="text-sm text-gray-600 mb-4">
{language === 'es'
? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.'
: 'You currently sign in with Google. Set a password for more access options.'}
</p>
<Input
id="password"
type="password"
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
value={newPasswordForm.password}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
required
/>
<p className="text-xs text-gray-500 -mt-2">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmNewPassword"
type="password"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
value={newPasswordForm.confirmPassword}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={settingPassword}>
{language === 'es' ? 'Establecer Contraseña' : 'Set Password'}
</Button>
</form>
)}
</Card>
{/* Active Sessions */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">
{language === 'es' ? 'Sesiones Activas' : 'Active Sessions'}
</h3>
{sessions.length > 1 && (
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
{language === 'es' ? 'Cerrar Todas' : 'Logout All'}
</Button>
)}
</div>
{sessions.length === 0 ? (
<p className="text-sm text-gray-600">
{language === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
</p>
) : (
<div className="space-y-3">
{sessions.map((session, index) => (
<div
key={session.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium text-sm">
{session.userAgent
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '...' : '')
: (language === 'es' ? 'Dispositivo desconocido' : 'Unknown device')}
</p>
<p className="text-xs text-gray-500">
{language === 'es' ? 'Última actividad:' : 'Last active:'} {formatDate(session.lastActiveAt)}
{session.ipAddress && `${session.ipAddress}`}
</p>
</div>
{index !== 0 && (
<Button
variant="outline"
size="sm"
onClick={() => handleRevokeSession(session.id)}
>
{language === 'es' ? 'Cerrar' : 'Revoke'}
</Button>
)}
{index === 0 && (
<span className="text-xs text-green-600 font-medium">
{language === 'es' ? 'Esta sesión' : 'This session'}
</span>
)}
</div>
))}
</div>
)}
</Card>
{/* Danger Zone */}
<Card className="p-6 border-red-200 bg-red-50">
<h3 className="text-lg font-semibold text-red-800 mb-4">
{language === 'es' ? 'Zona de Peligro' : 'Danger Zone'}
</h3>
<p className="text-sm text-red-700 mb-4">
{language === 'es'
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
: 'If you want to delete your account, please contact support.'}
</p>
<Button variant="outline" size="sm" className="border-red-300 text-red-700 hover:bg-red-100" disabled>
{language === 'es' ? 'Eliminar Cuenta' : 'Delete Account'}
</Button>
</Card>
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { UserTicket } from '@/lib/api';
interface TicketsTabProps {
tickets: UserTicket[];
language: string;
}
export default function TicketsTab({ tickets, language }: TicketsTabProps) {
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('all');
const now = new Date();
const filteredTickets = tickets.filter((ticket) => {
if (filter === 'all') return true;
const eventDate = ticket.event?.startDatetime
? new Date(ticket.event.startDatetime)
: null;
if (filter === 'upcoming') return eventDate && eventDate > now;
if (filter === 'past') return eventDate && eventDate <= now;
return true;
});
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const formatCurrency = (amount: number, currency: string = 'PYG') => {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
confirmed: 'bg-green-100 text-green-800',
checked_in: 'bg-blue-100 text-blue-800',
pending: 'bg-yellow-100 text-yellow-800',
cancelled: 'bg-red-100 text-red-800',
};
const labels: Record<string, Record<string, string>> = {
en: {
confirmed: 'Confirmed',
checked_in: 'Checked In',
pending: 'Pending',
cancelled: 'Cancelled',
},
es: {
confirmed: 'Confirmado',
checked_in: 'Registrado',
pending: 'Pendiente',
cancelled: 'Cancelado',
},
};
return (
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
{labels[language]?.[status] || status}
</span>
);
};
return (
<div className="space-y-6">
{/* Filter Buttons */}
<div className="flex gap-2">
{(['all', 'upcoming', 'past'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-secondary-blue text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{f === 'all' && (language === 'es' ? 'Todas' : 'All')}
{f === 'upcoming' && (language === 'es' ? 'Próximas' : 'Upcoming')}
{f === 'past' && (language === 'es' ? 'Pasadas' : 'Past')}
</button>
))}
</div>
{/* Tickets List */}
{filteredTickets.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600 mb-4">
{language === 'es' ? 'No tienes entradas' : 'You have no tickets'}
</p>
<Link href="/events">
<Button>
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
</Button>
</Link>
</Card>
) : (
<div className="space-y-4">
{filteredTickets.map((ticket) => (
<Card key={ticket.id} className="p-4">
<div className="flex flex-col md:flex-row md:items-center gap-4">
{/* Event Image */}
{ticket.event?.bannerUrl && (
<div className="w-full md:w-32 h-24 rounded-lg overflow-hidden flex-shrink-0">
<img
src={ticket.event.bannerUrl}
alt={ticket.event.title}
className="w-full h-full object-cover"
/>
</div>
)}
{/* Ticket Info */}
<div className="flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold">
{language === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: ticket.event?.title || 'Event'}
</h3>
{getStatusBadge(ticket.status)}
</div>
<div className="mt-2 space-y-1 text-sm text-gray-600">
{ticket.event?.startDatetime && (
<p>
<span className="font-medium">
{language === 'es' ? 'Fecha:' : 'Date:'}
</span>{' '}
{formatDate(ticket.event.startDatetime)}
</p>
)}
{ticket.event?.location && (
<p>
<span className="font-medium">
{language === 'es' ? 'Lugar:' : 'Location:'}
</span>{' '}
{ticket.event.location}
</p>
)}
{ticket.payment && (
<p>
<span className="font-medium">
{language === 'es' ? 'Pago:' : 'Payment:'}
</span>{' '}
{formatCurrency(ticket.payment.amount, ticket.payment.currency)} -
<span className={`ml-1 ${
ticket.payment.status === 'paid' ? 'text-green-600' : 'text-yellow-600'
}`}>
{ticket.payment.status === 'paid'
? (language === 'es' ? 'Pagado' : 'Paid')
: (language === 'es' ? 'Pendiente' : 'Pending')}
</span>
</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<Link href={`/booking/success/${ticket.id}`}>
<Button size="sm" className="w-full">
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
</Button>
</Link>
{ticket.invoice && (
<a
href={ticket.invoice.pdfUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="text-center"
>
<Button variant="outline" size="sm" className="w-full">
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
</Button>
</a>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,410 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
import toast from 'react-hot-toast';
import Link from 'next/link';
// Tab components
import TicketsTab from './components/TicketsTab';
import PaymentsTab from './components/PaymentsTab';
import ProfileTab from './components/ProfileTab';
import SecurityTab from './components/SecurityTab';
type Tab = 'overview' | 'tickets' | 'payments' | 'profile' | 'security';
export default function DashboardPage() {
const router = useRouter();
const { t, locale: language } = useLanguage();
const { user, isLoading: authLoading, token } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
const [tickets, setTickets] = useState<UserTicket[]>([]);
const [payments, setPayments] = useState<UserPayment[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
return;
}
if (user && token) {
loadDashboardData();
}
}, [user, authLoading, token]);
const loadDashboardData = async () => {
setLoading(true);
try {
const [summaryRes, nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
dashboardApi.getSummary(),
dashboardApi.getNextEvent(),
dashboardApi.getTickets(),
dashboardApi.getPayments(),
]);
setSummary(summaryRes.summary);
setNextEvent(nextEventRes.nextEvent);
setTickets(ticketsRes.tickets);
setPayments(paymentsRes.payments);
} catch (error: any) {
console.error('Failed to load dashboard:', error);
toast.error('Failed to load dashboard data');
} finally {
setLoading(false);
}
};
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: language === 'es' ? 'Resumen' : 'Overview' },
{ id: 'tickets', label: language === 'es' ? 'Mis Entradas' : 'My Tickets' },
{ id: 'payments', label: language === 'es' ? 'Pagos y Facturas' : 'Payments & Invoices' },
{ id: 'profile', label: language === 'es' ? 'Perfil' : 'Profile' },
{ id: 'security', label: language === 'es' ? 'Seguridad' : 'Security' },
];
if (authLoading || !user) {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
</div>
);
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(language === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="section-padding min-h-[70vh]">
<div className="container-page">
{/* Welcome Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">
{language === 'es' ? `Hola, ${user.name}!` : `Welcome, ${user.name}!`}
</h1>
{summary && (
<p className="text-gray-600">
{language === 'es'
? `Miembro desde hace ${summary.user.membershipDays} días`
: `Member for ${summary.user.membershipDays} days`
}
</p>
)}
</div>
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex gap-4 -mb-px overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-secondary-blue text-secondary-blue'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
</div>
) : (
<>
{activeTab === 'overview' && (
<OverviewTab
summary={summary}
nextEvent={nextEvent}
tickets={tickets}
language={language}
formatDate={formatDate}
formatTime={formatTime}
/>
)}
{activeTab === 'tickets' && (
<TicketsTab tickets={tickets} language={language} />
)}
{activeTab === 'payments' && (
<PaymentsTab payments={payments} language={language} />
)}
{activeTab === 'profile' && (
<ProfileTab onUpdate={loadDashboardData} />
)}
{activeTab === 'security' && (
<SecurityTab />
)}
</>
)}
</div>
</div>
);
}
// Overview Tab Component
function OverviewTab({
summary,
nextEvent,
tickets,
language,
formatDate,
formatTime,
}: {
summary: DashboardSummary | null;
nextEvent: NextEventInfo | null;
tickets: UserTicket[];
language: string;
formatDate: (date: string) => string;
formatTime: (date: string) => string;
}) {
return (
<div className="space-y-6">
{/* Stats Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-secondary-blue">{summary.stats.totalTickets}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Total Entradas' : 'Total Tickets'}
</div>
</Card>
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-green-600">{summary.stats.confirmedTickets}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Confirmadas' : 'Confirmed'}
</div>
</Card>
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-purple-600">{summary.stats.upcomingEvents}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Próximos' : 'Upcoming'}
</div>
</Card>
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-orange-500">{summary.stats.pendingPayments}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Pagos Pendientes' : 'Pending Payments'}
</div>
</Card>
</div>
)}
{/* Next Event Card */}
{nextEvent ? (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Tu Próximo Evento' : 'Your Next Event'}
</h3>
<div className="flex flex-col md:flex-row gap-6">
{nextEvent.event.bannerUrl && (
<div className="w-full md:w-48 h-32 rounded-lg overflow-hidden">
<img
src={nextEvent.event.bannerUrl}
alt={nextEvent.event.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex-1">
<h4 className="text-xl font-bold mb-2">
{language === 'es' && nextEvent.event.titleEs
? nextEvent.event.titleEs
: nextEvent.event.title}
</h4>
<div className="space-y-2 text-gray-600">
<p>
<span className="font-medium">
{language === 'es' ? 'Fecha:' : 'Date:'}
</span>{' '}
{formatDate(nextEvent.event.startDatetime)}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Hora:' : 'Time:'}
</span>{' '}
{formatTime(nextEvent.event.startDatetime)}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Lugar:' : 'Location:'}
</span>{' '}
{nextEvent.event.location}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Estado:' : 'Status:'}
</span>{' '}
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
nextEvent.payment?.status === 'paid'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{nextEvent.payment?.status === 'paid'
? (language === 'es' ? 'Pagado' : 'Paid')
: (language === 'es' ? 'Pendiente' : 'Pending')}
</span>
</p>
</div>
<div className="mt-4 flex gap-2">
<Link href={`/booking/success/${nextEvent.ticket.id}`}>
<Button size="sm">
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
</Button>
</Link>
{nextEvent.event.locationUrl && (
<a
href={nextEvent.event.locationUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
{language === 'es' ? 'Ver Mapa' : 'View Map'}
</Button>
</a>
)}
</div>
</div>
</div>
</Card>
) : (
<Card className="p-6 text-center">
<p className="text-gray-600 mb-4">
{language === 'es'
? 'No tienes eventos próximos'
: 'You have no upcoming events'}
</p>
<Link href="/events">
<Button>
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
</Button>
</Link>
</Card>
)}
{/* Recent Tickets */}
{tickets.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Entradas Recientes' : 'Recent Tickets'}
</h3>
<div className="space-y-3">
{tickets.slice(0, 3).map((ticket) => (
<div
key={ticket.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium">
{language === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: ticket.event?.title || 'Event'}
</p>
<p className="text-sm text-gray-600">
{ticket.event?.startDatetime
? formatDate(ticket.event.startDatetime)
: ''}
</p>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
ticket.status === 'confirmed' || ticket.status === 'checked_in'
? 'bg-green-100 text-green-800'
: ticket.status === 'cancelled'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{ticket.status}
</span>
</div>
))}
</div>
{tickets.length > 3 && (
<div className="mt-4 text-center">
<Button variant="outline" size="sm" onClick={() => {}}>
{language === 'es' ? 'Ver Todas' : 'View All'}
</Button>
</div>
)}
</Card>
)}
{/* Community Links */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Comunidad' : 'Community'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<a
href="https://wa.me/your-number"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-green-50 rounded-lg hover:bg-green-100 transition-colors"
>
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
</div>
<span className="font-medium">WhatsApp Group</span>
</a>
<a
href="https://instagram.com/spanglish"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-pink-50 rounded-lg hover:bg-pink-100 transition-colors"
>
<div className="w-10 h-10 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
</div>
<span className="font-medium">Instagram</span>
</a>
<Link
href="/community"
className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors"
>
<div className="w-10 h-10 bg-secondary-blue rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<span className="font-medium">
{language === 'es' ? 'Comunidad' : 'Community Page'}
</span>
</Link>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,213 @@
'use client';
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';
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);
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',
});
};
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) {
return null;
}
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}
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>
);
}

View File

@@ -0,0 +1,165 @@
'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 { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
export default function EventsPage() {
const { t, locale } = useLanguage();
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
useEffect(() => {
eventsApi.getAll()
.then(({ events }) => setEvents(events))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const now = new Date();
const upcomingEvents = events.filter(e =>
e.status === 'published' && new Date(e.startDatetime) >= now
);
const pastEvents = events.filter(e =>
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
);
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusBadge = (event: Event) => {
if (event.status === 'cancelled') {
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
}
if (event.availableSeats === 0) {
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
}
return null;
};
return (
<div className="section-padding">
<div className="container-page">
<h1 className="section-title">{t('events.title')}</h1>
{/* Filter tabs */}
<div className="mt-8 flex gap-2">
<button
onClick={() => setFilter('upcoming')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'upcoming'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.upcoming')} ({upcomingEvents.length})
</button>
<button
onClick={() => setFilter('past')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'past'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.past')} ({pastEvents.length})
</button>
</div>
{/* Events grid */}
<div className="mt-8">
{loading ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div>
) : displayedEvents.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg">{t('events.noEvents')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedEvents.map((event) => (
<Link key={event.id} href={`/events/${event.id}`} className="block">
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
{/* Event banner */}
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={event.title}
className="h-40 w-full object-cover"
/>
) : (
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
</div>
)}
<div className="p-6">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-lg text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h3>
{getStatusBadge(event)}
</div>
<div className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span>{formatDate(event.startDatetime)} - {formatTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-2">
<MapPinIcon className="w-4 h-4" />
<span className="truncate">{event.location}</span>
</div>
<div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" />
<span>
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</span>
</div>
</div>
<div className="mt-6 flex items-center justify-between">
<span className="font-bold text-xl text-primary-dark">
{event.price === 0
? t('events.details.free')
: `${event.price.toLocaleString()} ${event.currency}`}
</span>
<Button size="sm">
{t('common.moreInfo')}
</Button>
</div>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import { useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import Card from '@/components/ui/Card';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
interface FAQItem {
question: string;
questionEs: string;
answer: string;
answerEs: string;
}
const faqs: FAQItem[] = [
{
question: "What is Spanglish?",
questionEs: "¿Qué es Spanglish?",
answer: "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.",
answerEs: "Spanglish es una comunidad de intercambio de idiomas en Asunción, Paraguay. Organizamos eventos mensuales donde hablantes de español e inglés se reúnen para practicar idiomas, conocer gente nueva y divertirse en un ambiente social relajado."
},
{
question: "Who can attend Spanglish events?",
questionEs: "¿Quién puede asistir a los eventos de Spanglish?",
answer: "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.",
answerEs: "¡Cualquier persona interesada en practicar inglés o español es bienvenida! Aceptamos todos los niveles - desde principiantes hasta hablantes nativos. Nuestros eventos están diseñados para ser inclusivos y acogedores para todos."
},
{
question: "How do events work?",
questionEs: "¿Cómo funcionan los eventos?",
answer: "Our events typically last 2-3 hours. You'll 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.",
answerEs: "Nuestros eventos suelen durar 2-3 horas. Serás emparejado con personas que hablan el idioma que quieres practicar. Rotamos parejas durante la noche para que puedas conocer a varias personas. También hay actividades grupales y tiempo de conversación libre."
},
{
question: "How much does it cost to attend?",
questionEs: "¿Cuánto cuesta asistir?",
answer: "Event prices vary but are always kept affordable. The price covers venue costs and event organization. Check each event page for specific pricing. Some special events may be free!",
answerEs: "Los precios de los eventos varían pero siempre se mantienen accesibles. El precio cubre los costos del local y la organización del evento. Consulta la página de cada evento para precios específicos. ¡Algunos eventos especiales pueden ser gratis!"
},
{
question: "What payment methods do you accept?",
questionEs: "¿Qué métodos de pago aceptan?",
answer: "We accept multiple payment methods: credit/debit cards through Bancard, Bitcoin Lightning for crypto enthusiasts, and cash payment at the event. You can choose your preferred method when booking.",
answerEs: "Aceptamos múltiples métodos de pago: tarjetas de crédito/débito a través de Bancard, Bitcoin Lightning para entusiastas de cripto, y pago en efectivo en el evento. Puedes elegir tu método preferido al reservar."
},
{
question: "Do I need to speak the language already?",
questionEs: "¿Necesito ya hablar el idioma?",
answer: "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. It's a judgment-free zone for learning.",
answerEs: "¡Para nada! Damos la bienvenida a principiantes absolutos. Nuestros eventos están estructurados para apoyar todos los niveles. Los hablantes nativos son pacientes y felices de ayudar a los principiantes a practicar. Es una zona libre de juicios para aprender."
},
{
question: "Can I come alone?",
questionEs: "¿Puedo ir solo/a?",
answer: "Absolutely! Most people come alone and that's totally fine. In fact, it's a great way to meet new people. Our events are designed to be social, so you'll quickly find conversation partners.",
answerEs: "¡Absolutamente! La mayoría de las personas vienen solas y eso está totalmente bien. De hecho, es una excelente manera de conocer gente nueva. Nuestros eventos están diseñados para ser sociales, así que encontrarás compañeros de conversación rápidamente."
},
{
question: "What if I can't make it after booking?",
questionEs: "¿Qué pasa si no puedo asistir después de reservar?",
answer: "If you can't attend, please let us know as soon as possible so we can offer your spot to someone on the waitlist. Contact us through the website or WhatsApp group to cancel your booking.",
answerEs: "Si no puedes asistir, por favor avísanos lo antes posible para poder ofrecer tu lugar a alguien en la lista de espera. Contáctanos a través del sitio web o el grupo de WhatsApp para cancelar tu reserva."
},
{
question: "How can I stay updated about events?",
questionEs: "¿Cómo puedo mantenerme actualizado sobre los eventos?",
answer: "Join our WhatsApp group for instant updates, follow us on Instagram for announcements and photos, or subscribe to our newsletter on the website. We typically announce events 2-3 weeks in advance.",
answerEs: "Únete a nuestro grupo de WhatsApp para actualizaciones instantáneas, síguenos en Instagram para anuncios y fotos, o suscríbete a nuestro boletín en el sitio web. Normalmente anunciamos eventos con 2-3 semanas de anticipación."
},
{
question: "Can I volunteer or help organize events?",
questionEs: "¿Puedo ser voluntario o ayudar a organizar eventos?",
answer: "Yes! We're always looking for enthusiastic volunteers. Volunteers help with setup, greeting newcomers, facilitating activities, and more. Contact us through the website if you're interested in getting involved.",
answerEs: "¡Sí! Siempre estamos buscando voluntarios entusiastas. Los voluntarios ayudan con la preparación, saludar a los recién llegados, facilitar actividades y más. Contáctanos a través del sitio web si estás interesado en participar."
}
];
export default function FAQPage() {
const { t, locale } = useLanguage();
const [openIndex, setOpenIndex] = useState<number | null>(null);
const toggleFAQ = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<div className="section-padding">
<div className="container-page max-w-3xl">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-primary-dark mb-4">
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
: 'Find answers to the most common questions about Spanglish'}
</p>
</div>
<div className="space-y-4">
{faqs.map((faq, index) => (
<Card key={index} className="overflow-hidden">
<button
onClick={() => toggleFAQ(index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-primary-dark pr-4">
{locale === 'es' ? faq.questionEs : faq.question}
</span>
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
openIndex === index && 'transform rotate-180'
)}
/>
</button>
<div
className={clsx(
'overflow-hidden transition-all duration-200',
openIndex === index ? 'max-h-96' : 'max-h-0'
)}
>
<div className="px-6 pb-4 text-gray-600">
{locale === 'es' ? faq.answerEs : faq.answer}
</div>
</div>
</Card>
))}
</div>
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
<h2 className="text-xl font-semibold text-primary-dark mb-2">
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
</h2>
<p className="text-gray-600 mb-4">
{locale === 'es'
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
: "Don't hesitate to reach out. We're here to help!"}
</p>
<a
href="/contact"
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"
>
{locale === 'es' ? 'Contáctanos' : 'Contact Us'}
</a>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer';
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { notFound } from 'next/navigation';
import { Metadata } from 'next';
import { getLegalPage, getAllLegalSlugs } from '@/lib/legal';
import LegalPageLayout from '@/components/layout/LegalPageLayout';
interface PageProps {
params: { slug: string };
}
// Generate static params for all legal pages
export async function generateStaticParams() {
const slugs = getAllLegalSlugs();
return slugs.map((slug) => ({ slug }));
}
// Generate metadata for SEO
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const legalPage = getLegalPage(params.slug);
if (!legalPage) {
return {
title: 'Not Found',
};
}
return {
title: `${legalPage.title} | Spanglish`,
description: `${legalPage.title} for Spanglish - Language Exchange Community in Paraguay`,
};
}
export default function LegalPage({ params }: PageProps) {
const legalPage = getLegalPage(params.slug);
if (!legalPage) {
notFound();
}
return (
<LegalPageLayout
title={legalPage.title}
content={legalPage.content}
lastUpdated={legalPage.lastUpdated}
/>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import {
CalendarIcon,
MapPinIcon,
ChatBubbleLeftRightIcon,
} from '@heroicons/react/24/outline';
export default function LinktreePage() {
const { t, locale } = useLanguage();
const [nextEvent, setNextEvent] = useState<Event | null>(null);
const [loading, setLoading] = useState(true);
// Social links from environment variables
const whatsappLink = process.env.NEXT_PUBLIC_WHATSAPP;
const instagramHandle = process.env.NEXT_PUBLIC_INSTAGRAM;
const telegramHandle = process.env.NEXT_PUBLIC_TELEGRAM;
useEffect(() => {
eventsApi.getNextUpcoming()
.then(({ event }) => setNextEvent(event))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
// Handle both full URLs and handles
const instagramUrl = instagramHandle
? (instagramHandle.startsWith('http') ? instagramHandle : `https://instagram.com/${instagramHandle.replace('@', '')}`)
: null;
const telegramUrl = telegramHandle
? (telegramHandle.startsWith('http') ? telegramHandle : `https://t.me/${telegramHandle.replace('@', '')}`)
: null;
return (
<div className="min-h-screen bg-gradient-to-b from-primary-dark via-gray-900 to-primary-dark">
<div className="max-w-md mx-auto px-4 py-8 pb-16">
{/* Profile Header */}
<div className="text-center mb-8">
<div className="w-24 h-24 mx-auto bg-primary-yellow rounded-full flex items-center justify-center mb-4 shadow-lg">
<ChatBubbleLeftRightIcon className="w-12 h-12 text-primary-dark" />
</div>
<h1 className="text-2xl font-bold text-white">Spanglish</h1>
<p className="text-gray-400 mt-1">{t('linktree.tagline')}</p>
</div>
{/* Next Event Card */}
<div className="mb-6">
<h2 className="text-sm font-semibold text-primary-yellow uppercase tracking-wider mb-3 text-center">
{t('linktree.nextEvent')}
</h2>
{loading ? (
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 text-center">
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div>
) : nextEvent ? (
<Link href={`/book/${nextEvent.id}`} className="block group">
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 transition-all duration-300 hover:bg-white/15 hover:scale-[1.02] hover:shadow-xl">
<h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors">
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
</h3>
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-gray-300 text-sm">
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span>{formatDate(nextEvent.startDatetime)} {formatTime(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-2 text-gray-300 text-sm">
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span className="truncate">{nextEvent.location}</span>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<span className="font-bold text-primary-yellow">
{nextEvent.price === 0
? t('events.details.free')
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
</span>
<span className="text-sm text-gray-400">
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
</span>
</div>
<div className="mt-4 bg-primary-yellow text-primary-dark font-semibold py-3 px-4 rounded-xl text-center transition-all duration-200 group-hover:bg-yellow-400">
{t('linktree.bookNow')}
</div>
</div>
</Link>
) : (
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 text-center text-gray-400">
<CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-50" />
<p>{t('linktree.noEvents')}</p>
</div>
)}
</div>
{/* Social Links */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-primary-yellow uppercase tracking-wider mb-3 text-center">
{t('linktree.joinCommunity')}
</h2>
{/* WhatsApp */}
{whatsappLink && (
<a
href={whatsappLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 bg-white/10 backdrop-blur-sm rounded-2xl p-4 border border-white/10 transition-all duration-300 hover:bg-[#25D366]/20 hover:border-[#25D366]/30 hover:scale-[1.02] group"
>
<div className="w-12 h-12 bg-[#25D366] rounded-xl flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-white group-hover:text-[#25D366] transition-colors">
{t('linktree.whatsapp.title')}
</p>
<p className="text-sm text-gray-400">{t('linktree.whatsapp.subtitle')}</p>
</div>
<svg className="w-5 h-5 text-gray-400 group-hover:text-[#25D366] transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
)}
{/* Telegram */}
{telegramUrl && (
<a
href={telegramUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 bg-white/10 backdrop-blur-sm rounded-2xl p-4 border border-white/10 transition-all duration-300 hover:bg-[#0088cc]/20 hover:border-[#0088cc]/30 hover:scale-[1.02] group"
>
<div className="w-12 h-12 bg-[#0088cc] rounded-xl flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-white group-hover:text-[#0088cc] transition-colors">
{t('linktree.telegram.title')}
</p>
<p className="text-sm text-gray-400">{t('linktree.telegram.subtitle')}</p>
</div>
<svg className="w-5 h-5 text-gray-400 group-hover:text-[#0088cc] transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
)}
{/* Instagram */}
{instagramUrl && (
<a
href={instagramUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 bg-white/10 backdrop-blur-sm rounded-2xl p-4 border border-white/10 transition-all duration-300 hover:bg-[#E4405F]/20 hover:border-[#E4405F]/30 hover:scale-[1.02] group"
>
<div className="w-12 h-12 bg-gradient-to-br from-[#833AB4] via-[#E4405F] to-[#FCAF45] rounded-xl flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-white group-hover:text-[#E4405F] transition-colors">
{t('linktree.instagram.title')}
</p>
<p className="text-sm text-gray-400">{t('linktree.instagram.subtitle')}</p>
</div>
<svg className="w-5 h-5 text-gray-400 group-hover:text-[#E4405F] transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
)}
</div>
{/* Website Link */}
<div className="mt-6">
<Link
href="/"
className="flex items-center justify-center gap-2 bg-primary-yellow text-primary-dark font-semibold py-4 px-6 rounded-2xl transition-all duration-300 hover:bg-yellow-400 hover:scale-[1.02]"
>
<span>{t('linktree.visitWebsite')}</span>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
</div>
{/* Footer */}
<div className="mt-8 text-center">
<p className="text-gray-500 text-sm">
{t('footer.copyright', { year: new Date().getFullYear().toString() })}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,287 @@
'use client';
import { useState, useEffect, 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 { 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 [loading, setLoading] = useState(false);
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
const [magicLinkSent, setMagicLinkSent] = useState(false);
const [formData, setFormData] = useState({
email: '',
password: '',
});
// 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);
try {
await login(formData.email, formData.password);
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!');
router.push(redirectTo);
} catch (error: any) {
toast.error(error.message || t('auth.errors.invalidCredentials'));
} finally {
setLoading(false);
}
};
const handleMagicLinkRequest = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.email) {
toast.error(language === 'es' ? 'Ingresa tu email' : 'Please enter your email');
return;
}
setLoading(true);
try {
await authApi.requestMagicLink(formData.email);
setMagicLinkSent(true);
toast.success(
language === 'es'
? 'Revisa tu correo para el enlace de acceso'
: 'Check your email for the login link'
);
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
} finally {
setLoading(false);
}
};
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="container-page">
<div className="max-w-md mx-auto">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold">{t('auth.login.title')}</h1>
<p className="mt-2 text-gray-600">{t('auth.login.subtitle')}</p>
</div>
<Card className="p-8">
{/* Google Sign-In Button */}
<div id="google-signin-button" className="mb-4 flex justify-center"></div>
{/* 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 continuar con' : 'or continue with'}
</span>
</div>
</div>
{/* Login Mode Tabs */}
<div className="flex gap-2 mb-6">
<button
type="button"
onClick={() => setLoginMode('password')}
className={`flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-colors ${
loginMode === 'password'
? 'bg-secondary-blue text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{language === 'es' ? 'Contraseña' : 'Password'}
</button>
<button
type="button"
onClick={() => setLoginMode('magic-link')}
className={`flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-colors ${
loginMode === 'magic-link'
? 'bg-secondary-blue text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{language === 'es' ? 'Enlace por Email' : 'Email Link'}
</button>
</div>
{loginMode === 'password' ? (
<form onSubmit={handleSubmit} className="space-y-6">
<Input
id="email"
label={t('auth.login.email')}
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<Input
id="password"
label={t('auth.login.password')}
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
<div className="flex justify-end">
<Link
href="/auth/forgot-password"
className="text-sm text-secondary-blue hover:underline"
>
{language === 'es' ? '¿Olvidaste tu contraseña?' : 'Forgot password?'}
</Link>
</div>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{t('auth.login.submit')}
</Button>
</form>
) : magicLinkSent ? (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">
{language === 'es' ? 'Revisa tu Email' : 'Check Your Email'}
</h3>
<p className="text-gray-600 text-sm mb-4">
{language === 'es'
? `Enviamos un enlace de acceso a ${formData.email}`
: `We sent a login link to ${formData.email}`}
</p>
<button
onClick={() => setMagicLinkSent(false)}
className="text-secondary-blue hover:underline text-sm"
>
{language === 'es' ? 'Usar otro email' : 'Use a different email'}
</button>
</div>
) : (
<form onSubmit={handleMagicLinkRequest} className="space-y-6">
<Input
id="magic-email"
label={t('auth.login.email')}
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<p className="text-sm text-gray-500 -mt-4">
{language === 'es'
? 'Te enviaremos un enlace para iniciar sesión sin contraseña'
: "We'll send you a link to sign in without a password"}
</p>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{language === 'es' ? 'Enviar Enlace' : 'Send Login Link'}
</Button>
</form>
)}
<p className="mt-6 text-center text-sm text-gray-600">
{t('auth.login.noAccount')}{' '}
<Link href="/register" className="text-secondary-blue hover:underline font-medium">
{t('auth.login.register')}
</Link>
</p>
</Card>
</div>
</div>
</div>
</>
);
}
function LoadingFallback() {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<LoginContent />
</Suspense>
);
}

View File

@@ -0,0 +1,303 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, contactsApi, Event } from '@/lib/api';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Input from '@/components/ui/Input';
import {
CalendarIcon,
MapPinIcon,
UserGroupIcon,
ChatBubbleLeftRightIcon,
AcademicCapIcon,
SparklesIcon
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function HomePage() {
const { t, locale } = useLanguage();
const [nextEvent, setNextEvent] = useState<Event | null>(null);
const [loading, setLoading] = useState(true);
const [email, setEmail] = useState('');
const [subscribing, setSubscribing] = useState(false);
useEffect(() => {
eventsApi.getNextUpcoming()
.then(({ event }) => setNextEvent(event))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const handleSubscribe = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
setSubscribing(true);
try {
await contactsApi.subscribe(email);
toast.success(t('home.newsletter.success'));
setEmail('');
} catch (error) {
toast.error(t('home.newsletter.error'));
} finally {
setSubscribing(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',
});
};
return (
<>
{/* Hero Section */}
<section className="bg-gradient-to-br from-white via-secondary-gray to-white">
<div className="container-page py-16 md:py-24">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-primary-dark leading-tight text-balance">
{t('home.hero.title')}
</h1>
<p className="mt-6 text-xl text-gray-600">
{t('home.hero.subtitle')}
</p>
<div className="mt-8 flex flex-wrap gap-4">
<Link href="/events">
<Button size="lg">
{t('home.hero.cta')}
</Button>
</Link>
<Link href="/community">
<Button variant="outline" size="lg">
{t('common.learnMore')}
</Button>
</Link>
</div>
</div>
{/* 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.26.jpg"
alt="Language exchange event"
fill
sizes="(max-width: 1024px) 50vw, 25vw"
className="object-cover"
/>
<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"
fill
sizes="(max-width: 1024px) 50vw, 25vw"
className="object-cover"
/>
</div>
</div>
<div className="space-y-4 pt-8">
<div className="relative rounded-card h-48 overflow-hidden">
<Image
src="/images/2026-01-29 13.10.16.jpg"
alt="Community meetup"
fill
sizes="(max-width: 1024px) 50vw, 25vw"
className="object-cover"
/>
</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"
fill
sizes="(max-width: 1024px) 50vw, 25vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-secondary-brown/40" />
<UserGroupIcon className="relative z-10 w-16 h-16 text-secondary-brown opacity-70" />
</div>
</div>
</div>
{/* Decorative elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 bg-primary-yellow/30 rounded-full blur-2xl" />
<div className="absolute -bottom-4 -right-4 w-32 h-32 bg-secondary-blue/20 rounded-full blur-2xl" />
</div>
</div>
</div>
</section>
{/* Next Event Section */}
<section className="section-padding bg-white">
<div className="container-page">
<h2 className="section-title text-center">
{t('home.nextEvent.title')}
</h2>
<div className="mt-12 max-w-3xl mx-auto">
{loading ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div>
) : nextEvent ? (
<Link href={`/events/${nextEvent.id}`} className="block">
<Card variant="elevated" className="p-8 cursor-pointer hover:shadow-lg transition-shadow">
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-1">
<h3 className="text-2xl font-bold text-primary-dark">
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
</h3>
<p className="mt-3 text-gray-600">
{locale === 'es' && nextEvent.descriptionEs
? nextEvent.descriptionEs
: nextEvent.description}
</p>
<div className="mt-6 space-y-3">
<div className="flex items-center gap-3 text-gray-700">
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
<span>{formatDate(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-3 text-gray-700">
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
</span>
<span>{formatTime(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-3 text-gray-700">
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
<span>{nextEvent.location}</span>
</div>
</div>
</div>
<div className="flex flex-col justify-between items-start md:items-end">
<div className="text-right">
<span className="text-3xl font-bold text-primary-dark">
{nextEvent.price === 0
? t('events.details.free')
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
</span>
<p className="text-sm text-gray-500 mt-1">
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
</p>
</div>
<Button size="lg" className="mt-6">
{t('common.moreInfo')}
</Button>
</div>
</div>
</Card>
</Link>
) : (
<div className="text-center py-12 text-gray-500">
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg">{t('home.nextEvent.noEvents')}</p>
<p className="mt-2">{t('home.nextEvent.stayTuned')}</p>
</div>
)}
</div>
</div>
</section>
{/* About Section */}
<section className="section-padding bg-secondary-gray">
<div className="container-page">
<div className="text-center max-w-3xl mx-auto">
<h2 className="section-title">{t('home.about.title')}</h2>
<p className="section-subtitle">
{t('home.about.description')}
</p>
</div>
<div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8">
<Card className="p-8 text-center card-hover">
<div className="w-16 h-16 mx-auto bg-primary-yellow/20 rounded-full flex items-center justify-center">
<CalendarIcon className="w-8 h-8 text-primary-dark" />
</div>
<h3 className="mt-6 text-xl font-semibold">
{t('home.about.feature1')}
</h3>
<p className="mt-3 text-gray-600">
{t('home.about.feature1Desc')}
</p>
</Card>
<Card className="p-8 text-center card-hover">
<div className="w-16 h-16 mx-auto bg-primary-yellow/20 rounded-full flex items-center justify-center">
<ChatBubbleLeftRightIcon className="w-8 h-8 text-primary-dark" />
</div>
<h3 className="mt-6 text-xl font-semibold">
{t('home.about.feature2')}
</h3>
<p className="mt-3 text-gray-600">
{t('home.about.feature2Desc')}
</p>
</Card>
<Card className="p-8 text-center card-hover">
<div className="w-16 h-16 mx-auto bg-primary-yellow/20 rounded-full flex items-center justify-center">
<AcademicCapIcon className="w-8 h-8 text-primary-dark" />
</div>
<h3 className="mt-6 text-xl font-semibold">
{t('home.about.feature3')}
</h3>
<p className="mt-3 text-gray-600">
{t('home.about.feature3Desc')}
</p>
</Card>
</div>
</div>
</section>
{/* Newsletter Section */}
<section className="section-padding bg-primary-dark text-white">
<div className="container-page">
<div className="max-w-2xl mx-auto text-center">
<SparklesIcon className="w-12 h-12 mx-auto text-primary-yellow" />
<h2 className="mt-6 text-3xl md:text-4xl font-bold">
{t('home.newsletter.title')}
</h2>
<p className="mt-4 text-gray-300">
{t('home.newsletter.description')}
</p>
<form onSubmit={handleSubscribe} className="mt-8 flex flex-col sm:flex-row gap-4 max-w-md mx-auto">
<Input
type="email"
placeholder={t('home.newsletter.placeholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1 bg-white/10 border-white/20 text-white placeholder:text-gray-400"
required
/>
<Button type="submit" isLoading={subscribing}>
{t('home.newsletter.button')}
</Button>
</form>
</div>
</div>
</section>
</>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
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 toast from 'react-hot-toast';
export default function RegisterPage() {
const router = useRouter();
const { t } = useLanguage();
const { register } = useAuth();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
phone: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await register(formData);
toast.success('Account created successfully!');
router.push('/');
} catch (error: any) {
toast.error(error.message || t('auth.errors.emailExists'));
} finally {
setLoading(false);
}
};
return (
<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">
<h1 className="text-3xl font-bold">{t('auth.register.title')}</h1>
<p className="mt-2 text-gray-600">{t('auth.register.subtitle')}</p>
</div>
<Card className="p-8">
<form onSubmit={handleSubmit} className="space-y-6">
<Input
id="name"
label={t('auth.register.name')}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
minLength={2}
/>
<Input
id="email"
label={t('auth.register.email')}
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<Input
id="password"
label={t('auth.register.password')}
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
minLength={8}
/>
<Input
id="phone"
label={t('auth.register.phone')}
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{t('auth.register.submit')}
</Button>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
{t('auth.register.hasAccount')}{' '}
<Link href="/login" className="text-secondary-blue hover:underline font-medium">
{t('auth.register.login')}
</Link>
</p>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,426 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
TicketIcon,
CheckCircleIcon,
XCircleIcon,
CurrencyDollarIcon,
UserIcon,
EnvelopeIcon,
PhoneIcon,
FunnelIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
interface TicketWithDetails extends Omit<Ticket, 'payment'> {
event?: Event;
payment?: {
id: string;
ticketId?: string;
provider: string;
amount: number;
currency: string;
status: string;
reference?: string;
createdAt?: string;
updatedAt?: string;
};
}
export default function AdminBookingsPage() {
const { locale } = useLanguage();
const [tickets, setTickets] = useState<TicketWithDetails[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState<string | null>(null);
// Filters
const [selectedEvent, setSelectedEvent] = useState<string>('');
const [selectedStatus, setSelectedStatus] = useState<string>('');
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [ticketsRes, eventsRes] = await Promise.all([
ticketsApi.getAll(),
eventsApi.getAll(),
]);
// Fetch full ticket details with payment info
const ticketsWithDetails = await Promise.all(
ticketsRes.tickets.map(async (ticket) => {
try {
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id);
return fullTicket;
} catch {
return ticket;
}
})
);
setTickets(ticketsWithDetails);
setEvents(eventsRes.events);
} catch (error) {
toast.error('Failed to load bookings');
} finally {
setLoading(false);
}
};
const handleMarkPaid = async (ticketId: string) => {
setProcessing(ticketId);
try {
await ticketsApi.markPaid(ticketId);
toast.success('Payment marked as received');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to mark payment');
} finally {
setProcessing(null);
}
};
const handleCheckin = async (ticketId: string) => {
setProcessing(ticketId);
try {
await ticketsApi.checkin(ticketId);
toast.success('Check-in successful');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to check in');
} finally {
setProcessing(null);
}
};
const handleCancel = async (ticketId: string) => {
if (!confirm('Are you sure you want to cancel this booking?')) return;
setProcessing(ticketId);
try {
await ticketsApi.cancel(ticketId);
toast.success('Booking cancelled');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to cancel');
} finally {
setProcessing(null);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
case 'checked_in':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'paid':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed':
case 'cancelled':
return 'bg-red-100 text-red-800';
case 'refunded':
return 'bg-purple-100 text-purple-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getPaymentMethodLabel = (provider: string) => {
switch (provider) {
case 'bancard':
return 'TPago / Card';
case 'lightning':
return 'Bitcoin Lightning';
case 'cash':
return 'Cash at Event';
default:
return provider;
}
};
// Filter tickets
const filteredTickets = tickets.filter((ticket) => {
if (selectedEvent && ticket.eventId !== selectedEvent) return false;
if (selectedStatus && ticket.status !== selectedStatus) return false;
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
return true;
});
// Sort by created date (newest first)
const sortedTickets = [...filteredTickets].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
// Stats
const stats = {
total: tickets.length,
pending: tickets.filter(t => t.status === 'pending').length,
confirmed: tickets.filter(t => t.status === 'confirmed').length,
checkedIn: tickets.filter(t => t.status === 'checked_in').length,
cancelled: tickets.filter(t => t.status === 'cancelled').length,
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">Manage Bookings</h1>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-primary-dark">{stats.total}</p>
<p className="text-sm text-gray-500">Total</p>
</Card>
<Card className="p-4 text-center border-l-4 border-yellow-400">
<p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
<p className="text-sm text-gray-500">Pending</p>
</Card>
<Card className="p-4 text-center border-l-4 border-green-400">
<p className="text-2xl font-bold text-green-600">{stats.confirmed}</p>
<p className="text-sm text-gray-500">Confirmed</p>
</Card>
<Card className="p-4 text-center border-l-4 border-blue-400">
<p className="text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
<p className="text-sm text-gray-500">Checked In</p>
</Card>
<Card className="p-4 text-center border-l-4 border-red-400">
<p className="text-2xl font-bold text-red-600">{stats.cancelled}</p>
<p className="text-sm text-gray-500">Cancelled</p>
</Card>
<Card className="p-4 text-center border-l-4 border-orange-400">
<p className="text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
<p className="text-sm text-gray-500">Pending Payment</p>
</Card>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex items-center gap-2 mb-4">
<FunnelIcon className="w-5 h-5 text-gray-500" />
<span className="font-medium">Filters</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select
value={selectedEvent}
onChange={(e) => setSelectedEvent(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="checked_in">Checked In</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
<select
value={selectedPaymentStatus}
onChange={(e) => setSelectedPaymentStatus(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Payment Statuses</option>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
<option value="refunded">Refunded</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
</Card>
{/* Bookings List */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Payment</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{sortedTickets.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
No bookings found.
</td>
</tr>
) : (
sortedTickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4 text-gray-400" />
<span className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<EnvelopeIcon className="w-4 h-4" />
<span>{ticket.attendeeEmail || 'N/A'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<PhoneIcon className="w-4 h-4" />
<span>{ticket.attendeePhone || 'N/A'}</span>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className="text-sm">
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
</span>
</td>
<td className="px-6 py-4">
<div className="space-y-1">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
{ticket.payment?.status || 'pending'}
</span>
<p className="text-sm text-gray-500">
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
</p>
{ticket.payment && (
<p className="text-sm font-medium">
{ticket.payment.amount?.toLocaleString()} {ticket.payment.currency}
</p>
)}
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
{ticket.status}
</span>
{ticket.qrCode && (
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(ticket.createdAt)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{/* Mark as Paid (for pending payments) */}
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleMarkPaid(ticket.id)}
isLoading={processing === ticket.id}
className="text-green-600 hover:bg-green-50"
>
<CurrencyDollarIcon className="w-4 h-4 mr-1" />
Mark Paid
</Button>
)}
{/* Check-in (for confirmed tickets) */}
{ticket.status === 'confirmed' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleCheckin(ticket.id)}
isLoading={processing === ticket.id}
className="text-blue-600 hover:bg-blue-50"
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
Check In
</Button>
)}
{/* Cancel (for pending/confirmed) */}
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
<Button
size="sm"
variant="ghost"
onClick={() => handleCancel(ticket.id)}
isLoading={processing === ticket.id}
className="text-red-600 hover:bg-red-50"
>
<XCircleIcon className="w-4 h-4 mr-1" />
Cancel
</Button>
)}
{ticket.status === 'checked_in' && (
<span className="text-sm text-green-600 flex items-center gap-1">
<CheckCircleIcon className="w-4 h-4" />
Attended
</span>
)}
{ticket.status === 'cancelled' && (
<span className="text-sm text-gray-400">Cancelled</span>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { contactsApi, Contact } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function AdminContactsPage() {
const { t, locale } = useLanguage();
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string>('');
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
useEffect(() => {
loadContacts();
}, [statusFilter]);
const loadContacts = async () => {
try {
const { contacts } = await contactsApi.getAll(statusFilter || undefined);
setContacts(contacts);
} catch (error) {
toast.error('Failed to load contacts');
} finally {
setLoading(false);
}
};
const handleStatusChange = async (id: string, status: string) => {
try {
await contactsApi.updateStatus(id, status);
toast.success('Status updated');
loadContacts();
if (selectedContact?.id === id) {
setSelectedContact({ ...selectedContact, status: status as any });
}
} catch (error) {
toast.error('Failed to update status');
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
new: 'badge-info',
read: 'badge-warning',
replied: 'badge-success',
};
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.nav.contacts')}</h1>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">All</option>
<option value="new">New</option>
<option value="read">Read</option>
<option value="replied">Replied</option>
</select>
</div>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Messages List */}
<div className="lg:col-span-1">
<Card className="divide-y divide-secondary-light-gray max-h-[600px] overflow-y-auto">
{contacts.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<EnvelopeIcon className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>No messages</p>
</div>
) : (
contacts.map((contact) => (
<button
key={contact.id}
onClick={() => {
setSelectedContact(contact);
if (contact.status === 'new') {
handleStatusChange(contact.id, 'read');
}
}}
className={`w-full text-left p-4 hover:bg-gray-50 transition-colors ${
selectedContact?.id === contact.id ? 'bg-secondary-gray' : ''
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
{contact.status === 'new' ? (
<EnvelopeIcon className="w-4 h-4 text-secondary-blue" />
) : (
<EnvelopeOpenIcon className="w-4 h-4 text-gray-400" />
)}
<span className={`font-medium text-sm ${contact.status === 'new' ? 'text-primary-dark' : 'text-gray-600'}`}>
{contact.name}
</span>
</div>
{getStatusBadge(contact.status)}
</div>
<p className="mt-1 text-xs text-gray-500">{contact.email}</p>
<p className="mt-1 text-sm text-gray-600 truncate">{contact.message}</p>
<p className="mt-2 text-xs text-gray-400">{formatDate(contact.createdAt)}</p>
</button>
))
)}
</Card>
</div>
{/* Message Detail */}
<div className="lg:col-span-2">
<Card className="p-6 min-h-[400px]">
{selectedContact ? (
<div>
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-xl font-bold">{selectedContact.name}</h2>
<a
href={`mailto:${selectedContact.email}`}
className="text-secondary-blue hover:underline"
>
{selectedContact.email}
</a>
</div>
<div className="flex items-center gap-2">
{selectedContact.status !== 'replied' && (
<Button
size="sm"
variant="outline"
onClick={() => handleStatusChange(selectedContact.id, 'replied')}
>
<CheckIcon className="w-4 h-4 mr-1" />
Mark as Replied
</Button>
)}
<a href={`mailto:${selectedContact.email}`}>
<Button size="sm">
Reply
</Button>
</a>
</div>
</div>
<div className="border-t border-secondary-light-gray pt-6">
<p className="text-sm text-gray-500 mb-2">
Received: {formatDate(selectedContact.createdAt)}
</p>
<div className="prose prose-sm max-w-none">
<p className="whitespace-pre-wrap text-gray-700">{selectedContact.message}</p>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-500">
<div className="text-center">
<EnvelopeIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p>Select a message to view</p>
</div>
</div>
)}
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,961 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
EnvelopeIcon,
PencilIcon,
DocumentDuplicateIcon,
EyeIcon,
PaperAirplaneIcon,
ClockIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
type TabType = 'templates' | 'logs' | 'compose';
const DRAFT_STORAGE_KEY = 'spanglish-email-draft';
interface EmailDraft {
eventId: string;
templateSlug: string;
customSubject: string;
customBody: string;
recipientFilter: 'all' | 'confirmed' | 'pending' | 'checked_in';
savedAt: string;
}
export default function AdminEmailsPage() {
const { t, locale } = useLanguage();
const [activeTab, setActiveTab] = useState<TabType>('templates');
const [loading, setLoading] = useState(true);
// Templates state
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [showTemplateForm, setShowTemplateForm] = useState(false);
const [saving, setSaving] = useState(false);
// Logs state
const [logs, setLogs] = useState<EmailLog[]>([]);
const [logsOffset, setLogsOffset] = useState(0);
const [logsTotal, setLogsTotal] = useState(0);
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
// Stats state
const [stats, setStats] = useState<EmailStats | null>(null);
// Preview state
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
const [previewSubject, setPreviewSubject] = useState<string>('');
// Template form state
const [templateForm, setTemplateForm] = useState({
name: '',
slug: '',
subject: '',
subjectEs: '',
bodyHtml: '',
bodyHtmlEs: '',
bodyText: '',
bodyTextEs: '',
description: '',
isActive: true,
});
// Compose/Draft state
const [events, setEvents] = useState<any[]>([]);
const [composeForm, setComposeForm] = useState<EmailDraft>({
eventId: '',
templateSlug: '',
customSubject: '',
customBody: '',
recipientFilter: 'confirmed',
savedAt: '',
});
const [hasDraft, setHasDraft] = useState(false);
const [sending, setSending] = useState(false);
const [showRecipientPreview, setShowRecipientPreview] = useState(false);
const [previewRecipients, setPreviewRecipients] = useState<any[]>([]);
useEffect(() => {
loadData();
loadEvents();
loadDraft();
}, []);
const loadEvents = async () => {
try {
const res = await fetch('/api/events', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) {
const data = await res.json();
setEvents(data.events || []);
}
} catch (error) {
console.error('Failed to load events');
}
};
const loadDraft = () => {
try {
const saved = localStorage.getItem(DRAFT_STORAGE_KEY);
if (saved) {
const draft = JSON.parse(saved) as EmailDraft;
setComposeForm(draft);
setHasDraft(true);
}
} catch (error) {
console.error('Failed to load draft');
}
};
const saveDraft = () => {
try {
const draft: EmailDraft = {
...composeForm,
savedAt: new Date().toISOString(),
};
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
setHasDraft(true);
toast.success('Draft saved');
} catch (error) {
toast.error('Failed to save draft');
}
};
const clearDraft = () => {
localStorage.removeItem(DRAFT_STORAGE_KEY);
setComposeForm({
eventId: '',
templateSlug: '',
customSubject: '',
customBody: '',
recipientFilter: 'confirmed',
savedAt: '',
});
setHasDraft(false);
};
const loadRecipientPreview = async () => {
if (!composeForm.eventId) {
toast.error('Please select an event');
return;
}
try {
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) {
const data = await res.json();
let attendees = data.attendees || [];
// Apply filter
if (composeForm.recipientFilter !== 'all') {
attendees = attendees.filter((a: any) => a.status === composeForm.recipientFilter);
}
setPreviewRecipients(attendees);
setShowRecipientPreview(true);
}
} catch (error) {
toast.error('Failed to load recipients');
}
};
const handleSendEmail = async () => {
if (!composeForm.eventId || !composeForm.templateSlug) {
toast.error('Please select an event and template');
return;
}
if (!confirm(`Are you sure you want to send this email to ${previewRecipients.length} recipients?`)) {
return;
}
setSending(true);
try {
const res = await emailsApi.sendToEvent(composeForm.eventId, {
templateSlug: composeForm.templateSlug,
recipientFilter: composeForm.recipientFilter,
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
});
if (res.success || res.sentCount > 0) {
toast.success(`Sent ${res.sentCount} emails successfully`);
if (res.failedCount > 0) {
toast.error(`${res.failedCount} emails failed`);
}
clearDraft();
setShowRecipientPreview(false);
} else {
toast.error('Failed to send emails');
}
} catch (error: any) {
toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
}
};
useEffect(() => {
if (activeTab === 'logs') {
loadLogs();
}
}, [activeTab, logsOffset]);
const loadData = async () => {
try {
const [templatesRes, statsRes] = await Promise.all([
emailsApi.getTemplates(),
emailsApi.getStats(),
]);
setTemplates(templatesRes.templates);
setStats(statsRes.stats);
} catch (error) {
toast.error('Failed to load email data');
} finally {
setLoading(false);
}
};
const loadLogs = async () => {
try {
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset });
setLogs(res.logs);
setLogsTotal(res.pagination.total);
} catch (error) {
toast.error('Failed to load email logs');
}
};
const resetTemplateForm = () => {
setTemplateForm({
name: '',
slug: '',
subject: '',
subjectEs: '',
bodyHtml: '',
bodyHtmlEs: '',
bodyText: '',
bodyTextEs: '',
description: '',
isActive: true,
});
setEditingTemplate(null);
};
const handleEditTemplate = (template: EmailTemplate) => {
setTemplateForm({
name: template.name,
slug: template.slug,
subject: template.subject,
subjectEs: template.subjectEs || '',
bodyHtml: template.bodyHtml,
bodyHtmlEs: template.bodyHtmlEs || '',
bodyText: template.bodyText || '',
bodyTextEs: template.bodyTextEs || '',
description: template.description || '',
isActive: template.isActive,
});
setEditingTemplate(template);
setShowTemplateForm(true);
};
const handleSaveTemplate = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const data = {
...templateForm,
subjectEs: templateForm.subjectEs || undefined,
bodyHtmlEs: templateForm.bodyHtmlEs || undefined,
bodyText: templateForm.bodyText || undefined,
bodyTextEs: templateForm.bodyTextEs || undefined,
description: templateForm.description || undefined,
};
if (editingTemplate) {
await emailsApi.updateTemplate(editingTemplate.id, data);
toast.success('Template updated');
} else {
await emailsApi.createTemplate(data);
toast.success('Template created');
}
setShowTemplateForm(false);
resetTemplateForm();
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to save template');
} finally {
setSaving(false);
}
};
const handlePreviewTemplate = async (template: EmailTemplate) => {
try {
const res = await emailsApi.preview({
templateSlug: template.slug,
variables: {
attendeeName: 'John Doe',
attendeeEmail: 'john@example.com',
ticketId: 'TKT-ABC123',
eventTitle: 'Spanglish Night - January Edition',
eventDate: 'January 28, 2026',
eventTime: '7:00 PM',
eventLocation: 'Casa Cultural, Asunción',
eventLocationUrl: 'https://maps.google.com',
eventPrice: '50,000 PYG',
paymentAmount: '50,000 PYG',
paymentMethod: 'Lightning',
paymentReference: 'PAY-XYZ789',
paymentDate: 'January 28, 2026',
customMessage: 'This is a preview message.',
},
locale,
});
setPreviewSubject(res.subject);
setPreviewHtml(res.bodyHtml);
} catch (error) {
toast.error('Failed to preview template');
}
};
const handleDeleteTemplate = async (id: string) => {
if (!confirm('Are you sure you want to delete this template?')) return;
try {
await emailsApi.deleteTemplate(id);
toast.success('Template deleted');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to delete template');
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'sent':
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
case 'failed':
return <XCircleIcon className="w-5 h-5 text-red-500" />;
case 'pending':
return <ClockIcon className="w-5 h-5 text-yellow-500" />;
case 'bounced':
return <ExclamationTriangleIcon className="w-5 h-5 text-orange-500" />;
default:
return <ClockIcon className="w-5 h-5 text-gray-500" />;
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">Email Center</h1>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<EnvelopeIcon className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats.total}</p>
<p className="text-sm text-gray-500">Total Sent</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircleIcon className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats.sent}</p>
<p className="text-sm text-gray-500">Delivered</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
<ClockIcon className="w-5 h-5 text-yellow-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats.pending}</p>
<p className="text-sm text-gray-500">Pending</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<XCircleIcon className="w-5 h-5 text-red-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats.failed}</p>
<p className="text-sm text-gray-500">Failed</p>
</div>
</div>
</Card>
</div>
)}
{/* Tabs */}
<div className="border-b border-secondary-light-gray mb-6">
<nav className="flex gap-6">
{(['templates', 'compose', 'logs'] as TabType[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={clsx(
'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative',
{
'border-primary-yellow text-primary-dark': activeTab === tab,
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab,
}
)}
>
{tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'}
{tab === 'compose' && hasDraft && (
<span className="absolute -top-1 -right-2 w-2 h-2 bg-primary-yellow rounded-full" />
)}
</button>
))}
</nav>
</div>
{/* Templates Tab */}
{activeTab === 'templates' && (
<div>
<div className="flex justify-between items-center mb-4">
<p className="text-gray-600">Manage email templates for booking confirmations, receipts, and updates.</p>
<Button onClick={() => { resetTemplateForm(); setShowTemplateForm(true); }}>
Create Template
</Button>
</div>
<div className="grid gap-4">
{templates.map((template) => (
<Card key={template.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">{template.name}</h3>
{template.isSystem && (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">System</span>
)}
{!template.isActive && (
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded">Inactive</span>
)}
</div>
<p className="text-sm text-gray-500 mt-1">{template.slug}</p>
<p className="text-sm text-gray-600 mt-2">{template.description || 'No description'}</p>
<p className="text-sm font-medium mt-2">Subject: {template.subject}</p>
{template.variables && template.variables.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{template.variables.slice(0, 5).map((v: any) => (
<span key={v.name} className="text-xs bg-primary-yellow/20 text-primary-dark px-2 py-0.5 rounded">
{`{{${v.name}}}`}
</span>
))}
{template.variables.length > 5 && (
<span className="text-xs text-gray-500">+{template.variables.length - 5} more</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handlePreviewTemplate(template)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="Preview"
>
<EyeIcon className="w-5 h-5" />
</button>
<button
onClick={() => handleEditTemplate(template)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="Edit"
>
<PencilIcon className="w-5 h-5" />
</button>
{!template.isSystem && (
<button
onClick={() => handleDeleteTemplate(template.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Delete"
>
<XCircleIcon className="w-5 h-5" />
</button>
)}
</div>
</div>
</Card>
))}
</div>
</div>
)}
{/* Compose Tab */}
{activeTab === 'compose' && (
<div>
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold">Compose Email to Event Attendees</h2>
<div className="flex items-center gap-2">
{hasDraft && (
<span className="text-xs text-gray-500">
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString() : ''}
</span>
)}
<Button variant="outline" size="sm" onClick={saveDraft}>
Save Draft
</Button>
{hasDraft && (
<Button variant="ghost" size="sm" onClick={clearDraft}>
Clear Draft
</Button>
)}
</div>
</div>
<div className="space-y-4">
{/* Event Selection */}
<div>
<label className="block text-sm font-medium mb-1">Select Event *</label>
<select
value={composeForm.eventId}
onChange={(e) => setComposeForm({ ...composeForm, eventId: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="">Choose an event</option>
{events.filter(e => e.status === 'published').map((event) => (
<option key={event.id} value={event.id}>
{event.title} - {new Date(event.startDatetime).toLocaleDateString()}
</option>
))}
</select>
</div>
{/* Recipient Filter */}
<div>
<label className="block text-sm font-medium mb-1">Recipients</label>
<select
value={composeForm.recipientFilter}
onChange={(e) => setComposeForm({ ...composeForm, recipientFilter: e.target.value as any })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="all">All attendees</option>
<option value="confirmed">Confirmed only</option>
<option value="pending">Pending only</option>
<option value="checked_in">Checked in only</option>
</select>
</div>
{/* Template Selection */}
<div>
<label className="block text-sm font-medium mb-1">Email Template *</label>
<select
value={composeForm.templateSlug}
onChange={(e) => setComposeForm({ ...composeForm, templateSlug: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="">Choose a template</option>
{templates.filter(t => t.isActive).map((template) => (
<option key={template.id} value={template.slug}>
{template.name}
</option>
))}
</select>
</div>
{/* Custom Message */}
<div>
<label className="block text-sm font-medium mb-1">
Custom Message (optional)
</label>
<textarea
value={composeForm.customBody}
onChange={(e) => setComposeForm({ ...composeForm, customBody: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
rows={4}
placeholder="Add a custom message that will be included in the email..."
/>
<p className="text-xs text-gray-500 mt-1">
This will be available as {'{{customMessage}}'} in the template
</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-secondary-light-gray">
<Button
onClick={loadRecipientPreview}
disabled={!composeForm.eventId}
>
Preview Recipients
</Button>
</div>
</div>
</Card>
{/* Recipient Preview Modal */}
{showRecipientPreview && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-secondary-light-gray">
<h2 className="text-lg font-bold">Recipient Preview</h2>
<p className="text-sm text-gray-500">
{previewRecipients.length} recipient(s) will receive this email
</p>
</div>
<div className="flex-1 overflow-y-auto p-4">
{previewRecipients.length === 0 ? (
<p className="text-center text-gray-500 py-8">
No recipients match your filter criteria
</p>
) : (
<div className="space-y-2">
{previewRecipients.map((recipient: any) => (
<div
key={recipient.id}
className="flex items-center justify-between p-3 bg-secondary-gray rounded-btn"
>
<div>
<p className="font-medium text-sm">{recipient.attendeeFirstName} {recipient.attendeeLastName || ''}</p>
<p className="text-xs text-gray-500">{recipient.attendeeEmail}</p>
</div>
<span className={clsx('badge text-xs', {
'badge-success': recipient.status === 'confirmed',
'badge-warning': recipient.status === 'pending',
'badge-info': recipient.status === 'checked_in',
'badge-gray': recipient.status === 'cancelled',
})}>
{recipient.status}
</span>
</div>
))}
</div>
)}
</div>
<div className="p-4 border-t border-secondary-light-gray flex gap-3">
<Button
onClick={handleSendEmail}
isLoading={sending}
disabled={previewRecipients.length === 0}
>
Send to {previewRecipients.length} Recipients
</Button>
<Button variant="outline" onClick={() => setShowRecipientPreview(false)}>
Cancel
</Button>
</div>
</Card>
</div>
)}
</div>
)}
{/* Logs Tab */}
{activeTab === 'logs' && (
<div>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Recipient</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Subject</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Sent</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{logs.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No emails sent yet
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-2">
{getStatusIcon(log.status)}
<span className="capitalize text-sm">{log.status}</span>
</div>
</td>
<td className="px-6 py-4">
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
<p className="text-sm text-gray-500">{log.recipientEmail}</p>
</td>
<td className="px-6 py-4 max-w-xs">
<p className="text-sm truncate">{log.subject}</p>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(log.sentAt || log.createdAt)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setSelectedLog(log)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="View Email"
>
<EyeIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{logsTotal > 20 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary-light-gray">
<p className="text-sm text-gray-600">
Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={logsOffset === 0}
onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}
>
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={logsOffset + 20 >= logsTotal}
onClick={() => setLogsOffset(logsOffset + 20)}
>
<ChevronRightIcon className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
)}
{/* Template Form Modal */}
{showTemplateForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold mb-6">
{editingTemplate ? 'Edit Template' : 'Create Template'}
</h2>
<form onSubmit={handleSaveTemplate} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Template Name"
value={templateForm.name}
onChange={(e) => setTemplateForm({ ...templateForm, name: e.target.value })}
required
placeholder="e.g., Booking Confirmation"
/>
<Input
label="Slug (unique identifier)"
value={templateForm.slug}
onChange={(e) => setTemplateForm({ ...templateForm, slug: e.target.value })}
required
disabled={editingTemplate?.isSystem}
placeholder="e.g., booking-confirmation"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Subject (English)"
value={templateForm.subject}
onChange={(e) => setTemplateForm({ ...templateForm, subject: e.target.value })}
required
placeholder="e.g., Your Spanglish ticket is confirmed"
/>
<Input
label="Subject (Spanish)"
value={templateForm.subjectEs}
onChange={(e) => setTemplateForm({ ...templateForm, subjectEs: e.target.value })}
placeholder="e.g., Tu entrada de Spanglish está confirmada"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Body HTML (English)</label>
<textarea
value={templateForm.bodyHtml}
onChange={(e) => setTemplateForm({ ...templateForm, bodyHtml: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow font-mono text-sm"
rows={8}
required
placeholder="<h2>Your Booking is Confirmed!</h2>..."
/>
<p className="text-xs text-gray-500 mt-1">
Use {`{{variableName}}`} for dynamic content. Common variables: attendeeName, eventTitle, eventDate, ticketId
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Body HTML (Spanish)</label>
<textarea
value={templateForm.bodyHtmlEs}
onChange={(e) => setTemplateForm({ ...templateForm, bodyHtmlEs: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow font-mono text-sm"
rows={6}
placeholder="<h2>¡Tu Reserva está Confirmada!</h2>..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={templateForm.description}
onChange={(e) => setTemplateForm({ ...templateForm, description: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2}
placeholder="What is this template used for?"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={templateForm.isActive}
onChange={(e) => setTemplateForm({ ...templateForm, isActive: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="isActive" className="text-sm">Template is active</label>
</div>
<div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving}>
{editingTemplate ? 'Update Template' : 'Create Template'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
>
Cancel
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Preview Modal */}
{previewHtml && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<div>
<h2 className="text-lg font-bold">Email Preview</h2>
<p className="text-sm text-gray-500">Subject: {previewSubject}</p>
</div>
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)}>
Close
</Button>
</div>
<div className="flex-1 overflow-auto">
<iframe
srcDoc={previewHtml}
className="w-full h-full min-h-[500px]"
title="Email Preview"
/>
</div>
</Card>
</div>
)}
{/* Log Detail Modal */}
{selectedLog && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<div>
<h2 className="text-lg font-bold">Email Details</h2>
<div className="flex items-center gap-2 mt-1">
{getStatusIcon(selectedLog.status)}
<span className="capitalize text-sm">{selectedLog.status}</span>
{selectedLog.errorMessage && (
<span className="text-sm text-red-500">- {selectedLog.errorMessage}</span>
)}
</div>
</div>
<Button variant="outline" size="sm" onClick={() => setSelectedLog(null)}>
Close
</Button>
</div>
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
<p><strong>To:</strong> {selectedLog.recipientName} &lt;{selectedLog.recipientEmail}&gt;</p>
<p><strong>Subject:</strong> {selectedLog.subject}</p>
<p><strong>Sent:</strong> {formatDate(selectedLog.sentAt || selectedLog.createdAt)}</p>
</div>
<div className="flex-1 overflow-auto">
{selectedLog.bodyHtml ? (
<iframe
srcDoc={selectedLog.bodyHtml}
className="w-full h-full min-h-[400px]"
title="Email Content"
/>
) : (
<div className="p-4 text-gray-500">Email content not available</div>
)}
</div>
</Card>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,538 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, mediaApi, 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 toast from 'react-hot-toast';
import clsx from 'clsx';
export default function AdminEventsPage() {
const { t, locale } = useLanguage();
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
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;
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;
}>({
title: '',
titleEs: '',
description: '',
descriptionEs: '',
startDatetime: '',
endDatetime: '',
location: '',
locationUrl: '',
price: 0,
currency: 'PYG',
capacity: 50,
status: 'draft',
bannerUrl: '',
});
useEffect(() => {
loadEvents();
}, []);
const loadEvents = async () => {
try {
const { events } = await eventsApi.getAll();
setEvents(events);
} catch (error) {
toast.error('Failed to load events');
} finally {
setLoading(false);
}
};
const resetForm = () => {
setFormData({
title: '',
titleEs: '',
description: '',
descriptionEs: '',
startDatetime: '',
endDatetime: '',
location: '',
locationUrl: '',
price: 0,
currency: 'PYG',
capacity: 50,
status: 'draft' as const,
bannerUrl: '',
});
setEditingEvent(null);
};
const handleEdit = (event: Event) => {
setFormData({
title: event.title,
titleEs: event.titleEs || '',
description: event.description,
descriptionEs: event.descriptionEs || '',
startDatetime: event.startDatetime.slice(0, 16),
endDatetime: event.endDatetime?.slice(0, 16) || '',
location: event.location,
locationUrl: event.locationUrl || '',
price: event.price,
currency: event.currency,
capacity: event.capacity,
status: event.status,
bannerUrl: event.bannerUrl || '',
});
setEditingEvent(event);
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const eventData = {
title: formData.title,
titleEs: formData.titleEs || undefined,
description: formData.description,
descriptionEs: formData.descriptionEs || undefined,
startDatetime: new Date(formData.startDatetime).toISOString(),
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
location: formData.location,
locationUrl: formData.locationUrl || undefined,
price: formData.price,
currency: formData.currency,
capacity: formData.capacity,
status: formData.status,
bannerUrl: formData.bannerUrl || undefined,
};
if (editingEvent) {
await eventsApi.update(editingEvent.id, eventData);
toast.success('Event updated');
} else {
await eventsApi.create(eventData);
toast.success('Event created');
}
setShowForm(false);
resetForm();
loadEvents();
} catch (error: any) {
toast.error(error.message || 'Failed to save event');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this event?')) return;
try {
await eventsApi.delete(id);
toast.success('Event deleted');
loadEvents();
} catch (error) {
toast.error('Failed to delete event');
}
};
const handleStatusChange = async (event: Event, status: Event['status']) => {
try {
await eventsApi.update(event.id, { status });
toast.success('Status updated');
loadEvents();
} catch (error) {
toast.error('Failed to update status');
}
};
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', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
draft: 'badge-gray',
published: 'badge-success',
cancelled: 'badge-danger',
completed: 'badge-info',
archived: 'badge-gray',
};
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
};
const handleDuplicate = async (event: Event) => {
try {
await eventsApi.duplicate(event.id);
toast.success('Event duplicated successfully');
loadEvents();
} catch (error) {
toast.error('Failed to duplicate event');
}
};
const handleArchive = async (event: Event) => {
try {
await eventsApi.update(event.id, { status: 'archived' });
toast.success('Event archived');
loadEvents();
} catch (error) {
toast.error('Failed to archive event');
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
<Button onClick={() => { resetForm(); setShowForm(true); }}>
<PlusIcon className="w-5 h-5 mr-2" />
{t('admin.events.create')}
</Button>
</div>
{/* Event Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold mb-6">
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Title (English)"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
<Input
label="Title (Spanish)"
value={formData.titleEs}
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description (English)</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={3}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
<textarea
value={formData.descriptionEs}
onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={3}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Start Date & Time"
type="datetime-local"
value={formData.startDatetime}
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })}
required
/>
<Input
label="End Date & Time"
type="datetime-local"
value={formData.endDatetime}
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })}
/>
</div>
<Input
label="Location"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
required
/>
<Input
label="Location URL (Google Maps)"
type="url"
value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
label="Price"
type="number"
min="0"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
/>
<div>
<label className="block text-sm font-medium mb-1">Currency</label>
<select
value={formData.currency}
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="PYG">PYG</option>
<option value="USD">USD</option>
</select>
</div>
<Input
label="Capacity"
type="number"
min="1"
value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="cancelled">Cancelled</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</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>
<div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving}>
{editingEvent ? 'Update Event' : 'Create Event'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => { setShowForm(false); resetForm(); }}
>
Cancel
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Events Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Date</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Capacity</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{events.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No events found. Create your first event!
</td>
</tr>
) : (
events.map((event) => (
<tr key={event.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={event.title}
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
<PhotoIcon className="w-6 h-6 text-gray-400" />
</div>
)}
<div>
<p className="font-medium">{event.title}</p>
<p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(event.startDatetime)}
</td>
<td className="px-6 py-4 text-sm">
{event.bookedCount || 0} / {event.capacity}
</td>
<td className="px-6 py-4">
{getStatusBadge(event.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-1">
{event.status === 'draft' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleStatusChange(event, 'published')}
>
Publish
</Button>
)}
<Link
href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"
title="Manage Event"
>
<EyeIcon className="w-4 h-4" />
</Link>
<button
onClick={() => handleEdit(event)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="Edit"
>
<PencilIcon className="w-4 h-4" />
</button>
<button
onClick={() => handleDuplicate(event)}
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
title="Duplicate"
>
<DocumentDuplicateIcon className="w-4 h-4" />
</button>
{event.status !== 'archived' && (
<button
onClick={() => handleArchive(event)}
className="p-2 hover:bg-gray-100 text-gray-600 rounded-btn"
title="Archive"
>
<ArchiveBoxIcon className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDelete(event.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,305 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { mediaApi, Media } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
PhotoIcon,
TrashIcon,
ArrowUpTrayIcon,
XMarkIcon,
MagnifyingGlassIcon,
LinkIcon,
CheckIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function AdminGalleryPage() {
const { locale } = useLanguage();
const [media, setMedia] = useState<Media[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [selectedImage, setSelectedImage] = useState<Media | null>(null);
const [filter, setFilter] = useState<string>('');
const [copiedId, setCopiedId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadMedia();
}, [filter]);
const loadMedia = async () => {
try {
// We need to call the media API - let's add it if it doesn't exist
const res = await fetch('/api/media', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
},
});
if (res.ok) {
const data = await res.json();
setMedia(data.media || []);
}
} catch (error) {
toast.error('Failed to load media');
} finally {
setLoading(false);
}
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
let successCount = 0;
let failCount = 0;
for (const file of Array.from(files)) {
try {
await mediaApi.upload(file, undefined, 'gallery');
successCount++;
} catch (error) {
failCount++;
}
}
if (successCount > 0) {
toast.success(`${successCount} image(s) uploaded successfully`);
}
if (failCount > 0) {
toast.error(`${failCount} image(s) failed to upload`);
}
loadMedia();
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this image?')) return;
try {
await mediaApi.delete(id);
toast.success('Image deleted');
loadMedia();
if (selectedImage?.id === id) {
setSelectedImage(null);
}
} catch (error) {
toast.error('Failed to delete image');
}
};
const copyUrl = async (url: string, id: string) => {
const fullUrl = window.location.origin + url;
try {
await navigator.clipboard.writeText(fullUrl);
setCopiedId(id);
toast.success('URL copied');
setTimeout(() => setCopiedId(null), 2000);
} catch (error) {
toast.error('Failed to copy URL');
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const filteredMedia = media.filter(m => {
if (!filter) return true;
if (filter === 'gallery') return m.relatedType === 'gallery' || !m.relatedType;
if (filter === 'event') return m.relatedType === 'event';
return true;
});
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">Gallery Management</h1>
<Button onClick={() => fileInputRef.current?.click()} isLoading={uploading}>
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
Upload Images
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
multiple
onChange={handleUpload}
className="hidden"
/>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<Card className="p-4 text-center">
<p className="text-3xl font-bold text-primary-dark">{media.length}</p>
<p className="text-sm text-gray-500">Total Images</p>
</Card>
<Card className="p-4 text-center">
<p className="text-3xl font-bold text-blue-600">
{media.filter(m => m.relatedType === 'event').length}
</p>
<p className="text-sm text-gray-500">Event Images</p>
</Card>
<Card className="p-4 text-center">
<p className="text-3xl font-bold text-green-600">
{media.filter(m => m.relatedType === 'gallery' || !m.relatedType).length}
</p>
<p className="text-sm text-gray-500">Gallery Images</p>
</Card>
</div>
{/* Filter */}
<Card className="p-4 mb-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Filter:</label>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Images</option>
<option value="gallery">Gallery Only</option>
<option value="event">Event Banners</option>
</select>
</div>
</Card>
{/* Gallery Grid */}
{filteredMedia.length === 0 ? (
<Card className="p-12 text-center">
<PhotoIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<h3 className="text-lg font-semibold text-gray-600 mb-2">No images yet</h3>
<p className="text-gray-500 mb-4">Upload images to build your gallery</p>
<Button onClick={() => fileInputRef.current?.click()}>
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
Upload First Image
</Button>
</Card>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredMedia.map((item) => (
<Card key={item.id} className="group relative overflow-hidden aspect-square">
<img
src={item.fileUrl}
alt=""
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform"
onClick={() => setSelectedImage(item)}
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
onClick={() => setSelectedImage(item)}
className="p-2 bg-white rounded-full hover:bg-gray-100"
title="View"
>
<MagnifyingGlassIcon className="w-5 h-5" />
</button>
<button
onClick={() => copyUrl(item.fileUrl, item.id)}
className="p-2 bg-white rounded-full hover:bg-gray-100"
title="Copy URL"
>
{copiedId === item.id ? (
<CheckIcon className="w-5 h-5 text-green-600" />
) : (
<LinkIcon className="w-5 h-5" />
)}
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 bg-white rounded-full hover:bg-red-100 text-red-600"
title="Delete"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
{item.relatedType && (
<div className="absolute top-2 left-2">
<span className={`text-xs px-2 py-1 rounded ${
item.relatedType === 'event' ? 'bg-blue-500 text-white' : 'bg-green-500 text-white'
}`}>
{item.relatedType === 'event' ? 'Event' : 'Gallery'}
</span>
</div>
)}
</Card>
))}
</div>
)}
{/* Image Preview Modal */}
{selectedImage && (
<div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedImage(null)}
>
<button
className="absolute top-4 right-4 text-white hover:text-gray-300"
onClick={() => setSelectedImage(null)}
>
<XMarkIcon className="w-8 h-8" />
</button>
<div
className="max-w-4xl max-h-[80vh] flex flex-col items-center"
onClick={(e) => e.stopPropagation()}
>
<img
src={selectedImage.fileUrl}
alt=""
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
<div className="mt-4 bg-white rounded-lg p-4 w-full max-w-md">
<p className="text-sm text-gray-500 mb-2">
Uploaded: {formatDate(selectedImage.createdAt)}
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={window.location.origin + selectedImage.fileUrl}
readOnly
className="flex-1 px-3 py-2 text-sm border rounded-btn bg-gray-50"
/>
<Button
size="sm"
onClick={() => copyUrl(selectedImage.fileUrl, selectedImage.id)}
>
{copiedId === selectedImage.id ? 'Copied!' : 'Copy'}
</Button>
</div>
<div className="flex gap-2 mt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => handleDelete(selectedImage.id)}
>
<TrashIcon className="w-4 h-4 mr-2" />
Delete
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import LanguageToggle from '@/components/LanguageToggle';
import Button from '@/components/ui/Button';
import {
HomeIcon,
CalendarIcon,
TicketIcon,
UsersIcon,
CreditCardIcon,
EnvelopeIcon,
InboxIcon,
PhotoIcon,
Cog6ToothIcon,
ArrowLeftOnRectangleIcon,
Bars3Icon,
XMarkIcon,
BanknotesIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useState } from 'react';
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const { t, locale } = useLanguage();
const { user, isAdmin, isLoading, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
if (!isLoading && (!user || !isAdmin)) {
router.push('/login');
}
}, [user, isAdmin, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
if (!user || !isAdmin) {
return null;
}
const navigation = [
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
];
const handleLogout = () => {
logout();
router.push('/');
};
return (
<div className="min-h-screen bg-secondary-gray">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={clsx(
'fixed top-0 left-0 z-50 h-full w-64 bg-white shadow-lg transform transition-transform duration-300 lg:transform-none',
{
'translate-x-0': sidebarOpen,
'-translate-x-full lg:translate-x-0': !sidebarOpen,
}
)}
>
<div className="flex flex-col h-full">
{/* Logo */}
<div className="p-6 border-b border-secondary-light-gray">
<Link href="/" className="flex items-center gap-2">
<span className="text-xl font-bold font-heading text-primary-dark">
Span<span className="text-primary-yellow">glish</span>
</span>
</Link>
<p className="text-xs text-gray-500 mt-1">{t('admin.nav.dashboard')}</p>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-btn transition-colors',
{
'bg-primary-yellow text-primary-dark font-medium': isActive,
'text-gray-700 hover:bg-gray-100': !isActive,
}
)}
>
<item.icon className="w-5 h-5" />
{item.name}
</Link>
);
})}
</nav>
{/* User section */}
<div className="p-4 border-t border-secondary-light-gray">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
<span className="font-semibold text-primary-dark">
{user.name.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium text-sm">{user.name}</p>
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-btn transition-colors"
>
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
{t('nav.logout')}
</button>
</div>
</div>
</aside>
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<header className="sticky top-0 z-30 bg-white shadow-sm">
<div className="flex items-center justify-between px-4 py-4">
<button
className="lg:hidden p-2 rounded-btn hover:bg-gray-100"
onClick={() => setSidebarOpen(true)}
>
<Bars3Icon className="w-6 h-6" />
</button>
<div className="flex items-center gap-4 ml-auto">
<LanguageToggle />
<Link href="/">
<Button variant="outline" size="sm">
View Site
</Button>
</Link>
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { adminApi, DashboardData } from '@/lib/api';
import Card from '@/components/ui/Card';
import {
UsersIcon,
CalendarIcon,
TicketIcon,
CurrencyDollarIcon,
EnvelopeIcon,
UserGroupIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
export default function AdminDashboardPage() {
const { t, locale } = useLanguage();
const { user } = useAuth();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
adminApi.getDashboard()
.then(({ dashboard }) => setData(dashboard))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const statCards = data ? [
{
label: t('admin.dashboard.stats.users'),
value: data.stats.totalUsers,
icon: UsersIcon,
color: 'bg-blue-100 text-blue-600',
href: '/admin/users',
},
{
label: t('admin.dashboard.stats.events'),
value: data.stats.totalEvents,
icon: CalendarIcon,
color: 'bg-purple-100 text-purple-600',
href: '/admin/events',
},
{
label: t('admin.dashboard.stats.tickets'),
value: data.stats.confirmedTickets,
icon: TicketIcon,
color: 'bg-green-100 text-green-600',
href: '/admin/tickets',
},
{
label: t('admin.dashboard.stats.revenue'),
value: `${data.stats.totalRevenue.toLocaleString()} PYG`,
icon: CurrencyDollarIcon,
color: 'bg-yellow-100 text-yellow-600',
href: '/admin/payments',
},
] : [];
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.dashboard.title')}</h1>
<p className="text-gray-600">
{t('admin.dashboard.welcome')}, {user?.name}
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat) => (
<Link key={stat.label} href={stat.href}>
<Card className="p-6 hover:shadow-card-hover transition-shadow">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="mt-2 text-2xl font-bold text-primary-dark">{stat.value}</p>
</div>
<div className={`w-12 h-12 rounded-btn flex items-center justify-center ${stat.color}`}>
<stat.icon className="w-6 h-6" />
</div>
</div>
</Card>
</Link>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Alerts */}
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
<div className="space-y-3">
{/* Low capacity warnings */}
{data?.upcomingEvents
.filter(event => {
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
return percentFull >= 80 && availableSeats > 0;
})
.map(event => {
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
return (
<Link
key={event.id}
href="/admin/events"
className="flex items-center justify-between p-3 bg-orange-50 rounded-btn hover:bg-orange-100 transition-colors"
>
<div className="flex items-center gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
<div>
<span className="text-sm font-medium">{event.title}</span>
<p className="text-xs text-gray-500">Only {availableSeats} spots left ({percentFull}% full)</p>
</div>
</div>
<span className="badge badge-warning">Low capacity</span>
</Link>
);
})}
{/* Sold out events */}
{data?.upcomingEvents
.filter(event => (event.availableSeats ?? (event.capacity - (event.bookedCount || 0))) === 0)
.map(event => (
<Link
key={event.id}
href="/admin/events"
className="flex items-center justify-between p-3 bg-red-50 rounded-btn hover:bg-red-100 transition-colors"
>
<div className="flex items-center gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-600" />
<div>
<span className="text-sm font-medium">{event.title}</span>
<p className="text-xs text-gray-500">Event is sold out!</p>
</div>
</div>
<span className="badge badge-danger">Sold out</span>
</Link>
))}
{data && data.stats.pendingPayments > 0 && (
<Link
href="/admin/payments"
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
>
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
<span className="text-sm">Pending payments</span>
</div>
<span className="badge badge-warning">{data.stats.pendingPayments}</span>
</Link>
)}
{data && data.stats.newContacts > 0 && (
<Link
href="/admin/contacts"
className="flex items-center justify-between p-3 bg-blue-50 rounded-btn hover:bg-blue-100 transition-colors"
>
<div className="flex items-center gap-3">
<EnvelopeIcon className="w-5 h-5 text-blue-600" />
<span className="text-sm">New messages</span>
</div>
<span className="badge badge-info">{data.stats.newContacts}</span>
</Link>
)}
{/* No alerts */}
{data &&
data.stats.pendingPayments === 0 &&
data.stats.newContacts === 0 &&
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
)}
</div>
</Card>
{/* Upcoming Events */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-lg">Upcoming Events</h2>
<Link href="/admin/events" className="text-sm text-secondary-blue hover:underline">
{t('common.viewAll')}
</Link>
</div>
{data?.upcomingEvents.length === 0 ? (
<p className="text-gray-500 text-sm">No upcoming events</p>
) : (
<div className="space-y-3">
{data?.upcomingEvents.slice(0, 5).map((event) => (
<Link
key={event.id}
href={`/admin/events`}
className="flex items-center justify-between p-3 bg-secondary-gray rounded-btn hover:bg-gray-200 transition-colors"
>
<div>
<p className="font-medium text-sm">{event.title}</p>
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
</div>
<span className="text-sm text-gray-600">
{event.bookedCount || 0}/{event.capacity}
</span>
</Link>
))}
</div>
)}
</Card>
{/* Quick Stats */}
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-4 bg-secondary-gray rounded-btn">
<UserGroupIcon className="w-8 h-8 mx-auto text-gray-400" />
<p className="mt-2 text-2xl font-bold">{data?.stats.totalSubscribers || 0}</p>
<p className="text-xs text-gray-500">Subscribers</p>
</div>
<div className="text-center p-4 bg-secondary-gray rounded-btn">
<TicketIcon className="w-8 h-8 mx-auto text-gray-400" />
<p className="mt-2 text-2xl font-bold">{data?.stats.totalTickets || 0}</p>
<p className="text-xs text-gray-500">Total Bookings</p>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,461 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { paymentOptionsApi, PaymentOptionsConfig } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
CreditCardIcon,
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
CheckCircleIcon,
XCircleIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function PaymentOptionsPage() {
const { t, locale } = useLanguage();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [options, setOptions] = useState<PaymentOptionsConfig>({
tpagoEnabled: false,
tpagoLink: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
lightningEnabled: true,
cashEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
});
useEffect(() => {
loadOptions();
}, []);
const loadOptions = async () => {
try {
setLoading(true);
const { paymentOptions } = await paymentOptionsApi.getGlobal();
setOptions(paymentOptions);
} catch (error) {
console.error('Failed to load payment options:', error);
toast.error('Failed to load payment options');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
await paymentOptionsApi.updateGlobal(options);
toast.success('Payment options saved successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to save payment options');
} finally {
setSaving(false);
}
};
const updateOption = <K extends keyof PaymentOptionsConfig>(
key: K,
value: PaymentOptionsConfig[K]
) => {
setOptions((prev) => ({ ...prev, [key]: value }));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-primary-dark">
{locale === 'es' ? 'Opciones de Pago' : 'Payment Options'}
</h1>
<p className="text-gray-600 mt-1">
{locale === 'es'
? 'Configura los métodos de pago disponibles para todos los eventos'
: 'Configure payment methods available for all events'}
</p>
</div>
<Button onClick={handleSave} isLoading={saving}>
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
</div>
{/* TPago / International Card */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<CreditCardIcon className="w-5 h-5 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'TPago / Tarjeta Internacional' : 'TPago / International Card'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
</p>
</div>
</div>
<button
onClick={() => updateOption('tpagoEnabled', !options.tpagoEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.tpagoEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.tpagoEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.tpagoEnabled && (
<div className="space-y-4 pt-4 border-t">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
</label>
<Input
value={options.tpagoLink || ''}
onChange={(e) => updateOption('tpagoLink', e.target.value || null)}
placeholder="https://www.tpago.com.py/links?alias=..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instructions (English)
</label>
<textarea
value={options.tpagoInstructions || ''}
onChange={(e) => updateOption('tpagoInstructions', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Instructions for users..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instrucciones (Español)
</label>
<textarea
value={options.tpagoInstructionsEs || ''}
onChange={(e) => updateOption('tpagoInstructionsEs', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Instrucciones para usuarios..."
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Bank Transfer */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<BuildingLibraryIcon className="w-5 h-5 text-green-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
</p>
</div>
</div>
<button
onClick={() => updateOption('bankTransferEnabled', !options.bankTransferEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.bankTransferEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.bankTransferEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.bankTransferEnabled && (
<div className="space-y-4 pt-4 border-t">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Nombre del Banco' : 'Bank Name'}
</label>
<Input
value={options.bankName || ''}
onChange={(e) => updateOption('bankName', e.target.value || null)}
placeholder="e.g., Banco Itaú"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Titular de la Cuenta' : 'Account Holder'}
</label>
<Input
value={options.bankAccountHolder || ''}
onChange={(e) => updateOption('bankAccountHolder', e.target.value || null)}
placeholder="e.g., Juan Pérez"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Número de Cuenta' : 'Account Number'}
</label>
<Input
value={options.bankAccountNumber || ''}
onChange={(e) => updateOption('bankAccountNumber', e.target.value || null)}
placeholder="e.g., 1234567890"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Alias
</label>
<Input
value={options.bankAlias || ''}
onChange={(e) => updateOption('bankAlias', e.target.value || null)}
placeholder="e.g., spanglish.pagos"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Teléfono' : 'Phone Number'}
</label>
<Input
value={options.bankPhone || ''}
onChange={(e) => updateOption('bankPhone', e.target.value || null)}
placeholder="e.g., +595 981 123456"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Additional Notes (English)
</label>
<textarea
value={options.bankNotes || ''}
onChange={(e) => updateOption('bankNotes', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Additional notes for users..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas Adicionales (Español)
</label>
<textarea
value={options.bankNotesEs || ''}
onChange={(e) => updateOption('bankNotesEs', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Notas adicionales para usuarios..."
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Bitcoin Lightning */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<BoltIcon className="w-5 h-5 text-orange-600" />
</div>
<div>
<h3 className="font-semibold text-lg">Bitcoin Lightning</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago instantáneo - confirmación automática' : 'Instant payment - automatic confirmation'}
</p>
</div>
</div>
<button
onClick={() => updateOption('lightningEnabled', !options.lightningEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.lightningEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.lightningEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.lightningEnabled && (
<div className="pt-4 border-t">
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<p className="text-sm text-orange-800">
{locale === 'es'
? 'Lightning está configurado a través de las variables de entorno de LNbits. Verifica que LNBITS_URL y LNBITS_API_KEY estén configurados correctamente.'
: 'Lightning is configured via LNbits environment variables. Make sure LNBITS_URL and LNBITS_API_KEY are properly set.'}
</p>
</div>
</div>
)}
</div>
</Card>
{/* Cash at Door */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
<BanknotesIcon className="w-5 h-5 text-yellow-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Efectivo en el Evento' : 'Cash at the Door'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
</p>
</div>
</div>
<button
onClick={() => updateOption('cashEnabled', !options.cashEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.cashEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.cashEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.cashEnabled && (
<div className="space-y-4 pt-4 border-t">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instructions (English)
</label>
<textarea
value={options.cashInstructions || ''}
onChange={(e) => updateOption('cashInstructions', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Instructions for cash payments..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instrucciones (Español)
</label>
<textarea
value={options.cashInstructionsEs || ''}
onChange={(e) => updateOption('cashInstructionsEs', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Instrucciones para pagos en efectivo..."
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Summary */}
<Card>
<div className="p-6">
<h3 className="font-semibold text-lg mb-4">
{locale === 'es' ? 'Resumen de Métodos Activos' : 'Active Methods Summary'}
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex items-center gap-2">
{options.tpagoEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.tpagoEnabled ? 'text-gray-900' : 'text-gray-400'}>
TPago
</span>
</div>
<div className="flex items-center gap-2">
{options.bankTransferEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.bankTransferEnabled ? 'text-gray-900' : 'text-gray-400'}>
{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}
</span>
</div>
<div className="flex items-center gap-2">
{options.lightningEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.lightningEnabled ? 'text-gray-900' : 'text-gray-400'}>
Lightning
</span>
</div>
<div className="flex items-center gap-2">
{options.cashEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.cashEnabled ? 'text-gray-900' : 'text-gray-400'}>
{locale === 'es' ? 'Efectivo' : 'Cash'}
</span>
</div>
</div>
</div>
</Card>
{/* Save button (bottom) */}
<div className="mt-6 flex justify-end">
<Button onClick={handleSave} isLoading={saving} size="lg">
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,744 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
CheckCircleIcon,
ArrowPathIcon,
ArrowDownTrayIcon,
DocumentArrowDownIcon,
XCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
ChatBubbleLeftIcon,
BoltIcon,
BanknotesIcon,
BuildingLibraryIcon,
CreditCardIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
type Tab = 'pending_approval' | 'all';
export default function AdminPaymentsPage() {
const { t, locale } = useLanguage();
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
const [statusFilter, setStatusFilter] = useState<string>('');
const [providerFilter, setProviderFilter] = useState<string>('');
// Modal state
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
const [noteText, setNoteText] = useState('');
const [processing, setProcessing] = useState(false);
// Export state
const [showExportModal, setShowExportModal] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportData, setExportData] = useState<{ payments: ExportedPayment[]; summary: FinancialSummary } | null>(null);
const [exportFilters, setExportFilters] = useState({
startDate: '',
endDate: '',
eventId: '',
});
useEffect(() => {
loadData();
}, [statusFilter, providerFilter]);
const loadData = async () => {
try {
setLoading(true);
const [pendingRes, allRes, eventsRes] = await Promise.all([
paymentsApi.getPendingApproval(),
paymentsApi.getAll({
status: statusFilter || undefined,
provider: providerFilter || undefined
}),
eventsApi.getAll(),
]);
setPendingApprovalPayments(pendingRes.payments);
setPayments(allRes.payments);
setEvents(eventsRes.events);
} catch (error) {
toast.error('Failed to load payments');
} finally {
setLoading(false);
}
};
const handleApprove = async (payment: PaymentWithDetails) => {
setProcessing(true);
try {
await paymentsApi.approve(payment.id, noteText);
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
setSelectedPayment(null);
setNoteText('');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to approve payment');
} finally {
setProcessing(false);
}
};
const handleReject = async (payment: PaymentWithDetails) => {
setProcessing(true);
try {
await paymentsApi.reject(payment.id, noteText);
toast.success(locale === 'es' ? 'Pago rechazado' : 'Payment rejected');
setSelectedPayment(null);
setNoteText('');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to reject payment');
} finally {
setProcessing(false);
}
};
const handleConfirmPayment = async (id: string) => {
try {
await paymentsApi.approve(id);
toast.success('Payment confirmed');
loadData();
} catch (error) {
toast.error('Failed to confirm payment');
}
};
const handleRefund = async (id: string) => {
if (!confirm('Are you sure you want to process this refund?')) return;
try {
await paymentsApi.refund(id);
toast.success('Refund processed');
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to process refund');
}
};
const handleExport = async () => {
setExporting(true);
try {
const data = await adminApi.exportFinancial({
startDate: exportFilters.startDate || undefined,
endDate: exportFilters.endDate || undefined,
eventId: exportFilters.eventId || undefined,
});
setExportData(data);
} catch (error) {
toast.error('Failed to generate export');
} finally {
setExporting(false);
}
};
const downloadCSV = () => {
if (!exportData) return;
const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'Event Title', 'Event Date'];
const rows = exportData.payments.map(p => [
p.paymentId,
p.amount,
p.currency,
p.provider,
p.status,
p.reference || '',
p.paidAt || '',
p.createdAt,
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
p.attendeeEmail || '',
p.eventTitle,
p.eventDate,
]);
const csvContent = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `financial-export-${new Date().toISOString().split('T')[0]}.csv`;
link.click();
toast.success('CSV downloaded');
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatCurrency = (amount: number, currency: string) => {
return `${amount.toLocaleString()} ${currency}`;
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700',
pending_approval: 'bg-yellow-100 text-yellow-700',
paid: 'bg-green-100 text-green-700',
refunded: 'bg-blue-100 text-blue-700',
failed: 'bg-red-100 text-red-700',
cancelled: 'bg-gray-100 text-gray-700',
};
const labels: Record<string, string> = {
pending: locale === 'es' ? 'Pendiente' : 'Pending',
pending_approval: locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval',
paid: locale === 'es' ? 'Pagado' : 'Paid',
refunded: locale === 'es' ? 'Reembolsado' : 'Refunded',
failed: locale === 'es' ? 'Fallido' : 'Failed',
cancelled: locale === 'es' ? 'Cancelado' : 'Cancelled',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || 'bg-gray-100 text-gray-700'}`}>
{labels[status] || status}
</span>
);
};
const getProviderIcon = (provider: string) => {
const icons: Record<string, typeof BoltIcon> = {
lightning: BoltIcon,
cash: BanknotesIcon,
bank_transfer: BuildingLibraryIcon,
tpago: CreditCardIcon,
bancard: CreditCardIcon,
};
const Icon = icons[provider] || CreditCardIcon;
return <Icon className="w-4 h-4" />;
};
const getProviderLabel = (provider: string) => {
const labels: Record<string, string> = {
cash: locale === 'es' ? 'Efectivo' : 'Cash',
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
lightning: 'Lightning',
tpago: 'TPago',
bancard: 'Bancard',
};
return labels[provider] || provider;
};
// Calculate totals
const totalPending = payments
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
.reduce((sum, p) => sum + p.amount, 0);
const totalPaid = payments
.filter(p => p.status === 'paid')
.reduce((sum, p) => sum + p.amount, 0);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
<Button onClick={() => setShowExportModal(true)}>
<DocumentArrowDownIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Exportar Datos' : 'Export Data'}
</Button>
</div>
{/* Approval Detail Modal */}
{selectedPayment && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg p-6">
<h2 className="text-xl font-bold mb-4">
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
</h2>
<div className="space-y-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">{locale === 'es' ? 'Monto' : 'Amount'}</p>
<p className="font-bold text-lg">{formatCurrency(selectedPayment.amount, selectedPayment.currency)}</p>
</div>
<div>
<p className="text-gray-500">{locale === 'es' ? 'Método' : 'Method'}</p>
<p className="font-medium flex items-center gap-2">
{getProviderIcon(selectedPayment.provider)}
{getProviderLabel(selectedPayment.provider)}
</p>
</div>
</div>
</div>
{selectedPayment.ticket && (
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-2">{locale === 'es' ? 'Asistente' : 'Attendee'}</h4>
<p className="font-bold">
{selectedPayment.ticket.attendeeFirstName} {selectedPayment.ticket.attendeeLastName}
</p>
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeeEmail}</p>
{selectedPayment.ticket.attendeePhone && (
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
)}
</div>
)}
{selectedPayment.event && (
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-2">{locale === 'es' ? 'Evento' : 'Event'}</h4>
<p className="font-bold">{selectedPayment.event.title}</p>
<p className="text-sm text-gray-600">{formatDate(selectedPayment.event.startDatetime)}</p>
</div>
)}
{selectedPayment.userMarkedPaidAt && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<ClockIcon className="w-4 h-4" />
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Nota interna (opcional)' : 'Internal note (optional)'}
</label>
<textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={2}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder={locale === 'es' ? 'Agregar nota...' : 'Add a note...'}
/>
</div>
</div>
<div className="flex gap-3">
<Button
onClick={() => handleApprove(selectedPayment)}
isLoading={processing}
className="flex-1"
>
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Aprobar' : 'Approve'}
</Button>
<Button
variant="outline"
onClick={() => handleReject(selectedPayment)}
isLoading={processing}
className="flex-1 border-red-300 text-red-600 hover:bg-red-50"
>
<XCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Rechazar' : 'Reject'}
</Button>
</div>
<button
onClick={() => { setSelectedPayment(null); setNoteText(''); }}
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</button>
</Card>
</div>
)}
{/* Export Modal */}
{showExportModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold mb-6">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
{!exportData ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={locale === 'es' ? 'Fecha Inicio' : 'Start Date'}
type="date"
value={exportFilters.startDate}
onChange={(e) => setExportFilters({ ...exportFilters, startDate: e.target.value })}
/>
<Input
label={locale === 'es' ? 'Fecha Fin' : 'End Date'}
type="date"
value={exportFilters.endDate}
onChange={(e) => setExportFilters({ ...exportFilters, endDate: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Evento (opcional)' : 'Event (optional)'}</label>
<select
value={exportFilters.eventId}
onChange={(e) => setExportFilters({ ...exportFilters, eventId: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="">{locale === 'es' ? 'Todos los Eventos' : 'All Events'}</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div className="flex gap-3 pt-4">
<Button onClick={handleExport} isLoading={exporting}>
{locale === 'es' ? 'Generar Reporte' : 'Generate Report'}
</Button>
<Button variant="outline" onClick={() => setShowExportModal(false)}>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
</div>
</div>
) : (
<div className="space-y-6">
{/* Summary */}
<div className="bg-secondary-gray rounded-btn p-4">
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Resumen Financiero' : 'Financial Summary'}</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p>
<p className="text-xl font-bold text-green-600">{exportData.summary.totalPaid.toLocaleString()} PYG</p>
<p className="text-xs text-gray-500">{exportData.summary.paidCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
</div>
<div>
<p className="text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
<p className="text-xl font-bold text-yellow-600">{exportData.summary.totalPending.toLocaleString()} PYG</p>
<p className="text-xs text-gray-500">{exportData.summary.pendingCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
</div>
<div>
<p className="text-gray-500">{locale === 'es' ? 'Total Reembolsado' : 'Total Refunded'}</p>
<p className="text-xl font-bold text-blue-600">{exportData.summary.totalRefunded.toLocaleString()} PYG</p>
<p className="text-xs text-gray-500">{exportData.summary.refundedCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
</div>
<div>
<p className="text-gray-500">{locale === 'es' ? 'Total Registros' : 'Total Records'}</p>
<p className="text-xl font-bold">{exportData.summary.totalPayments}</p>
</div>
</div>
</div>
{/* By Provider */}
<div className="bg-secondary-gray rounded-btn p-4">
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
<div>
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p>
</div>
<div>
<p className="text-gray-500">Lightning</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.lightning?.toLocaleString() || 0} PYG</p>
</div>
<div>
<p className="text-gray-500">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</p>
<p className="text-lg font-bold">{(exportData.summary.byProvider as any).bank_transfer?.toLocaleString() || 0} PYG</p>
</div>
<div>
<p className="text-gray-500">TPago</p>
<p className="text-lg font-bold">{(exportData.summary.byProvider as any).tpago?.toLocaleString() || 0} PYG</p>
</div>
<div>
<p className="text-gray-500">Bancard</p>
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
</div>
</div>
</div>
<div className="flex gap-3">
<Button onClick={downloadCSV}>
<ArrowDownTrayIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
</Button>
<Button variant="outline" onClick={() => setExportData(null)}>
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
</Button>
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }}>
{locale === 'es' ? 'Cerrar' : 'Close'}
</Button>
</div>
</div>
)}
</Card>
</div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
<ExclamationTriangleIcon className="w-5 h-5 text-yellow-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p>
<p className="text-xl font-bold text-yellow-600">{pendingApprovalPayments.length}</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<ClockIcon className="w-5 h-5 text-gray-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircleIcon className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p>
<p className="text-xl font-bold text-green-600">{formatCurrency(totalPaid, 'PYG')}</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<BoltIcon className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagos' : 'Total Payments'}</p>
<p className="text-xl font-bold">{payments.length}</p>
</div>
</div>
</Card>
</div>
{/* Tabs */}
<div className="border-b mb-6">
<nav className="flex gap-4">
<button
onClick={() => setActiveTab('pending_approval')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'pending_approval'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}
{pendingApprovalPayments.length > 0 && (
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">
{pendingApprovalPayments.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('all')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'all'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{locale === 'es' ? 'Todos los Pagos' : 'All Payments'}
</button>
</nav>
</div>
{/* Pending Approval Tab */}
{activeTab === 'pending_approval' && (
<>
{pendingApprovalPayments.length === 0 ? (
<Card className="p-12 text-center">
<CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" />
<p className="text-gray-500">
{locale === 'es'
? 'No hay pagos pendientes de aprobación'
: 'No payments pending approval'}
</p>
</Card>
) : (
<div className="space-y-4">
{pendingApprovalPayments.map((payment) => (
<Card key={payment.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
{getProviderIcon(payment.provider)}
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<p className="font-bold text-lg">{formatCurrency(payment.amount, payment.currency)}</p>
{getStatusBadge(payment.status)}
</div>
{payment.ticket && (
<p className="text-sm font-medium">
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
</p>
)}
{payment.event && (
<p className="text-sm text-gray-500">{payment.event.title}</p>
)}
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
<span className="flex items-center gap-1">
{getProviderIcon(payment.provider)}
{getProviderLabel(payment.provider)}
</span>
{payment.userMarkedPaidAt && (
<span className="flex items-center gap-1">
<ClockIcon className="w-3 h-3" />
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
</span>
)}
</div>
</div>
</div>
<Button onClick={() => setSelectedPayment(payment)}>
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
</div>
</Card>
))}
</div>
)}
</>
)}
{/* All Payments Tab */}
{activeTab === 'all' && (
<>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
<select
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
<option value="lightning">Lightning</option>
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
<option value="bank_transfer">{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}</option>
<option value="tpago">TPago</option>
</select>
</div>
</div>
</Card>
{/* Payments Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Evento' : 'Event'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Monto' : 'Amount'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Método' : 'Method'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Fecha' : 'Date'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{payments.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}
</td>
</tr>
) : (
payments.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
{payment.ticket ? (
<div>
<p className="font-medium text-sm">
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
</p>
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
</div>
) : (
<span className="text-gray-400 text-sm">-</span>
)}
</td>
<td className="px-6 py-4">
{payment.event ? (
<p className="text-sm">{payment.event.title}</p>
) : (
<span className="text-gray-400 text-sm">-</span>
)}
</td>
<td className="px-6 py-4 font-medium">
{formatCurrency(payment.amount, payment.currency)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
{getProviderIcon(payment.provider)}
{getProviderLabel(payment.provider)}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(payment.createdAt)}
</td>
<td className="px-6 py-4">
{getStatusBadge(payment.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
<Button
size="sm"
onClick={() => setSelectedPayment(payment)}
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
)}
{payment.status === 'paid' && (
<Button
size="sm"
variant="outline"
onClick={() => handleRefund(payment.id)}
>
<ArrowPathIcon className="w-4 h-4 mr-1" />
{t('admin.payments.refund')}
</Button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,407 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { CheckCircleIcon, XCircleIcon, PlusIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
export default function AdminTicketsPage() {
const { t, locale } = useLanguage();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [selectedEvent, setSelectedEvent] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
// Manual ticket creation state
const [showCreateForm, setShowCreateForm] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm, setCreateForm] = useState({
eventId: '',
firstName: '',
lastName: '',
email: '',
phone: '',
preferredLanguage: 'en' as 'en' | 'es',
autoCheckin: false,
adminNote: '',
});
useEffect(() => {
Promise.all([
ticketsApi.getAll(),
eventsApi.getAll(),
])
.then(([ticketsRes, eventsRes]) => {
setTickets(ticketsRes.tickets);
setEvents(eventsRes.events);
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const loadTickets = async () => {
try {
const params: any = {};
if (selectedEvent) params.eventId = selectedEvent;
if (statusFilter) params.status = statusFilter;
const { tickets } = await ticketsApi.getAll(params);
setTickets(tickets);
} catch (error) {
toast.error('Failed to load tickets');
}
};
useEffect(() => {
if (!loading) {
loadTickets();
}
}, [selectedEvent, statusFilter]);
const handleCheckin = async (id: string) => {
try {
await ticketsApi.checkin(id);
toast.success('Check-in successful');
loadTickets();
} catch (error: any) {
toast.error(error.message || 'Check-in failed');
}
};
const handleCancel = async (id: string) => {
if (!confirm('Are you sure you want to cancel this ticket?')) return;
try {
await ticketsApi.cancel(id);
toast.success('Ticket cancelled');
loadTickets();
} catch (error) {
toast.error('Failed to cancel ticket');
}
};
const handleConfirm = async (id: string) => {
try {
await ticketsApi.updateStatus(id, 'confirmed');
toast.success('Ticket confirmed');
loadTickets();
} catch (error) {
toast.error('Failed to confirm ticket');
}
};
const handleCreateTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!createForm.eventId) {
toast.error('Please select an event');
return;
}
setCreating(true);
try {
await ticketsApi.adminCreate({
eventId: createForm.eventId,
firstName: createForm.firstName,
lastName: createForm.lastName || undefined,
email: createForm.email,
phone: createForm.phone,
preferredLanguage: createForm.preferredLanguage,
autoCheckin: createForm.autoCheckin,
adminNote: createForm.adminNote || undefined,
});
toast.success('Ticket created successfully');
setShowCreateForm(false);
setCreateForm({
eventId: '',
firstName: '',
lastName: '',
email: '',
phone: '',
preferredLanguage: 'en',
autoCheckin: false,
adminNote: '',
});
loadTickets();
} catch (error: any) {
toast.error(error.message || 'Failed to create ticket');
} finally {
setCreating(false);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
pending: 'badge-warning',
confirmed: 'badge-success',
cancelled: 'badge-danger',
checked_in: 'badge-info',
};
const labels: Record<string, string> = {
pending: t('admin.tickets.status.pending'),
confirmed: t('admin.tickets.status.confirmed'),
cancelled: t('admin.tickets.status.cancelled'),
checked_in: t('admin.tickets.status.checkedIn'),
};
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>;
};
const getEventName = (eventId: string) => {
const event = events.find(e => e.id === eventId);
return event?.title || 'Unknown Event';
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1>
<Button onClick={() => setShowCreateForm(true)}>
<PlusIcon className="w-5 h-5 mr-2" />
Create Ticket
</Button>
</div>
{/* Manual Ticket Creation Modal */}
{showCreateForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg p-6">
<h2 className="text-xl font-bold mb-6">Create Ticket Manually</h2>
<form onSubmit={handleCreateTicket} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Event *</label>
<select
value={createForm.eventId}
onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
required
>
<option value="">Select an event</option>
{events.filter(e => e.status === 'published').map((event) => (
<option key={event.id} value={event.id}>
{event.title} ({event.availableSeats} spots left)
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="First Name *"
value={createForm.firstName}
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })}
required
placeholder="First name"
/>
<Input
label="Last Name (optional)"
value={createForm.lastName}
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })}
placeholder="Last name"
/>
</div>
<Input
label="Email (optional)"
type="email"
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
placeholder="attendee@email.com"
/>
<Input
label="Phone (optional)"
value={createForm.phone}
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })}
placeholder="+595 XXX XXX XXX"
/>
<div>
<label className="block text-sm font-medium mb-1">Preferred Language</label>
<select
value={createForm.preferredLanguage}
onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="en">English</option>
<option value="es">Spanish</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Admin Note</label>
<textarea
value={createForm.adminNote}
onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
rows={2}
placeholder="Internal note about this booking (optional)"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoCheckin"
checked={createForm.autoCheckin}
onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="autoCheckin" className="text-sm">
Automatically check in (mark as present)
</label>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800">
Note: This creates a ticket with cash payment marked as paid. Use this for walk-ins at the door. Email and phone are optional for door entries.
</div>
<div className="flex gap-3 pt-4">
<Button type="submit" isLoading={creating}>
Create Ticket
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowCreateForm(false)}
>
Cancel
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-medium mb-1">Event</label>
<select
value={selectedEvent}
onChange={(e) => setSelectedEvent(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px]"
>
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="checked_in">Checked In</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
</Card>
{/* Tickets Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Ticket</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{tickets.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No tickets found
</td>
</tr>
) : (
tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div>
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
<p className="text-xs text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
</div>
</td>
<td className="px-6 py-4 text-sm">
{getEventName(ticket.eventId)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(ticket.createdAt)}
</td>
<td className="px-6 py-4">
{getStatusBadge(ticket.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{ticket.status === 'pending' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleConfirm(ticket.id)}
>
Confirm
</Button>
)}
{ticket.status === 'confirmed' && (
<Button
size="sm"
onClick={() => handleCheckin(ticket.id)}
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
{t('admin.tickets.checkin')}
</Button>
)}
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
<button
onClick={() => handleCancel(ticket.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Cancel"
>
<XCircleIcon className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { usersApi, User } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { TrashIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function AdminUsersPage() {
const { t, locale } = useLanguage();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [roleFilter, setRoleFilter] = useState<string>('');
useEffect(() => {
loadUsers();
}, [roleFilter]);
const loadUsers = async () => {
try {
const { users } = await usersApi.getAll(roleFilter || undefined);
setUsers(users);
} catch (error) {
toast.error('Failed to load users');
} finally {
setLoading(false);
}
};
const handleRoleChange = async (userId: string, newRole: string) => {
try {
await usersApi.update(userId, { role: newRole as any });
toast.success('Role updated');
loadUsers();
} catch (error) {
toast.error('Failed to update role');
}
};
const handleDelete = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await usersApi.delete(userId);
toast.success('User deleted');
loadUsers();
} catch (error: any) {
toast.error(error.message || 'Failed to delete user');
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const getRoleBadge = (role: string) => {
const styles: Record<string, string> = {
admin: 'badge-danger',
organizer: 'badge-info',
staff: 'badge-warning',
marketing: 'badge-success',
user: 'badge-gray',
};
return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>;
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1>
</div>
{/* Filters */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">All Roles</option>
<option value="admin">{t('admin.users.roles.admin')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option>
<option value="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option>
<option value="user">{t('admin.users.roles.user')}</option>
</select>
</div>
</div>
</Card>
{/* Users Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">User</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Contact</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Role</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Joined</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{users.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No users found
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
<span className="font-semibold text-primary-dark">
{user.name.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{user.phone || '-'}
</td>
<td className="px-6 py-4">
<select
value={user.role}
onChange={(e) => handleRoleChange(user.id, e.target.value)}
className="px-2 py-1 rounded border border-secondary-light-gray text-sm"
>
<option value="user">{t('admin.users.roles.user')}</option>
<option value="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option>
<option value="admin">{t('admin.users.roles.admin')}</option>
</select>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(user.createdAt)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleDelete(user.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,78 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Poppins:wght@500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply font-sans text-primary-dark antialiased;
}
h1, h2, h3, h4, h5, h6 {
@apply font-heading;
}
}
@layer components {
.container-page {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
.section-padding {
@apply py-16 md:py-24;
}
.section-title {
@apply text-3xl md:text-4xl font-bold text-primary-dark;
}
.section-subtitle {
@apply text-lg text-gray-600 mt-4;
}
/* Form styles */
.form-group {
@apply space-y-6;
}
/* Card hover effect */
.card-hover {
@apply transition-all duration-300 hover:shadow-card-hover hover:-translate-y-1;
}
/* Status badges */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-green-100 text-green-800;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800;
}
.badge-danger {
@apply bg-red-100 text-red-800;
}
.badge-info {
@apply bg-blue-100 text-blue-800;
}
.badge-gray {
@apply bg-gray-100 text-gray-800;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View File

@@ -0,0 +1,58 @@
import type { Metadata } from 'next';
import { Toaster } from 'react-hot-toast';
import { LanguageProvider } from '@/context/LanguageContext';
import { AuthProvider } from '@/context/AuthContext';
import './globals.css';
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'],
openGraph: {
title: 'Spanglish - Language Exchange in Asunción',
description: 'Practice English and Spanish with native speakers at our language exchange events.',
type: 'website',
locale: 'en_US',
alternateLocale: 'es_ES',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthProvider>
<LanguageProvider>
{children}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
borderRadius: '12px',
padding: '16px',
},
success: {
style: {
background: '#F0FDF4',
color: '#166534',
},
},
error: {
style: {
background: '#FEF2F2',
color: '#991B1B',
},
},
}}
/>
</LanguageProvider>
</AuthProvider>
</body>
</html>
);
}