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

View File

@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { Locale, localeNames, localeFlags } from '@/i18n';
import { ChevronDownIcon, GlobeAltIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
interface LanguageToggleProps {
variant?: 'dropdown' | 'buttons';
showFlags?: boolean;
}
export default function LanguageToggle({
variant = 'dropdown',
showFlags = true
}: LanguageToggleProps) {
const { locale, setLocale } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const availableLocales = Object.keys(localeNames) as Locale[];
if (variant === 'buttons') {
return (
<div className="flex items-center gap-1 bg-secondary-gray rounded-btn p-1">
{availableLocales.map((loc) => (
<button
key={loc}
onClick={() => setLocale(loc)}
className={clsx(
'px-3 py-1.5 rounded-btn text-sm font-medium transition-colors',
{
'bg-white shadow-sm text-primary-dark': locale === loc,
'text-gray-600 hover:text-primary-dark': locale !== loc,
}
)}
>
{showFlags && <span className="mr-1">{localeFlags[loc]}</span>}
{loc.toUpperCase()}
</button>
))}
</div>
);
}
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-btn hover:bg-secondary-gray transition-colors"
>
<GlobeAltIcon className="w-5 h-5 text-gray-600" />
{showFlags && <span>{localeFlags[locale]}</span>}
<span className="text-sm font-medium">{localeNames[locale]}</span>
<ChevronDownIcon
className={clsx(
'w-4 h-4 text-gray-500 transition-transform',
{ 'rotate-180': isOpen }
)}
/>
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-2 w-40 bg-white rounded-card shadow-card-hover border border-secondary-light-gray z-20 overflow-hidden">
{availableLocales.map((loc) => (
<button
key={loc}
onClick={() => {
setLocale(loc);
setIsOpen(false);
}}
className={clsx(
'w-full flex items-center gap-2 px-4 py-2.5 text-left transition-colors',
{
'bg-secondary-gray': locale === loc,
'hover:bg-gray-50': locale !== loc,
}
)}
>
{showFlags && <span>{localeFlags[loc]}</span>}
<span className="text-sm font-medium">{localeNames[loc]}</span>
</button>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import { useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import {
ShareIcon,
LinkIcon,
CheckIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
interface ShareButtonsProps {
title: string;
url?: string;
description?: string;
}
export default function ShareButtons({ title, url, description }: ShareButtonsProps) {
const { locale } = useLanguage();
const [copied, setCopied] = useState(false);
// Use provided URL or current page URL
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
const shareText = description || title;
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied');
setTimeout(() => setCopied(false), 2000);
} catch (err) {
toast.error(locale === 'es' ? 'Error al copiar' : 'Failed to copy');
}
};
const shareToWhatsApp = () => {
const text = encodeURIComponent(`${shareText}\n\n${shareUrl}`);
window.open(`https://wa.me/?text=${text}`, '_blank');
};
const shareToFacebook = () => {
const encodedUrl = encodeURIComponent(shareUrl);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
};
const shareToTwitter = () => {
const text = encodeURIComponent(shareText);
const encodedUrl = encodeURIComponent(shareUrl);
window.open(`https://twitter.com/intent/tweet?text=${text}&url=${encodedUrl}`, '_blank', 'width=600,height=400');
};
const shareToLinkedIn = () => {
const encodedUrl = encodeURIComponent(shareUrl);
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`, '_blank', 'width=600,height=400');
};
const handleNativeShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title,
text: shareText,
url: shareUrl,
});
} catch (err) {
// User cancelled or error
}
}
};
return (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-gray-600">
{locale === 'es' ? 'Compartir evento' : 'Share event'}
</p>
<div className="flex items-center gap-2 flex-wrap">
{/* WhatsApp */}
<button
onClick={shareToWhatsApp}
className="w-10 h-10 flex items-center justify-center rounded-full bg-[#25D366] text-white hover:opacity-90 transition-opacity"
title="WhatsApp"
>
<svg className="w-5 h-5" 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>
</button>
{/* Facebook */}
<button
onClick={shareToFacebook}
className="w-10 h-10 flex items-center justify-center rounded-full bg-[#1877F2] text-white hover:opacity-90 transition-opacity"
title="Facebook"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</button>
{/* Twitter/X */}
<button
onClick={shareToTwitter}
className="w-10 h-10 flex items-center justify-center rounded-full bg-black text-white hover:opacity-90 transition-opacity"
title="X (Twitter)"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
{/* LinkedIn */}
<button
onClick={shareToLinkedIn}
className="w-10 h-10 flex items-center justify-center rounded-full bg-[#0A66C2] text-white hover:opacity-90 transition-opacity"
title="LinkedIn"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</button>
{/* Copy Link */}
<button
onClick={handleCopyLink}
className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
title={locale === 'es' ? 'Copiar enlace' : 'Copy link'}
>
{copied ? (
<CheckIcon className="w-5 h-5 text-green-600" />
) : (
<LinkIcon className="w-5 h-5" />
)}
</button>
{/* Native Share (mobile) */}
{typeof navigator !== 'undefined' && typeof navigator.share === 'function' && (
<button
onClick={handleNativeShare}
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-yellow text-primary-dark hover:bg-primary-yellow/90 transition-colors"
title={locale === 'es' ? 'Más opciones' : 'More options'}
>
<ShareIcon className="w-5 h-5" />
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { getSocialLinks, socialIcons } from '@/lib/socialLinks';
const legalLinks = [
{ slug: 'terms-policy', en: 'Terms & Conditions', es: 'Términos y Condiciones' },
{ slug: 'privacy-policy', en: 'Privacy Policy', es: 'Política de Privacidad' },
{ slug: 'refund-cancelation-policy', en: 'Refund Policy', es: 'Política de Reembolso' },
];
export default function Footer() {
const { t, locale } = useLanguage();
const currentYear = new Date().getFullYear();
const socialLinks = getSocialLinks();
return (
<footer className="bg-secondary-gray border-t border-secondary-light-gray">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* Brand */}
<div className="col-span-1 md:col-span-2">
<Link href="/" className="inline-block">
<span className="text-2xl font-bold font-heading text-primary-dark">
Span<span className="text-primary-yellow">glish</span>
</span>
</Link>
<p className="mt-3 text-gray-600 max-w-md">
{t('footer.tagline')}
</p>
</div>
{/* Quick Links */}
<div>
<h3 className="font-semibold text-primary-dark mb-4">
{t('footer.links')}
</h3>
<ul className="space-y-2">
<li>
<Link
href="/events"
className="text-gray-600 hover:text-primary-dark transition-colors"
>
{t('nav.events')}
</Link>
</li>
<li>
<Link
href="/community"
className="text-gray-600 hover:text-primary-dark transition-colors"
>
{t('nav.community')}
</Link>
</li>
<li>
<Link
href="/contact"
className="text-gray-600 hover:text-primary-dark transition-colors"
>
{t('nav.contact')}
</Link>
</li>
<li>
<Link
href="/faq"
className="text-gray-600 hover:text-primary-dark transition-colors"
>
{t('nav.faq')}
</Link>
</li>
</ul>
</div>
{/* Social */}
{socialLinks.length > 0 && (
<div>
<h3 className="font-semibold text-primary-dark mb-4">
{t('footer.social')}
</h3>
<div className="flex flex-wrap gap-3">
{socialLinks.map((link) => (
<a
key={link.type}
href={link.url}
target={link.type === 'email' ? undefined : '_blank'}
rel={link.type === 'email' ? undefined : 'noopener noreferrer'}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white shadow-sm hover:shadow-md hover:bg-primary-yellow/10 transition-all"
title={link.label}
>
{socialIcons[link.type]}
</a>
))}
</div>
</div>
)}
</div>
{/* Legal Links */}
<div className="border-t border-secondary-light-gray mt-10 pt-8">
<div className="flex flex-wrap justify-center gap-4 md:gap-6 mb-4">
{legalLinks.map((link) => (
<Link
key={link.slug}
href={`/legal/${link.slug}`}
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
>
{locale === 'es' ? link.es : link.en}
</Link>
))}
</div>
<div className="text-center text-gray-500 text-sm">
{t('footer.copyright', { year: currentYear })}
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import LanguageToggle from '@/components/LanguageToggle';
import Button from '@/components/ui/Button';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
export default function Header() {
const { t } = useLanguage();
const { user, isAdmin, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const navLinks = [
{ href: '/', label: t('nav.home') },
{ href: '/events', label: t('nav.events') },
{ href: '/community', label: t('nav.community') },
{ href: '/contact', label: t('nav.contact') },
];
return (
<header className="sticky top-0 z-50 bg-white shadow-sm">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center gap-2">
<span className="text-2xl font-bold font-heading text-primary-dark">
Span<span className="text-primary-yellow">glish</span>
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-gray-700 hover:text-primary-dark font-medium transition-colors"
>
{link.label}
</Link>
))}
</div>
{/* Right side actions */}
<div className="hidden md:flex items-center gap-4">
<LanguageToggle />
{user ? (
<div className="flex items-center gap-3">
<Link href="/dashboard">
<Button variant="ghost" size="sm">
{t('nav.dashboard')}
</Button>
</Link>
{isAdmin && (
<Link href="/admin">
<Button variant="ghost" size="sm">
{t('nav.admin')}
</Button>
</Link>
)}
<span className="text-sm text-gray-600">{user.name}</span>
<Button variant="outline" size="sm" onClick={logout}>
{t('nav.logout')}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Link href="/login">
<Button variant="ghost" size="sm">
{t('nav.login')}
</Button>
</Link>
<Link href="/events">
<Button size="sm">
{t('nav.joinEvent')}
</Button>
</Link>
</div>
)}
</div>
{/* Mobile menu button */}
<button
className="md:hidden p-2 rounded-lg hover:bg-gray-100"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? (
<XMarkIcon className="w-6 h-6" />
) : (
<Bars3Icon className="w-6 h-6" />
)}
</button>
</div>
{/* Mobile Navigation */}
<div
className={clsx(
'md:hidden overflow-hidden transition-all duration-300',
{
'max-h-0': !mobileMenuOpen,
'max-h-96 pb-4': mobileMenuOpen,
}
)}
>
<div className="flex flex-col gap-2 pt-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg font-medium"
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</Link>
))}
<div className="border-t border-gray-100 mt-2 pt-4 px-4">
<LanguageToggle variant="buttons" />
</div>
<div className="px-4 pt-2 flex flex-col gap-2">
{user ? (
<>
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">
{t('nav.dashboard')}
</Button>
</Link>
{isAdmin && (
<Link href="/admin" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">
{t('nav.admin')}
</Button>
</Link>
)}
<Button variant="secondary" onClick={logout} className="w-full">
{t('nav.logout')}
</Button>
</>
) : (
<>
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">
{t('nav.login')}
</Button>
</Link>
<Link href="/events" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full">
{t('nav.joinEvent')}
</Button>
</Link>
</>
)}
</div>
</div>
</div>
</nav>
</header>
);
}

View File

@@ -0,0 +1,191 @@
'use client';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
interface LegalPageLayoutProps {
title: string;
content: string;
lastUpdated?: string;
}
export default function LegalPageLayout({ title, content, lastUpdated }: LegalPageLayoutProps) {
return (
<div className="section-padding">
<div className="container-page max-w-4xl">
{/* Back link */}
<Link
href="/"
className="inline-flex items-center text-gray-600 hover:text-primary-dark transition-colors mb-8"
>
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Back to Home
</Link>
{/* Title */}
<div className="mb-8 pb-6 border-b border-gray-200">
<h1 className="text-3xl md:text-4xl font-bold text-primary-dark mb-2">
{title}
</h1>
{lastUpdated && lastUpdated !== '[Insert Date]' && (
<p className="text-sm text-gray-500">
Last updated: {lastUpdated}
</p>
)}
</div>
{/* Markdown content */}
<article className="prose prose-gray max-w-none legal-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Style headings
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-primary-dark mt-8 mb-4 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold text-primary-dark mt-8 mb-4 pb-2 border-b border-gray-200">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-primary-dark mt-6 mb-3">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-primary-dark mt-4 mb-2">
{children}
</h4>
),
// Style paragraphs
p: ({ children }) => (
<p className="text-gray-700 leading-relaxed mb-4">
{children}
</p>
),
// Style lists
ul: ({ children }) => (
<ul className="list-disc list-inside space-y-2 mb-4 text-gray-700 ml-4">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside space-y-2 mb-4 text-gray-700 ml-4">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-gray-700">
{children}
</li>
),
// Style links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
),
// Style horizontal rules
hr: () => (
<hr className="my-8 border-gray-200" />
),
// Style blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary-yellow pl-4 my-4 italic text-gray-600">
{children}
</blockquote>
),
// Style tables
table: ({ children }) => (
<div className="overflow-x-auto my-6">
<table className="min-w-full divide-y divide-gray-200 border border-gray-200 rounded-lg">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-gray-50">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="bg-white divide-y divide-gray-200">
{children}
</tbody>
),
tr: ({ children }) => (
<tr>
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left text-sm font-semibold text-primary-dark">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-sm text-gray-700">
{children}
</td>
),
// Style code blocks
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm text-gray-800">
{children}
</code>
);
}
return (
<code className={className}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-gray-100 rounded-lg p-4 overflow-x-auto my-4">
{children}
</pre>
),
// Style strong and emphasis
strong: ({ children }) => (
<strong className="font-semibold text-primary-dark">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic">
{children}
</em>
),
}}
>
{content}
</ReactMarkdown>
</article>
{/* Back to top link */}
<div className="mt-12 pt-6 border-t border-gray-200">
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
>
Back to top
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { ButtonHTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-medium transition-all duration-200 rounded-btn focus:outline-none focus:ring-2 focus:ring-offset-2',
{
// Variants
'bg-primary-yellow text-primary-dark hover:bg-yellow-400 focus:ring-yellow-400':
variant === 'primary',
'bg-primary-dark text-white hover:bg-gray-800 focus:ring-gray-800':
variant === 'secondary',
'border-2 border-primary-dark text-primary-dark bg-transparent hover:bg-gray-50 focus:ring-gray-400':
variant === 'outline',
'text-primary-dark hover:bg-gray-100 focus:ring-gray-300':
variant === 'ghost',
'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500':
variant === 'danger',
// Sizes
'text-sm px-3 py-1.5': size === 'sm',
'text-base px-5 py-2.5': size === 'md',
'text-lg px-7 py-3': size === 'lg',
// States
'opacity-50 cursor-not-allowed': disabled || isLoading,
},
className
)}
{...props}
>
{isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
</>
) : (
children
)}
</button>
);
}
);
Button.displayName = 'Button';
export default Button;

View File

@@ -0,0 +1,35 @@
'use client';
import { HTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx';
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'elevated' | 'bordered';
}
const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant = 'default', children, ...props }, ref) => {
return (
<div
ref={ref}
className={clsx(
'bg-white rounded-card overflow-hidden',
{
'shadow-card': variant === 'default',
'shadow-card-hover hover:shadow-lg transition-shadow duration-200':
variant === 'elevated',
'border border-secondary-light-gray': variant === 'bordered',
},
className
)}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
export default Card;

View File

@@ -0,0 +1,48 @@
'use client';
import { InputHTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, id, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label
htmlFor={id}
className="block text-sm font-medium text-primary-dark mb-1.5"
>
{label}
</label>
)}
<input
ref={ref}
id={id}
className={clsx(
'w-full px-4 py-3 rounded-btn border transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent',
'placeholder:text-gray-400',
{
'border-secondary-light-gray': !error,
'border-red-500 focus:ring-red-500': error,
},
className
)}
{...props}
/>
{error && (
<p className="mt-1.5 text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
export default Input;

View File

@@ -0,0 +1,173 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
interface User {
id: string;
email: string;
name: string;
role: string;
phone?: string;
languagePreference?: string;
isClaimed?: boolean;
rucNumber?: string;
accountStatus?: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
isAdmin: boolean;
login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (credential: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => void;
updateUser: (user: User) => void;
setAuthData: (data: { user: User; token: string }) => void;
}
interface RegisterData {
email: string;
password: string;
name: string;
phone?: string;
languagePreference?: string;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'spanglish-token';
const USER_KEY = 'spanglish-user';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Load auth state from localStorage
const savedToken = localStorage.getItem(TOKEN_KEY);
const savedUser = localStorage.getItem(USER_KEY);
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
}
setIsLoading(false);
}, []);
const setAuthData = useCallback((data: { user: User; token: string }) => {
setToken(data.token);
setUser(data.user);
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
}, []);
const login = async (email: string, password: string) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Login failed');
}
const data = await res.json();
setAuthData(data);
};
const loginWithGoogle = async (credential: string) => {
const res = await fetch('/api/auth/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Google login failed');
}
const data = await res.json();
setAuthData(data);
};
const loginWithMagicLink = async (magicToken: string) => {
const res = await fetch('/api/auth/magic-link/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: magicToken }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Magic link login failed');
}
const data = await res.json();
setAuthData(data);
};
const register = async (registerData: RegisterData) => {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registerData),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Registration failed');
}
const data = await res.json();
setAuthData(data);
};
const logout = useCallback(() => {
setToken(null);
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}, []);
const updateUser = useCallback((updatedUser: User) => {
setUser(updatedUser);
localStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
}, []);
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isAdmin,
login,
loginWithGoogle,
loginWithMagicLink,
register,
logout,
updateUser,
setAuthData,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,72 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Locale, defaultLocale, t, locales } from '@/i18n';
interface LanguageContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
const STORAGE_KEY = 'spanglish-locale';
export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(defaultLocale);
const [mounted, setMounted] = useState(false);
useEffect(() => {
// Load saved locale from localStorage
const saved = localStorage.getItem(STORAGE_KEY) as Locale | null;
if (saved && locales[saved]) {
setLocaleState(saved);
} else {
// Try to detect browser language
const browserLang = navigator.language.split('-')[0] as Locale;
if (locales[browserLang]) {
setLocaleState(browserLang);
}
}
setMounted(true);
}, []);
const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale);
localStorage.setItem(STORAGE_KEY, newLocale);
};
const translate = (key: string, params?: Record<string, string | number>) => {
return t(locale, key, params);
};
// Prevent hydration mismatch
if (!mounted) {
return (
<LanguageContext.Provider
value={{
locale: defaultLocale,
setLocale,
t: (key, params) => t(defaultLocale, key, params),
}}
>
{children}
</LanguageContext.Provider>
);
}
return (
<LanguageContext.Provider value={{ locale, setLocale, t: translate }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
}

View File

@@ -0,0 +1,59 @@
import en from './locales/en.json';
import es from './locales/es.json';
// Type for available locales - easily extendable
export type Locale = 'en' | 'es';
// Add new languages here
export const locales: Record<Locale, typeof en> = {
en,
es,
};
// Language display names
export const localeNames: Record<Locale, string> = {
en: 'English',
es: 'Español',
};
// Language flags (emoji or use icons)
export const localeFlags: Record<Locale, string> = {
en: '🇺🇸',
es: '🇪🇸',
};
export const defaultLocale: Locale = 'en';
// Get nested translation value
function getNestedValue(obj: any, path: string): string {
return path.split('.').reduce((acc, part) => acc && acc[part], obj) || path;
}
// Translation function
export function t(locale: Locale, key: string, params?: Record<string, string | number>): string {
const translations = locales[locale] || locales[defaultLocale];
let value = getNestedValue(translations, key);
// Replace parameters
if (params) {
Object.entries(params).forEach(([paramKey, paramValue]) => {
value = value.replace(`{${paramKey}}`, String(paramValue));
});
}
return value;
}
// HOW TO ADD NEW LANGUAGES:
// 1. Create a new JSON file in locales/ (e.g., pt.json for Portuguese)
// 2. Add the locale to the Locale type above
// 3. Import and add to the locales object
// 4. Add display name to localeNames
// 5. Add flag to localeFlags
//
// Example for Portuguese:
// import pt from './locales/pt.json';
// export type Locale = 'en' | 'es' | 'pt';
// export const locales = { en, es, pt };
// export const localeNames = { en: 'English', es: 'Español', pt: 'Português' };
// export const localeFlags = { en: '🇺🇸', es: '🇪🇸', pt: '🇧🇷' };

View File

@@ -0,0 +1,321 @@
{
"common": {
"loading": "Loading...",
"error": "An error occurred",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"search": "Search",
"filter": "Filter",
"submit": "Submit",
"back": "Back",
"next": "Next",
"viewAll": "View All",
"learnMore": "Learn More",
"moreInfo": "More Info"
},
"nav": {
"home": "Home",
"events": "Events",
"community": "Community",
"contact": "Contact",
"faq": "FAQ",
"joinEvent": "Join Event",
"login": "Login",
"register": "Sign Up",
"logout": "Logout",
"admin": "Admin",
"dashboard": "My Account"
},
"home": {
"hero": {
"title": "Practice English & Spanish in Asunción",
"subtitle": "Meet people. Learn languages. Have fun.",
"cta": "Join Next Event"
},
"about": {
"title": "What is Spanglish?",
"description": "Spanglish is a language exchange community where Spanish and English speakers come together to practice, learn, and connect. Our monthly events create a friendly environment for language learning through real conversations.",
"feature1": "Monthly Events",
"feature1Desc": "Regular meetups at welcoming venues",
"feature2": "Native Speakers",
"feature2Desc": "Practice with native English and Spanish speakers",
"feature3": "All Levels",
"feature3Desc": "Beginners to advanced are welcome"
},
"nextEvent": {
"title": "Next Event",
"noEvents": "No upcoming events scheduled",
"stayTuned": "Stay tuned for announcements!"
},
"gallery": {
"title": "Our Community"
},
"newsletter": {
"title": "Stay Updated",
"description": "Subscribe to get notified about upcoming events",
"placeholder": "Enter your email",
"button": "Subscribe",
"success": "Thanks for subscribing!",
"error": "Subscription failed. Please try again."
}
},
"events": {
"title": "Events",
"upcoming": "Upcoming Events",
"past": "Past Events",
"noEvents": "No events found",
"details": {
"date": "Date",
"time": "Time",
"location": "Location",
"price": "Price",
"free": "Free",
"capacity": "Capacity",
"spotsLeft": "spots left",
"soldOut": "Sold Out",
"cancelled": "Cancelled",
"eventEnded": "Event Ended"
},
"booking": {
"join": "Join Event",
"book": "Book Now",
"register": "Register"
}
},
"booking": {
"title": "Book Your Spot",
"form": {
"personalInfo": "Your Information",
"fullName": "Full Name",
"fullNamePlaceholder": "Enter your full name",
"firstName": "First Name",
"firstNamePlaceholder": "Enter your first name",
"lastName": "Last Name",
"lastNamePlaceholder": "Enter your last name",
"email": "Email Address",
"emailPlaceholder": "your@email.com",
"phone": "Phone / WhatsApp",
"phonePlaceholder": "+595 XXX XXX XXX",
"preferredLanguage": "Preferred Language",
"paymentMethod": "Payment Method",
"ruc": "RUC (Tax ID)",
"rucPlaceholder": "12345678-9",
"rucOptional": "Optional - for invoice",
"reserveSpot": "Reserve My Spot",
"proceedPayment": "Proceed to Payment",
"termsNote": "By booking, you agree to our terms and conditions.",
"soldOutMessage": "This event is fully booked. Check back later or browse other events.",
"errors": {
"nameRequired": "Please enter your full name",
"firstNameRequired": "Please enter your first name",
"lastNameRequired": "Please enter your last name",
"emailInvalid": "Please enter a valid email address",
"phoneRequired": "Phone number is required",
"bookingFailed": "Booking failed. Please try again.",
"rucInvalidFormat": "Invalid format. Example: 12345678-9",
"rucInvalidCheckDigit": "Invalid RUC. Please verify the number."
}
},
"summary": {
"title": "Booking Summary",
"event": "Event",
"date": "Date",
"price": "Price",
"total": "Total"
},
"confirm": "Confirm Booking",
"success": {
"title": "Booking Confirmed!",
"message": "Your spot has been reserved successfully!",
"description": "We've sent a confirmation to your email.",
"event": "Event",
"date": "Date",
"time": "Time",
"location": "Location",
"ticketId": "Ticket ID",
"instructions": "Please save this ticket ID. You'll need it at check-in.",
"cashNote": "Payment Required",
"cashDescription": "Please bring the exact amount in cash to pay at the event entrance.",
"cardNote": "You will be redirected to complete your card payment shortly.",
"lightningNote": "A Lightning invoice will be generated for payment.",
"emailSent": "A confirmation email has been sent to your inbox.",
"browseEvents": "Browse More Events",
"backHome": "Back to Home"
}
},
"community": {
"title": "Join Our Community",
"subtitle": "Connect with us on social media and stay updated",
"whatsapp": {
"title": "WhatsApp Group",
"description": "Join our WhatsApp group for event updates and community chat",
"button": "Join WhatsApp"
},
"instagram": {
"title": "Instagram",
"description": "Follow us for photos, stories, and announcements",
"button": "Follow Us"
},
"telegram": {
"title": "Telegram Channel",
"description": "Join our Telegram channel for news and announcements",
"button": "Join Telegram"
},
"guidelines": {
"title": "Community Guidelines",
"items": [
"Be respectful to all participants",
"Help others practice - we're all learning",
"Speak in the language you're practicing",
"Have fun and be open to making new friends"
]
},
"volunteer": {
"title": "Become a Volunteer",
"description": "Help us organize events and grow the community",
"button": "Contact Us"
}
},
"contact": {
"title": "Contact Us",
"subtitle": "Have questions? We'd love to hear from you.",
"form": {
"name": "Your Name",
"email": "Your Email",
"message": "Your Message",
"submit": "Send Message"
},
"success": "Message sent successfully!",
"error": "Failed to send message. Please try again.",
"info": {
"email": "Email",
"social": "Social Media"
}
},
"auth": {
"login": {
"title": "Welcome Back",
"subtitle": "Sign in to your account",
"email": "Email",
"password": "Password",
"submit": "Sign In",
"noAccount": "Don't have an account?",
"register": "Sign Up"
},
"register": {
"title": "Create Account",
"subtitle": "Join the Spanglish community",
"name": "Full Name",
"email": "Email",
"password": "Password (min. 8 characters)",
"phone": "Phone (optional)",
"submit": "Create Account",
"hasAccount": "Already have an account?",
"login": "Sign In"
},
"errors": {
"invalidCredentials": "Invalid email or password",
"emailExists": "Email already registered"
}
},
"admin": {
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back",
"stats": {
"users": "Total Users",
"events": "Total Events",
"tickets": "Total Tickets",
"revenue": "Total Revenue"
}
},
"nav": {
"dashboard": "Dashboard",
"events": "Events",
"bookings": "Bookings",
"tickets": "Tickets",
"users": "Users",
"payments": "Payments",
"contacts": "Messages",
"emails": "Emails",
"gallery": "Gallery",
"settings": "Settings"
},
"events": {
"title": "Manage Events",
"create": "Create Event",
"edit": "Edit Event",
"delete": "Delete Event",
"publish": "Publish",
"unpublish": "Unpublish"
},
"tickets": {
"title": "Manage Tickets",
"checkin": "Check In",
"cancel": "Cancel Ticket",
"status": {
"pending": "Pending",
"confirmed": "Confirmed",
"cancelled": "Cancelled",
"checkedIn": "Checked In"
}
},
"users": {
"title": "Manage Users",
"role": "Role",
"roles": {
"admin": "Admin",
"organizer": "Organizer",
"staff": "Staff",
"marketing": "Marketing",
"user": "User"
}
},
"payments": {
"title": "Payments",
"confirm": "Confirm Payment",
"refund": "Refund",
"status": {
"pending": "Pending",
"paid": "Paid",
"refunded": "Refunded",
"failed": "Failed"
}
}
},
"footer": {
"tagline": "Language exchange community in Asunción",
"links": "Quick Links",
"social": "Follow Us",
"copyright": "© {year} Spanglish. All rights reserved.",
"legal": {
"title": "Legal",
"terms": "Terms & Conditions",
"privacy": "Privacy Policy",
"refund": "Refund Policy"
}
},
"linktree": {
"tagline": "Language Exchange Community",
"nextEvent": "Next Event",
"noEvents": "No upcoming events",
"bookNow": "Book Now",
"joinCommunity": "Join Our Community",
"visitWebsite": "Visit Our Website",
"whatsapp": {
"title": "WhatsApp Community",
"subtitle": "Chat & event updates"
},
"telegram": {
"title": "Telegram Channel",
"subtitle": "News & announcements"
},
"instagram": {
"title": "Instagram",
"subtitle": "Photos & stories"
}
}
}

View File

@@ -0,0 +1,321 @@
{
"common": {
"loading": "Cargando...",
"error": "Ocurrió un error",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"create": "Crear",
"search": "Buscar",
"filter": "Filtrar",
"submit": "Enviar",
"back": "Volver",
"next": "Siguiente",
"viewAll": "Ver Todo",
"learnMore": "Saber Más",
"moreInfo": "Más Info"
},
"nav": {
"home": "Inicio",
"events": "Eventos",
"community": "Comunidad",
"contact": "Contacto",
"faq": "Preguntas Frecuentes",
"joinEvent": "Unirse al Evento",
"login": "Iniciar Sesión",
"register": "Registrarse",
"logout": "Cerrar Sesión",
"admin": "Admin",
"dashboard": "Mi Cuenta"
},
"home": {
"hero": {
"title": "Practica Inglés y Español en Asunción",
"subtitle": "Conoce gente. Aprende idiomas. Diviértete.",
"cta": "Unirse al Próximo Evento"
},
"about": {
"title": "¿Qué es Spanglish?",
"description": "Spanglish es una comunidad de intercambio de idiomas donde hablantes de español e inglés se reúnen para practicar, aprender y conectar. Nuestros eventos mensuales crean un ambiente amigable para el aprendizaje de idiomas a través de conversaciones reales.",
"feature1": "Eventos Mensuales",
"feature1Desc": "Encuentros regulares en lugares acogedores",
"feature2": "Hablantes Nativos",
"feature2Desc": "Practica con hablantes nativos de inglés y español",
"feature3": "Todos los Niveles",
"feature3Desc": "Desde principiantes hasta avanzados"
},
"nextEvent": {
"title": "Próximo Evento",
"noEvents": "No hay eventos programados",
"stayTuned": "¡Mantente atento a los anuncios!"
},
"gallery": {
"title": "Nuestra Comunidad"
},
"newsletter": {
"title": "Mantente Informado",
"description": "Suscríbete para recibir notificaciones sobre próximos eventos",
"placeholder": "Ingresa tu email",
"button": "Suscribirse",
"success": "¡Gracias por suscribirte!",
"error": "Error al suscribirse. Por favor intenta de nuevo."
}
},
"events": {
"title": "Eventos",
"upcoming": "Próximos Eventos",
"past": "Eventos Pasados",
"noEvents": "No se encontraron eventos",
"details": {
"date": "Fecha",
"time": "Hora",
"location": "Ubicación",
"price": "Precio",
"free": "Gratis",
"capacity": "Capacidad",
"spotsLeft": "lugares disponibles",
"soldOut": "Agotado",
"cancelled": "Cancelado",
"eventEnded": "Evento Finalizado"
},
"booking": {
"join": "Unirse al Evento",
"book": "Reservar Ahora",
"register": "Registrarse"
}
},
"booking": {
"title": "Reserva tu Lugar",
"form": {
"personalInfo": "Tu Información",
"fullName": "Nombre Completo",
"fullNamePlaceholder": "Ingresa tu nombre completo",
"firstName": "Nombre",
"firstNamePlaceholder": "Ingresa tu nombre",
"lastName": "Apellido",
"lastNamePlaceholder": "Ingresa tu apellido",
"email": "Correo Electrónico",
"emailPlaceholder": "tu@email.com",
"phone": "Teléfono / WhatsApp",
"phonePlaceholder": "+595 XXX XXX XXX",
"preferredLanguage": "Idioma Preferido",
"paymentMethod": "Método de Pago",
"ruc": "RUC",
"rucPlaceholder": "Ej: 12345678-9",
"rucOptional": "Opcional - para facturación",
"reserveSpot": "Reservar Mi Lugar",
"proceedPayment": "Proceder al Pago",
"termsNote": "Al reservar, aceptas nuestros términos y condiciones.",
"soldOutMessage": "Este evento está lleno. Vuelve más tarde o explora otros eventos.",
"errors": {
"nameRequired": "Por favor ingresa tu nombre completo",
"firstNameRequired": "Por favor ingresa tu nombre",
"lastNameRequired": "Por favor ingresa tu apellido",
"emailInvalid": "Por favor ingresa un correo electrónico válido",
"phoneRequired": "El número de teléfono es requerido",
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
"rucInvalidFormat": "Formato inválido. Ej: 12345678-9",
"rucInvalidCheckDigit": "RUC inválido. Verifique el número."
}
},
"summary": {
"title": "Resumen de Reserva",
"event": "Evento",
"date": "Fecha",
"price": "Precio",
"total": "Total"
},
"confirm": "Confirmar Reserva",
"success": {
"title": "¡Reserva Confirmada!",
"message": "¡Tu lugar ha sido reservado exitosamente!",
"description": "Hemos enviado una confirmación a tu correo.",
"event": "Evento",
"date": "Fecha",
"time": "Hora",
"location": "Ubicación",
"ticketId": "ID del Ticket",
"instructions": "Por favor guarda este ID de ticket. Lo necesitarás en el check-in.",
"cashNote": "Pago Requerido",
"cashDescription": "Por favor trae el monto exacto en efectivo para pagar en la entrada del evento.",
"cardNote": "Serás redirigido para completar tu pago con tarjeta en breve.",
"lightningNote": "Se generará una factura Lightning para el pago.",
"emailSent": "Un correo de confirmación ha sido enviado a tu bandeja de entrada.",
"browseEvents": "Ver Más Eventos",
"backHome": "Volver al Inicio"
}
},
"community": {
"title": "Únete a Nuestra Comunidad",
"subtitle": "Conéctate con nosotros en redes sociales",
"whatsapp": {
"title": "Grupo de WhatsApp",
"description": "Únete a nuestro grupo de WhatsApp para actualizaciones y chat comunitario",
"button": "Unirse a WhatsApp"
},
"instagram": {
"title": "Instagram",
"description": "Síguenos para fotos, historias y anuncios",
"button": "Seguirnos"
},
"telegram": {
"title": "Canal de Telegram",
"description": "Únete a nuestro canal de Telegram para noticias y anuncios",
"button": "Unirse a Telegram"
},
"guidelines": {
"title": "Reglas de la Comunidad",
"items": [
"Sé respetuoso con todos los participantes",
"Ayuda a otros a practicar - todos estamos aprendiendo",
"Habla en el idioma que estás practicando",
"Diviértete y abierto a hacer nuevos amigos"
]
},
"volunteer": {
"title": "Conviértete en Voluntario",
"description": "Ayúdanos a organizar eventos y hacer crecer la comunidad",
"button": "Contáctanos"
}
},
"contact": {
"title": "Contáctanos",
"subtitle": "¿Tienes preguntas? Nos encantaría saber de ti.",
"form": {
"name": "Tu Nombre",
"email": "Tu Email",
"message": "Tu Mensaje",
"submit": "Enviar Mensaje"
},
"success": "¡Mensaje enviado exitosamente!",
"error": "Error al enviar el mensaje. Por favor intenta de nuevo.",
"info": {
"email": "Email",
"social": "Redes Sociales"
}
},
"auth": {
"login": {
"title": "Bienvenido de Nuevo",
"subtitle": "Inicia sesión en tu cuenta",
"email": "Email",
"password": "Contraseña",
"submit": "Iniciar Sesión",
"noAccount": "¿No tienes cuenta?",
"register": "Registrarse"
},
"register": {
"title": "Crear Cuenta",
"subtitle": "Únete a la comunidad Spanglish",
"name": "Nombre Completo",
"email": "Email",
"password": "Contraseña (mín. 8 caracteres)",
"phone": "Teléfono (opcional)",
"submit": "Crear Cuenta",
"hasAccount": "¿Ya tienes cuenta?",
"login": "Iniciar Sesión"
},
"errors": {
"invalidCredentials": "Email o contraseña inválidos",
"emailExists": "El email ya está registrado"
}
},
"admin": {
"dashboard": {
"title": "Panel de Control",
"welcome": "Bienvenido de nuevo",
"stats": {
"users": "Usuarios Totales",
"events": "Eventos Totales",
"tickets": "Tickets Totales",
"revenue": "Ingresos Totales"
}
},
"nav": {
"dashboard": "Panel",
"events": "Eventos",
"bookings": "Reservas",
"tickets": "Tickets",
"users": "Usuarios",
"payments": "Pagos",
"contacts": "Mensajes",
"emails": "Emails",
"gallery": "Galería",
"settings": "Configuración"
},
"events": {
"title": "Gestionar Eventos",
"create": "Crear Evento",
"edit": "Editar Evento",
"delete": "Eliminar Evento",
"publish": "Publicar",
"unpublish": "Despublicar"
},
"tickets": {
"title": "Gestionar Tickets",
"checkin": "Check In",
"cancel": "Cancelar Ticket",
"status": {
"pending": "Pendiente",
"confirmed": "Confirmado",
"cancelled": "Cancelado",
"checkedIn": "Check In Realizado"
}
},
"users": {
"title": "Gestionar Usuarios",
"role": "Rol",
"roles": {
"admin": "Administrador",
"organizer": "Organizador",
"staff": "Staff",
"marketing": "Marketing",
"user": "Usuario"
}
},
"payments": {
"title": "Pagos",
"confirm": "Confirmar Pago",
"refund": "Reembolsar",
"status": {
"pending": "Pendiente",
"paid": "Pagado",
"refunded": "Reembolsado",
"failed": "Fallido"
}
}
},
"footer": {
"tagline": "Comunidad de intercambio de idiomas en Asunción",
"links": "Enlaces Rápidos",
"social": "Síguenos",
"copyright": "© {year} Spanglish. Todos los derechos reservados.",
"legal": {
"title": "Legal",
"terms": "Términos y Condiciones",
"privacy": "Política de Privacidad",
"refund": "Política de Reembolso"
}
},
"linktree": {
"tagline": "Comunidad de Intercambio de Idiomas",
"nextEvent": "Próximo Evento",
"noEvents": "No hay eventos próximos",
"bookNow": "Reservar Ahora",
"joinCommunity": "Únete a Nuestra Comunidad",
"visitWebsite": "Visitar Nuestro Sitio",
"whatsapp": {
"title": "Comunidad WhatsApp",
"subtitle": "Chat y novedades"
},
"telegram": {
"title": "Canal de Telegram",
"subtitle": "Noticias y anuncios"
},
"instagram": {
"title": "Instagram",
"subtitle": "Fotos e historias"
}
}
}

882
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,882 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
export interface ApiError {
error: string;
}
async function fetchApi<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = typeof window !== 'undefined'
? localStorage.getItem('spanglish-token')
: null;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Request failed' }));
const errorMessage = typeof errorData.error === 'string'
? errorData.error
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
throw new Error(errorMessage);
}
return res.json();
}
// Events API
export const eventsApi = {
getAll: (params?: { status?: string; upcoming?: boolean }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.upcoming) query.set('upcoming', 'true');
return fetchApi<{ events: Event[] }>(`/api/events?${query}`);
},
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
getNextUpcoming: () => fetchApi<{ event: Event | null }>('/api/events/next/upcoming'),
create: (data: Partial<Event>) =>
fetchApi<{ event: Event }>('/api/events', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: Partial<Event>) =>
fetchApi<{ event: Event }>(`/api/events/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: string) =>
fetchApi<{ message: string }>(`/api/events/${id}`, { method: 'DELETE' }),
duplicate: (id: string) =>
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }),
};
// Tickets API
export const ticketsApi = {
book: (data: BookingData) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets', {
method: 'POST',
body: JSON.stringify(data),
}),
getById: (id: string) => fetchApi<{ ticket: Ticket }>(`/api/tickets/${id}`),
getAll: (params?: { eventId?: string; status?: string }) => {
const query = new URLSearchParams();
if (params?.eventId) query.set('eventId', params.eventId);
if (params?.status) query.set('status', params.status);
return fetchApi<{ tickets: Ticket[] }>(`/api/tickets?${query}`);
},
checkin: (id: string) =>
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/checkin`, {
method: 'POST',
}),
removeCheckin: (id: string) =>
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/remove-checkin`, {
method: 'POST',
}),
cancel: (id: string) =>
fetchApi<{ message: string }>(`/api/tickets/${id}/cancel`, { method: 'POST' }),
updateStatus: (id: string, status: string) =>
fetchApi<{ ticket: Ticket }>(`/api/tickets/${id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
}),
updateNote: (id: string, note: string) =>
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/note`, {
method: 'POST',
body: JSON.stringify({ note }),
}),
markPaid: (id: string) =>
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/mark-paid`, {
method: 'POST',
}),
// For manual payment methods (bank_transfer, tpago) - user marks payment as sent
markPaymentSent: (id: string) =>
fetchApi<{ payment: Payment; message: string }>(`/api/tickets/${id}/mark-payment-sent`, {
method: 'POST',
}),
adminCreate: (data: {
eventId: string;
firstName: string;
lastName?: string;
email?: string;
phone?: string;
preferredLanguage?: 'en' | 'es';
autoCheckin?: boolean;
adminNote?: string;
}) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/create', {
method: 'POST',
body: JSON.stringify(data),
}),
checkPaymentStatus: (ticketId: string) =>
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
`/api/lnbits/status/${ticketId}`
),
};
// Contacts API
export const contactsApi = {
submit: (data: { name: string; email: string; message: string }) =>
fetchApi<{ message: string }>('/api/contacts', {
method: 'POST',
body: JSON.stringify(data),
}),
subscribe: (email: string, name?: string) =>
fetchApi<{ message: string }>('/api/contacts/subscribe', {
method: 'POST',
body: JSON.stringify({ email, name }),
}),
getAll: (status?: string) => {
const query = status ? `?status=${status}` : '';
return fetchApi<{ contacts: Contact[] }>(`/api/contacts${query}`);
},
updateStatus: (id: string, status: string) =>
fetchApi<{ contact: Contact }>(`/api/contacts/${id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
}),
};
// Users API
export const usersApi = {
getAll: (role?: string) => {
const query = role ? `?role=${role}` : '';
return fetchApi<{ users: User[] }>(`/api/users${query}`);
},
getById: (id: string) => fetchApi<{ user: User }>(`/api/users/${id}`),
update: (id: string, data: Partial<User>) =>
fetchApi<{ user: User }>(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: string) =>
fetchApi<{ message: string }>(`/api/users/${id}`, { method: 'DELETE' }),
};
// Payments API
export const paymentsApi = {
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.provider) query.set('provider', params.provider);
if (params?.pendingApproval) query.set('pendingApproval', 'true');
return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`);
},
getPendingApproval: () =>
fetchApi<{ payments: PaymentWithDetails[] }>('/api/payments/pending-approval'),
update: (id: string, data: { status: string; reference?: string; adminNote?: string }) =>
fetchApi<{ payment: Payment }>(`/api/payments/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
approve: (id: string, adminNote?: string) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ adminNote }),
}),
reject: (id: string, adminNote?: string) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ adminNote }),
}),
updateNote: (id: string, adminNote: string) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/note`, {
method: 'POST',
body: JSON.stringify({ adminNote }),
}),
refund: (id: string) =>
fetchApi<{ message: string }>(`/api/payments/${id}/refund`, { method: 'POST' }),
};
// Payment Options API
export const paymentOptionsApi = {
// Global payment options
getGlobal: () =>
fetchApi<{ paymentOptions: PaymentOptionsConfig }>('/api/payment-options'),
updateGlobal: (data: Partial<PaymentOptionsConfig>) =>
fetchApi<{ paymentOptions: PaymentOptionsConfig; message: string }>('/api/payment-options', {
method: 'PUT',
body: JSON.stringify(data),
}),
// Event-specific options (merged with global)
getForEvent: (eventId: string) =>
fetchApi<{ paymentOptions: PaymentOptionsConfig; hasOverrides: boolean }>(
`/api/payment-options/event/${eventId}`
),
// Event overrides (admin only)
getEventOverrides: (eventId: string) =>
fetchApi<{ overrides: Partial<PaymentOptionsConfig> | null }>(
`/api/payment-options/event/${eventId}/overrides`
),
updateEventOverrides: (eventId: string, data: Partial<PaymentOptionsConfig>) =>
fetchApi<{ overrides: Partial<PaymentOptionsConfig>; message: string }>(
`/api/payment-options/event/${eventId}/overrides`,
{
method: 'PUT',
body: JSON.stringify(data),
}
),
deleteEventOverrides: (eventId: string) =>
fetchApi<{ message: string }>(`/api/payment-options/event/${eventId}/overrides`, {
method: 'DELETE',
}),
};
// Media API
export const mediaApi = {
upload: async (file: File, relatedId?: string, relatedType?: string) => {
const token = typeof window !== 'undefined'
? localStorage.getItem('spanglish-token')
: null;
const formData = new FormData();
formData.append('file', file);
if (relatedId) formData.append('relatedId', relatedId);
if (relatedType) formData.append('relatedType', relatedType);
const res = await fetch(`${API_BASE}/api/media/upload`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Upload failed' }));
throw new Error(errorData.error || 'Upload failed');
}
return res.json() as Promise<{ media: Media; url: string }>;
},
delete: (id: string) =>
fetchApi<{ message: string }>(`/api/media/${id}`, { method: 'DELETE' }),
};
// Admin API
export const adminApi = {
getDashboard: () => fetchApi<{ dashboard: DashboardData }>('/api/admin/dashboard'),
getAnalytics: () => fetchApi<{ analytics: AnalyticsData }>('/api/admin/analytics'),
exportTickets: (eventId?: string) => {
const query = eventId ? `?eventId=${eventId}` : '';
return fetchApi<{ tickets: ExportedTicket[] }>(`/api/admin/export/tickets${query}`);
},
exportFinancial: (params?: { startDate?: string; endDate?: string; eventId?: string }) => {
const query = new URLSearchParams();
if (params?.startDate) query.set('startDate', params.startDate);
if (params?.endDate) query.set('endDate', params.endDate);
if (params?.eventId) query.set('eventId', params.eventId);
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
},
};
// Emails API
export const emailsApi = {
// Templates
getTemplates: () => fetchApi<{ templates: EmailTemplate[] }>('/api/emails/templates'),
getTemplate: (id: string) => fetchApi<{ template: EmailTemplate }>(`/api/emails/templates/${id}`),
createTemplate: (data: Partial<EmailTemplate>) =>
fetchApi<{ template: EmailTemplate; message: string }>('/api/emails/templates', {
method: 'POST',
body: JSON.stringify(data),
}),
updateTemplate: (id: string, data: Partial<EmailTemplate>) =>
fetchApi<{ template: EmailTemplate; message: string }>(`/api/emails/templates/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteTemplate: (id: string) =>
fetchApi<{ message: string }>(`/api/emails/templates/${id}`, { method: 'DELETE' }),
getTemplateVariables: (slug: string) =>
fetchApi<{ variables: EmailVariable[] }>(`/api/emails/templates/${slug}/variables`),
// Sending
sendToEvent: (eventId: string, data: {
templateSlug: string;
customVariables?: Record<string, any>;
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
}) =>
fetchApi<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }>(
`/api/emails/send/event/${eventId}`,
{
method: 'POST',
body: JSON.stringify(data),
}
),
sendCustom: (data: {
to: string;
toName?: string;
subject: string;
bodyHtml: string;
bodyText?: string;
eventId?: string;
}) =>
fetchApi<{ success: boolean; logId?: string; error?: string }>('/api/emails/send/custom', {
method: 'POST',
body: JSON.stringify(data),
}),
preview: (data: {
templateSlug: string;
variables?: Record<string, any>;
locale?: string;
}) =>
fetchApi<{ subject: string; bodyHtml: string }>('/api/emails/preview', {
method: 'POST',
body: JSON.stringify(data),
}),
// Logs
getLogs: (params?: { eventId?: string; status?: string; limit?: number; offset?: number }) => {
const query = new URLSearchParams();
if (params?.eventId) query.set('eventId', params.eventId);
if (params?.status) query.set('status', params.status);
if (params?.limit) query.set('limit', params.limit.toString());
if (params?.offset) query.set('offset', params.offset.toString());
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
},
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
getStats: (eventId?: string) => {
const query = eventId ? `?eventId=${eventId}` : '';
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
},
seedTemplates: () =>
fetchApi<{ message: string }>('/api/emails/seed-templates', { method: 'POST' }),
};
// Types
export interface Event {
id: string;
title: string;
titleEs?: string;
description: string;
descriptionEs?: string;
startDatetime: string;
endDatetime?: string;
location: string;
locationUrl?: string;
price: number;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string;
bookedCount?: number;
availableSeats?: number;
createdAt: string;
updatedAt: string;
}
export interface Ticket {
id: string;
userId: string;
eventId: string;
attendeeFirstName: string;
attendeeLastName?: string;
attendeeEmail?: string;
attendeePhone?: string;
preferredLanguage?: string;
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
checkinAt?: string;
qrCode: string;
adminNote?: string;
createdAt: string;
event?: Event;
payment?: Payment;
user?: User;
}
export interface Payment {
id: string;
ticketId: string;
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
amount: number;
currency: string;
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed';
reference?: string;
userMarkedPaidAt?: string;
paidAt?: string;
paidByAdminId?: string;
adminNote?: string;
createdAt: string;
updatedAt: string;
}
export interface PaymentWithDetails extends Payment {
ticket: {
id: string;
attendeeFirstName: string;
attendeeLastName?: string;
attendeeEmail?: string;
attendeePhone?: string;
status: string;
} | null;
event: {
id: string;
title: string;
startDatetime: string;
} | null;
}
export interface PaymentOptionsConfig {
tpagoEnabled: boolean;
tpagoLink?: string | null;
tpagoInstructions?: string | null;
tpagoInstructionsEs?: string | null;
bankTransferEnabled: boolean;
bankName?: string | null;
bankAccountHolder?: string | null;
bankAccountNumber?: string | null;
bankAlias?: string | null;
bankPhone?: string | null;
bankNotes?: string | null;
bankNotesEs?: string | null;
lightningEnabled: boolean;
cashEnabled: boolean;
cashInstructions?: string | null;
cashInstructionsEs?: string | null;
}
export interface User {
id: string;
email: string;
name: string;
phone?: string;
role: 'admin' | 'organizer' | 'staff' | 'marketing' | 'user';
languagePreference?: string;
isClaimed?: boolean;
rucNumber?: string;
accountStatus?: string;
createdAt: string;
}
export interface Contact {
id: string;
name: string;
email: string;
message: string;
status: 'new' | 'read' | 'replied';
createdAt: string;
}
export interface BookingData {
eventId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
preferredLanguage?: 'en' | 'es';
paymentMethod: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
ruc?: string;
}
export interface DashboardData {
stats: {
totalUsers: number;
totalEvents: number;
totalTickets: number;
confirmedTickets: number;
pendingPayments: number;
totalRevenue: number;
newContacts: number;
totalSubscribers: number;
};
upcomingEvents: Event[];
recentTickets: Ticket[];
}
export interface AnalyticsData {
events: {
id: string;
title: string;
date: string;
capacity: number;
totalBookings: number;
confirmedBookings: number;
checkedIn: number;
revenue: number;
}[];
}
export interface Media {
id: string;
fileUrl: string;
type: 'image' | 'video' | 'document';
relatedId?: string;
relatedType?: string;
createdAt: string;
}
export interface ExportedTicket {
ticketId: string;
ticketStatus: string;
qrCode: string;
checkinAt?: string;
userName: string;
userEmail: string;
userPhone?: string;
eventTitle: string;
eventDate: string;
paymentStatus: string;
paymentAmount: number;
createdAt: string;
}
export interface EmailTemplate {
id: string;
name: string;
slug: string;
subject: string;
subjectEs?: string;
bodyHtml: string;
bodyHtmlEs?: string;
bodyText?: string;
bodyTextEs?: string;
description?: string;
variables: EmailVariable[];
isSystem: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface EmailVariable {
name: string;
description: string;
example: string;
}
export interface EmailLog {
id: string;
templateId?: string;
eventId?: string;
recipientEmail: string;
recipientName?: string;
subject: string;
bodyHtml?: string;
status: 'pending' | 'sent' | 'failed' | 'bounced';
errorMessage?: string;
sentAt?: string;
sentBy?: string;
createdAt: string;
}
export interface EmailStats {
total: number;
sent: number;
failed: number;
pending: number;
}
export interface Pagination {
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
export interface ExportedPayment {
paymentId: string;
amount: number;
currency: string;
provider: string;
status: string;
reference?: string;
paidAt?: string;
createdAt: string;
ticketId: string;
attendeeFirstName: string;
attendeeLastName?: string;
attendeeEmail?: string;
eventId: string;
eventTitle: string;
eventDate: string;
}
export interface FinancialSummary {
totalPayments: number;
totalPaid: number;
totalPending: number;
totalRefunded: number;
byProvider: {
bancard: number;
lightning: number;
cash: number;
bank_transfer: number;
tpago: number;
};
paidCount: number;
pendingCount: number;
pendingApprovalCount: number;
refundedCount: number;
failedCount: number;
}
// ==================== User Dashboard Types ====================
export interface UserProfile {
id: string;
email: string;
name: string;
phone?: string;
languagePreference?: string;
rucNumber?: string;
isClaimed: boolean;
accountStatus: string;
hasPassword: boolean;
hasGoogleLinked: boolean;
memberSince: string;
membershipDays: number;
createdAt: string;
}
export interface UserTicket extends Ticket {
invoice?: {
id: string;
invoiceNumber: string;
pdfUrl?: string;
createdAt: string;
} | null;
}
export interface UserPayment extends Payment {
ticket: {
id: string;
attendeeFirstName: string;
attendeeLastName?: string;
status: string;
} | null;
event: {
id: string;
title: string;
titleEs?: string;
startDatetime: string;
} | null;
invoice?: {
id: string;
invoiceNumber: string;
pdfUrl?: string;
} | null;
}
export interface UserInvoice {
id: string;
paymentId: string;
invoiceNumber: string;
rucNumber?: string;
legalName?: string;
amount: number;
currency: string;
pdfUrl?: string;
status: string;
createdAt: string;
event?: {
id: string;
title: string;
titleEs?: string;
startDatetime: string;
} | null;
}
export interface UserSession {
id: string;
userAgent?: string;
ipAddress?: string;
lastActiveAt: string;
createdAt: string;
}
export interface DashboardSummary {
user: {
name: string;
email: string;
accountStatus: string;
memberSince: string;
membershipDays: number;
};
stats: {
totalTickets: number;
confirmedTickets: number;
upcomingEvents: number;
pendingPayments: number;
};
}
export interface NextEventInfo {
event: Event;
ticket: Ticket;
payment: Payment | null;
}
// ==================== Auth API (new methods) ====================
export const authApi = {
// Magic link
requestMagicLink: (email: string) =>
fetchApi<{ message: string }>('/api/auth/magic-link/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
verifyMagicLink: (token: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', {
method: 'POST',
body: JSON.stringify({ token }),
}),
// Password reset
requestPasswordReset: (email: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
confirmPasswordReset: (token: string, password: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/confirm', {
method: 'POST',
body: JSON.stringify({ token, password }),
}),
// Account claiming
requestClaimAccount: (email: string) =>
fetchApi<{ message: string }>('/api/auth/claim-account/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) =>
fetchApi<{ user: User; token: string; refreshToken: string; message: string }>(
'/api/auth/claim-account/confirm',
{
method: 'POST',
body: JSON.stringify({ token, ...data }),
}
),
// Google OAuth
googleAuth: (credential: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', {
method: 'POST',
body: JSON.stringify({ credential }),
}),
// Change password
changePassword: (currentPassword: string, newPassword: string) =>
fetchApi<{ message: string }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
}),
// Get current user
me: () => fetchApi<{ user: User }>('/api/auth/me'),
};
// ==================== User Dashboard API ====================
export const dashboardApi = {
// Summary
getSummary: () =>
fetchApi<{ summary: DashboardSummary }>('/api/dashboard/summary'),
// Profile
getProfile: () =>
fetchApi<{ profile: UserProfile }>('/api/dashboard/profile'),
updateProfile: (data: { name?: string; phone?: string; languagePreference?: string; rucNumber?: string }) =>
fetchApi<{ profile: UserProfile; message: string }>('/api/dashboard/profile', {
method: 'PUT',
body: JSON.stringify(data),
}),
// Tickets
getTickets: () =>
fetchApi<{ tickets: UserTicket[] }>('/api/dashboard/tickets'),
getTicket: (id: string) =>
fetchApi<{ ticket: UserTicket }>(`/api/dashboard/tickets/${id}`),
// Next event
getNextEvent: () =>
fetchApi<{ nextEvent: NextEventInfo | null }>('/api/dashboard/next-event'),
// Payments
getPayments: () =>
fetchApi<{ payments: UserPayment[] }>('/api/dashboard/payments'),
// Invoices
getInvoices: () =>
fetchApi<{ invoices: UserInvoice[] }>('/api/dashboard/invoices'),
// Sessions
getSessions: () =>
fetchApi<{ sessions: UserSession[] }>('/api/dashboard/sessions'),
revokeSession: (id: string) =>
fetchApi<{ message: string }>(`/api/dashboard/sessions/${id}`, { method: 'DELETE' }),
revokeAllSessions: () =>
fetchApi<{ message: string }>('/api/dashboard/sessions/revoke-all', { method: 'POST' }),
// Security
setPassword: (password: string) =>
fetchApi<{ message: string }>('/api/dashboard/set-password', {
method: 'POST',
body: JSON.stringify({ password }),
}),
unlinkGoogle: () =>
fetchApi<{ message: string }>('/api/dashboard/unlink-google', { method: 'POST' }),
};

98
frontend/src/lib/legal.ts Normal file
View File

@@ -0,0 +1,98 @@
import fs from 'fs';
import path from 'path';
export interface LegalPage {
slug: string;
title: string;
content: string;
lastUpdated?: string;
}
export interface LegalPageMeta {
slug: string;
title: string;
}
// Map file names to display titles
const titleMap: Record<string, { en: string; es: string }> = {
'privacy_policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
'terms_policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
'refund_cancelation_policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
};
// Convert file name to URL-friendly slug
export function fileNameToSlug(fileName: string): string {
return fileName.replace('.md', '').replace(/_/g, '-');
}
// Convert slug back to file name
export function slugToFileName(slug: string): string {
return slug.replace(/-/g, '_') + '.md';
}
// Get the legal directory path
function getLegalDir(): string {
return path.join(process.cwd(), 'legal');
}
// Get all legal page slugs for static generation
export function getAllLegalSlugs(): string[] {
const legalDir = getLegalDir();
if (!fs.existsSync(legalDir)) {
return [];
}
const files = fs.readdirSync(legalDir);
return files
.filter(file => file.endsWith('.md'))
.map(file => fileNameToSlug(file));
}
// Get metadata for all legal pages (for navigation/footer)
export function getAllLegalPagesMeta(locale: string = 'en'): LegalPageMeta[] {
const legalDir = getLegalDir();
if (!fs.existsSync(legalDir)) {
return [];
}
const files = fs.readdirSync(legalDir);
return files
.filter(file => file.endsWith('.md'))
.map(file => {
const slug = fileNameToSlug(file);
const baseFileName = file.replace('.md', '');
const titles = titleMap[baseFileName];
const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' ');
return { slug, title };
});
}
// Get a specific legal page content
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null {
const legalDir = getLegalDir();
const fileName = slugToFileName(slug);
const filePath = path.join(legalDir, fileName);
if (!fs.existsSync(filePath)) {
return null;
}
const content = fs.readFileSync(filePath, 'utf-8');
const baseFileName = fileName.replace('.md', '');
const titles = titleMap[baseFileName];
const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' ');
// Try to extract last updated date from content
const lastUpdatedMatch = content.match(/Last updated:\s*(.+)/i);
const lastUpdated = lastUpdatedMatch ? lastUpdatedMatch[1].trim() : undefined;
return {
slug,
title,
content,
lastUpdated,
};
}

View File

@@ -0,0 +1,163 @@
// Social links configuration - reads from environment variables
// All links are optional - if not set, they won't be displayed
export interface SocialLinks {
whatsapp?: string;
instagram?: string;
email?: string;
telegram?: string;
}
export interface SocialLink {
type: 'whatsapp' | 'instagram' | 'email' | 'telegram';
url: string;
label: string;
handle?: string;
}
// Get raw values from env
export const socialConfig: SocialLinks = {
whatsapp: process.env.NEXT_PUBLIC_WHATSAPP || undefined,
instagram: process.env.NEXT_PUBLIC_INSTAGRAM || undefined,
email: process.env.NEXT_PUBLIC_EMAIL || undefined,
telegram: process.env.NEXT_PUBLIC_TELEGRAM || undefined,
};
// Generate URLs from handles/values
export function getWhatsAppUrl(value?: string): string | null {
if (!value) return null;
// If it's already a full URL (group invite link or wa.me link), return as-is
if (value.startsWith('https://') || value.startsWith('http://')) {
return value;
}
// Otherwise, treat as phone number - remove any non-digit characters except +
const clean = value.replace(/[^\d+]/g, '');
return `https://wa.me/${clean.replace('+', '')}`;
}
export function getInstagramUrl(value?: string): string | null {
if (!value) return null;
// If it's already a full URL, return as-is
if (value.startsWith('https://') || value.startsWith('http://')) {
return value;
}
// Otherwise, treat as handle - remove @ if present
const clean = value.replace('@', '');
return `https://instagram.com/${clean}`;
}
export function getEmailUrl(email?: string): string | null {
if (!email) return null;
return `mailto:${email}`;
}
export function getTelegramUrl(value?: string): string | null {
if (!value) return null;
// If it's already a full URL, return as-is
if (value.startsWith('https://') || value.startsWith('http://')) {
return value;
}
// Otherwise, treat as handle - remove @ if present
const clean = value.replace('@', '');
return `https://t.me/${clean}`;
}
// Extract display handle from URL or value
function extractInstagramHandle(value: string): string {
// If it's a URL, extract the username from the path
if (value.startsWith('http')) {
const match = value.match(/instagram\.com\/([^/?]+)/);
return match ? `@${match[1]}` : '@instagram';
}
// Otherwise it's already a handle
return `@${value.replace('@', '')}`;
}
function extractTelegramHandle(value: string): string {
// If it's a URL, extract the channel/username from the path
if (value.startsWith('http')) {
const match = value.match(/t\.me\/([^/?]+)/);
return match ? `@${match[1]}` : '@telegram';
}
// Otherwise it's already a handle
return `@${value.replace('@', '')}`;
}
// Get all active social links as an array
export function getSocialLinks(): SocialLink[] {
const links: SocialLink[] = [];
if (socialConfig.whatsapp) {
const url = getWhatsAppUrl(socialConfig.whatsapp);
if (url) {
links.push({
type: 'whatsapp',
url,
label: 'WhatsApp',
handle: '@WhatsApp community',
});
}
}
if (socialConfig.instagram) {
const url = getInstagramUrl(socialConfig.instagram);
if (url) {
links.push({
type: 'instagram',
url,
label: 'Instagram',
handle: extractInstagramHandle(socialConfig.instagram),
});
}
}
if (socialConfig.email) {
const url = getEmailUrl(socialConfig.email);
if (url) {
links.push({
type: 'email',
url,
label: 'Email',
handle: socialConfig.email,
});
}
}
if (socialConfig.telegram) {
const url = getTelegramUrl(socialConfig.telegram);
if (url) {
links.push({
type: 'telegram',
url,
label: 'Telegram',
handle: extractTelegramHandle(socialConfig.telegram),
});
}
}
return links;
}
// SVG icons for each platform
export const socialIcons = {
whatsapp: (
<svg className="w-5 h-5" 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>
),
instagram: (
<svg className="w-5 h-5" 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>
),
email: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
),
telegram: (
<svg className="w-5 h-5" 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>
),
};