first commit
This commit is contained in:
172
frontend/src/app/(public)/auth/claim-account/page.tsx
Normal file
172
frontend/src/app/(public)/auth/claim-account/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/app/(public)/auth/forgot-password/page.tsx
Normal file
103
frontend/src/app/(public)/auth/forgot-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
frontend/src/app/(public)/auth/magic-link/page.tsx
Normal file
119
frontend/src/app/(public)/auth/magic-link/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
frontend/src/app/(public)/auth/reset-password/page.tsx
Normal file
178
frontend/src/app/(public)/auth/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1073
frontend/src/app/(public)/book/[eventId]/page.tsx
Normal file
1073
frontend/src/app/(public)/book/[eventId]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
244
frontend/src/app/(public)/booking/success/[ticketId]/page.tsx
Normal file
244
frontend/src/app/(public)/booking/success/[ticketId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
frontend/src/app/(public)/community/page.tsx
Normal file
153
frontend/src/app/(public)/community/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
frontend/src/app/(public)/contact/page.tsx
Normal file
158
frontend/src/app/(public)/contact/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx
Normal file
207
frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
frontend/src/app/(public)/dashboard/components/ProfileTab.tsx
Normal file
210
frontend/src/app/(public)/dashboard/components/ProfileTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
380
frontend/src/app/(public)/dashboard/components/SecurityTab.tsx
Normal file
380
frontend/src/app/(public)/dashboard/components/SecurityTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
frontend/src/app/(public)/dashboard/components/TicketsTab.tsx
Normal file
193
frontend/src/app/(public)/dashboard/components/TicketsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
410
frontend/src/app/(public)/dashboard/page.tsx
Normal file
410
frontend/src/app/(public)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
frontend/src/app/(public)/events/[id]/page.tsx
Normal file
213
frontend/src/app/(public)/events/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
frontend/src/app/(public)/events/page.tsx
Normal file
165
frontend/src/app/(public)/events/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
frontend/src/app/(public)/faq/page.tsx
Normal file
151
frontend/src/app/(public)/faq/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/app/(public)/layout.tsx
Normal file
16
frontend/src/app/(public)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/app/(public)/legal/[slug]/page.tsx
Normal file
46
frontend/src/app/(public)/legal/[slug]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
222
frontend/src/app/(public)/linktree/page.tsx
Normal file
222
frontend/src/app/(public)/linktree/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
287
frontend/src/app/(public)/login/page.tsx
Normal file
287
frontend/src/app/(public)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
303
frontend/src/app/(public)/page.tsx
Normal file
303
frontend/src/app/(public)/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
frontend/src/app/(public)/register/page.tsx
Normal file
103
frontend/src/app/(public)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user