first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user