Init Commit
This commit is contained in:
968
Frontend/src/pages/AdminPage.tsx
Normal file
968
Frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,968 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AdminService } from '../services/admin.service';
|
||||
import ModerationModal from '../components/ModerationModal';
|
||||
import UserModal from '../components/UserModal';
|
||||
import { parseUTCDate, getImageUrl } from '../utils/app.utils';
|
||||
import { STATUS_CONFIG } from '../constants/adStatuses';
|
||||
import AdDetailsModal from '../components/AdDetailsModal';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
type TabType = 'stats' | 'ads' | 'moderation' | 'transactions' | 'users' | 'audit';
|
||||
|
||||
export default function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('stats');
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Estado para el modal de detalle técnico
|
||||
const [selectedAdDetail, setSelectedAdDetail] = useState<any>(null);
|
||||
|
||||
// Estados para modales y selección
|
||||
const [selectedAd, setSelectedAd] = useState<any>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<number | null>(null);
|
||||
|
||||
// Estados para filtros de Usuarios
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
const [userPage, setUserPage] = useState(1);
|
||||
|
||||
// Estado para filtros de Avisos
|
||||
const [adsFilters, setAdsFilters] = useState({
|
||||
q: '',
|
||||
statusId: '',
|
||||
page: 1
|
||||
});
|
||||
|
||||
// Filtros de Auditoría
|
||||
const [auditFilters, setAuditFilters] = useState({
|
||||
actionType: '',
|
||||
entity: '',
|
||||
userId: '',
|
||||
fromDate: new Date().toISOString().split('T')[0],
|
||||
toDate: new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().split('T')[0],
|
||||
page: 1
|
||||
});
|
||||
|
||||
// Filtros de Transacciones
|
||||
const [transactionFilters, setTransactionFilters] = useState({
|
||||
status: '',
|
||||
userSearch: '',
|
||||
fromDate: new Date().toISOString().split('T')[0],
|
||||
toDate: new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().split('T')[0],
|
||||
page: 1
|
||||
});
|
||||
|
||||
const handleRepublish = async (id: number) => {
|
||||
if (!confirm('¿Estás seguro de que deseas republicar este aviso por 30 días más?')) return;
|
||||
try {
|
||||
await AdminService.republishAd(id);
|
||||
alert('Aviso republicado con éxito.');
|
||||
// Recargamos los datos de la pestaña actual para ver el cambio de estado
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || 'Error al republicar el aviso.');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [activeTab]);
|
||||
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
const handleTabChange = (tab: TabType) => {
|
||||
if (tab === activeTab) return;
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
setActiveTab(tab);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleAuditFilter = () => {
|
||||
if (auditFilters.toDate <= auditFilters.fromDate) {
|
||||
alert("La fecha 'Hasta' debe ser al menos un día posterior a la fecha 'Desde'");
|
||||
return;
|
||||
}
|
||||
setAuditFilters({ ...auditFilters, page: 1 });
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleTransactionFilter = () => {
|
||||
if (transactionFilters.toDate <= transactionFilters.fromDate) {
|
||||
alert("La fecha 'Hasta' debe ser al menos un día posterior a la fecha 'Desde'");
|
||||
return;
|
||||
}
|
||||
setTransactionFilters({ ...transactionFilters, page: 1 });
|
||||
loadData();
|
||||
};
|
||||
|
||||
const loadData = async (pageOverride?: number, searchOverride?: string) => {
|
||||
try {
|
||||
let res;
|
||||
const currentPage = pageOverride ?? userPage;
|
||||
const currentSearch = searchOverride ?? userSearch;
|
||||
|
||||
switch (activeTab) {
|
||||
case 'stats': res = await AdminService.getStats(); break;
|
||||
case 'moderation': res = await AdminService.getPendingAds(); break;
|
||||
case 'transactions': res = await AdminService.getTransactions(transactionFilters); break;
|
||||
case 'users': res = await AdminService.getUsers(currentSearch, currentPage); break;
|
||||
case 'audit': res = await AdminService.getAuditLogs({ ...auditFilters, userId: auditFilters.userId ? parseInt(auditFilters.userId) : undefined }); break;
|
||||
case 'ads':
|
||||
res = await AdminService.getAllAds({
|
||||
q: adsFilters.q,
|
||||
statusId: adsFilters.statusId ? parseInt(adsFilters.statusId) : undefined,
|
||||
page: adsFilters.page
|
||||
});
|
||||
break;
|
||||
}
|
||||
setData(res);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (id: number) => {
|
||||
if (!confirm('¿Aprobar este aviso?')) return;
|
||||
try {
|
||||
await AdminService.approveAd(id);
|
||||
loadData();
|
||||
} catch (err) { alert('Error al aprobar'); }
|
||||
};
|
||||
|
||||
|
||||
const handleToggleBlock = async (userId: number) => {
|
||||
if (!confirm('¿Estás seguro de cambiar el estado de bloqueo de este usuario?')) return;
|
||||
try {
|
||||
await AdminService.toggleBlockUser(userId);
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data || 'Error al cambiar estado del usuario');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 md:px-6 py-8 md:py-12">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 md:mb-12 gap-6">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-5xl font-black tracking-tighter uppercase mb-2">Panel de <span className="text-blue-500">Control</span></h1>
|
||||
<p className="text-[10px] md:text-xs text-gray-500 font-bold tracking-widest uppercase">Administración Central Motores Argentinos</p>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full md:w-auto">
|
||||
<div className="md:hidden w-full group">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className={`w-full flex items-center justify-between bg-white/5 p-4 rounded-2xl border backdrop-blur-xl text-white font-black uppercase tracking-widest text-xs transition-all ${isMobileMenuOpen ? 'border-blue-500 ring-2 ring-blue-500/20' : 'border-white/10'}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{activeTab === 'stats' ? '📊 Resumen' : activeTab === 'ads' ? '📦 Avisos' : activeTab === 'moderation' ? '🛡️ Moderación' : activeTab === 'transactions' ? '💰 Pagos' : activeTab === 'users' ? '👥 Usuarios' : '📋 Auditoría'}
|
||||
</span>
|
||||
<span className={`transition-transform duration-300 ${isMobileMenuOpen ? 'rotate-180 text-blue-400' : 'text-gray-500'}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{isMobileMenuOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-[#12141a] border border-white/10 rounded-2xl overflow-hidden z-[100] shadow-2xl animate-scale-up">
|
||||
{(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit'] as TabType[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => handleTabChange(tab)}
|
||||
className={`w-full text-left px-6 py-4 text-[10px] font-black uppercase tracking-widest transition-all border-b border-white/5 last:border-0 ${activeTab === tab ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-white/5'}`}
|
||||
>
|
||||
{tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : '📋 Auditoría'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menú tradicional para Escritorio */}
|
||||
<div className="hidden md:flex bg-white/5 p-1.5 rounded-2xl border border-white/5 backdrop-blur-xl">
|
||||
{(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit'] as TabType[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => handleTabChange(tab)}
|
||||
className={`px-5 md:px-6 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === tab ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20' : 'text-gray-500 hover:text-white'}`}
|
||||
>
|
||||
{tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : '📋 Auditoría'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || !data ? (
|
||||
<div className="flex justify-center p-40">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-fade-in">
|
||||
|
||||
{/* === DASHBOARD REDISEÑADO === */}
|
||||
{activeTab === 'stats' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
|
||||
{/* Tarjeta de Acción: Moderación */}
|
||||
<div
|
||||
onClick={() => handleTabChange('moderation')}
|
||||
className="col-span-1 md:col-span-2 glass p-8 rounded-[2.5rem] border border-amber-500/20 bg-gradient-to-br from-amber-500/5 to-transparent relative overflow-hidden group cursor-pointer hover:border-amber-500/40 transition-all"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-amber-500/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
|
||||
<div className="flex justify-between items-start relative z-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-3xl">⏳</span>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-amber-400">Pendientes de Revisión</h3>
|
||||
</div>
|
||||
<p className="text-5xl font-black text-white tracking-tighter mb-1">{data.pendingAds}</p>
|
||||
<p className="text-xs text-gray-400 font-medium">Avisos esperando aprobación manual</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full border border-amber-500/30 flex items-center justify-center text-amber-400 group-hover:bg-amber-500 group-hover:text-white transition-all">
|
||||
➔
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tarjeta de Acción: Mensajes */}
|
||||
<div
|
||||
className="col-span-1 md:col-span-2 glass p-8 rounded-[2.5rem] border border-blue-500/20 bg-gradient-to-br from-blue-500/5 to-transparent relative overflow-hidden group transition-all"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
|
||||
<div className="flex justify-between items-start relative z-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-3xl">💬</span>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-blue-400">Mensajes Sin Leer</h3>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-5xl font-black text-white tracking-tighter mb-1">{data.unreadMessages}</p>
|
||||
{data.unreadMessages > 0 && <span className="text-[10px] bg-red-500 text-white px-2 py-0.5 rounded-full font-bold animate-pulse">NUEVOS</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 font-medium">Interacciones pendientes de respuesta</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métricas Informativas (Fila Inferior) */}
|
||||
<DashboardMiniCard label="Total Avisos Histórico" value={data.totalAds} icon="🚗" />
|
||||
<DashboardMiniCard label="Avisos Activos Hoy" value={data.activeAds} icon="✅" color="green" />
|
||||
<DashboardMiniCard label="Usuarios Registrados" value={data.totalUsers} icon="👥" />
|
||||
<DashboardMiniCard label="Versión Sistema" value="v2.1.0" icon="🖥️" color="gray" />
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VISTA DE GESTIÓN DE AVISOS */}
|
||||
{activeTab === 'ads' && data.ads && (
|
||||
<div className="space-y-8">
|
||||
{/* Filtros */}
|
||||
<div className="flex flex-col md:flex-row gap-4 items-center justify-between bg-white/5 p-5 md:p-6 rounded-2xl md:rounded-[2rem] border border-white/5 backdrop-blur-xl">
|
||||
<div className="flex flex-col md:flex-row gap-4 w-full">
|
||||
<div className="relative flex-1 group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por auto, marca, usuario..."
|
||||
value={adsFilters.q}
|
||||
onChange={e => setAdsFilters({ ...adsFilters, q: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && loadData()}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl md:rounded-2xl px-12 py-3 md:py-4 text-sm text-white outline-none focus:border-blue-500 transition-all focus:bg-white/10"
|
||||
/>
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">🔍</span>
|
||||
</div>
|
||||
<div className="w-full md:w-64">
|
||||
<select
|
||||
value={adsFilters.statusId}
|
||||
onChange={e => setAdsFilters({ ...adsFilters, statusId: e.target.value })}
|
||||
className="w-full h-full bg-white/5 border border-white/10 rounded-xl md:rounded-2xl px-4 py-3 md:py-0 text-sm text-white outline-none focus:border-blue-500 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-gray-900">Todos los Estados</option>
|
||||
{Object.entries(STATUS_CONFIG).map(([id, config]) => (
|
||||
<option key={id} value={id} className="bg-gray-900">{config.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setAdsFilters({ ...adsFilters, page: 1 }); loadData(); }}
|
||||
className="w-full md:w-auto bg-blue-600 hover:bg-blue-500 text-white px-8 py-3 md:py-4 rounded-xl md:rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95"
|
||||
>
|
||||
Filtrar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lista de Avisos (Escritorio / Tabla) */}
|
||||
<div className="hidden md:block glass rounded-[2.5rem] overflow-hidden border border-white/5">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-white/5">
|
||||
<tr>
|
||||
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-gray-500">Aviso</th>
|
||||
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-gray-500">Usuario</th>
|
||||
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-gray-500">Estado</th>
|
||||
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-gray-500 text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{data.ads.map((ad: any) => {
|
||||
const statusConfig = STATUS_CONFIG[ad.statusID] || { label: 'Desc.', bg: 'bg-gray-500', color: 'text-white' };
|
||||
return (
|
||||
<tr key={ad.adID} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={getImageUrl(ad.thumbnail)} className="w-20 h-14 object-cover rounded-xl border border-white/10" alt="" />
|
||||
<div>
|
||||
<span className="text-sm font-black text-white uppercase tracking-tight block mb-1">{ad.title}</span>
|
||||
<span className="text-xs text-gray-500 font-medium">ID: #{ad.adID} • {parseUTCDate(ad.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white">{ad.userName}</span>
|
||||
<span className="text-xs text-gray-500">{ad.userEmail}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<span className={`px-3 py-1.5 rounded-lg text-[9px] font-black uppercase tracking-tighter border ${statusConfig.bg} ${statusConfig.color} ${statusConfig.border}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-5 text-right">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedAdDetail(ad)}
|
||||
className="bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest border border-white/10 transition-all flex items-center gap-2 w-full justify-center"
|
||||
>
|
||||
<span>ℹ️</span> Info Técnica
|
||||
</button>
|
||||
<Link
|
||||
to={`/publicar?edit=${ad.adID}`}
|
||||
className="bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest border border-white/10 transition-all flex items-center gap-2 w-full justify-center"
|
||||
>
|
||||
<span>✏️</span> Editar
|
||||
</Link>
|
||||
{ad.statusID === 8 && (
|
||||
<button
|
||||
onClick={() => handleRepublish(ad.adID)}
|
||||
className="bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 hover:text-amber-300 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest border border-amber-500/20 transition-all flex items-center gap-2 w-full justify-center"
|
||||
>
|
||||
<span>🔄</span> Republicar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Lista de Avisos (Móvil / Cards) */}
|
||||
<div className="md:hidden space-y-4">
|
||||
{data.ads.map((ad: any) => {
|
||||
const statusConfig = STATUS_CONFIG[ad.statusID] || { label: 'Desc.', bg: 'bg-gray-500', color: 'text-white' };
|
||||
return (
|
||||
<div key={ad.adID} className="glass p-5 rounded-3xl border border-white/5 space-y-4 shadow-xl">
|
||||
<div className="flex gap-4">
|
||||
<img src={getImageUrl(ad.thumbnail)} className="w-24 h-16 object-cover rounded-xl border border-white/10" alt="" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-black text-white uppercase tracking-tight block truncate mb-1">{ad.title}</span>
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-tighter border ${statusConfig.bg} ${statusConfig.color} ${statusConfig.border}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 py-3 border-y border-white/5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">ID / Fecha</span>
|
||||
<span className="text-[10px] text-white font-medium">#{ad.adID} • {parseUTCDate(ad.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Usuario</span>
|
||||
<span className="text-[10px] text-white font-medium truncate">{ad.userName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setSelectedAdDetail(ad)}
|
||||
className="flex-1 bg-white/5 py-3 rounded-xl border border-white/10 text-[9px] font-black uppercase tracking-widest text-gray-300 active:scale-95 transition-all text-center"
|
||||
>
|
||||
Info
|
||||
</button>
|
||||
<Link
|
||||
to={`/publicar?edit=${ad.adID}`}
|
||||
className="flex-1 bg-white/5 py-3 rounded-xl border border-white/10 text-[9px] font-black uppercase tracking-widest text-gray-300 active:scale-95 transition-all text-center"
|
||||
>
|
||||
Editar
|
||||
</Link>
|
||||
{ad.statusID === 8 && (
|
||||
<button
|
||||
onClick={() => handleRepublish(ad.adID)}
|
||||
className="flex-1 bg-amber-500/10 py-3 rounded-xl border border-amber-500/20 text-[9px] font-black uppercase tracking-widest text-amber-400 active:scale-95 transition-all text-center"
|
||||
>
|
||||
Republicar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data.ads.length === 0 && (
|
||||
<div className="p-12 md:p-20 text-center glass rounded-[2.5rem] border border-white/5">
|
||||
<p className="text-gray-500 font-bold uppercase tracking-widest">No se encontraron avisos.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paginación */}
|
||||
{data.total > data.pageSize && (
|
||||
<div className="flex justify-center gap-4 mt-8">
|
||||
<button
|
||||
disabled={adsFilters.page === 1}
|
||||
onClick={() => { const p = adsFilters.page - 1; setAdsFilters({ ...adsFilters, page: p }); loadData(); }}
|
||||
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
|
||||
>
|
||||
⬅️ Anterior
|
||||
</button>
|
||||
<div className="flex items-center px-6 rounded-xl bg-white/5 border border-white/10 text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||||
Página {data.page} de {Math.ceil(data.total / data.pageSize)}
|
||||
</div>
|
||||
<button
|
||||
disabled={adsFilters.page >= Math.ceil(data.total / data.pageSize)}
|
||||
onClick={() => { const p = adsFilters.page + 1; setAdsFilters({ ...adsFilters, page: p }); loadData(); }}
|
||||
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
|
||||
>
|
||||
Siguiente ➡️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VISTA MODERACIÓN */}
|
||||
{activeTab === 'moderation' && Array.isArray(data) && (
|
||||
<div className="space-y-6">
|
||||
{data.length === 0 ? (
|
||||
<div className="glass p-20 text-center rounded-[2.5rem] border-dashed border-2 border-white/5">
|
||||
<p className="text-gray-500 font-bold uppercase tracking-widest">No hay avisos pendientes de moderación</p>
|
||||
</div>
|
||||
) : (
|
||||
data.map((ad: any) => (
|
||||
<div key={ad.adID} className="glass p-6 rounded-3xl border border-white/5 flex flex-col md:flex-row gap-8 items-center group hover:border-blue-500/30 transition-all">
|
||||
<img src={ad.thumbnail?.startsWith('http') ? ad.thumbnail : `${import.meta.env.VITE_STATIC_BASE_URL}${ad.thumbnail}`} className="w-40 h-24 object-cover rounded-2xl shadow-xl" alt="" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold uppercase tracking-tight">{ad.versionName}</h3>
|
||||
<p className="text-sm text-gray-500 font-medium">Publicado por {ad.userName} ({ad.email}) • {parseUTCDate(ad.createdAt).toLocaleDateString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</p>
|
||||
|
||||
<p className="text-blue-400 font-black mt-1 text-lg">{ad.currency} {ad.price.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedAd(ad)}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white px-8 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
Revisar & Moderar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VISTA TRANSACTIONS */}
|
||||
{activeTab === 'transactions' && data.transactions && (
|
||||
<div className="space-y-8">
|
||||
{/* Filtros de Transacciones */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 bg-white/5 p-5 md:p-6 rounded-2xl md:rounded-[2rem] border border-white/5 backdrop-blur-xl">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Estado</label>
|
||||
<select
|
||||
value={transactionFilters.status}
|
||||
onChange={e => setTransactionFilters({ ...transactionFilters, status: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-900 text-white">Todos</option>
|
||||
<option value="APPROVED" className="bg-gray-900 text-white">Aprobado</option>
|
||||
<option value="PENDING" className="bg-gray-900 text-white">Pendiente</option>
|
||||
<option value="REJECTED" className="bg-gray-900 text-white">Rechazado</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Usuario</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Email o usuario..."
|
||||
value={transactionFilters.userSearch}
|
||||
onChange={e => setTransactionFilters({ ...transactionFilters, userSearch: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Desde</label>
|
||||
<input
|
||||
type="date"
|
||||
value={transactionFilters.fromDate}
|
||||
onClick={(e) => e.currentTarget.showPicker()}
|
||||
onChange={e => {
|
||||
const newFrom = e.target.value;
|
||||
const nextDay = new Date(new Date(newFrom + 'T12:00:00').setDate(new Date(newFrom + 'T12:00:00').getDate() + 1)).toISOString().split('T')[0];
|
||||
let newTo = transactionFilters.toDate;
|
||||
if (newTo <= newFrom) newTo = nextDay;
|
||||
setTransactionFilters({ ...transactionFilters, fromDate: newFrom, toDate: newTo });
|
||||
}}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all [color-scheme:dark] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Hasta</label>
|
||||
<input
|
||||
type="date"
|
||||
value={transactionFilters.toDate}
|
||||
onClick={(e) => e.currentTarget.showPicker()}
|
||||
min={new Date(new Date(transactionFilters.fromDate + 'T12:00:00').setDate(new Date(transactionFilters.fromDate + 'T12:00:00').getDate() + 1)).toISOString().split('T')[0]}
|
||||
onChange={e => setTransactionFilters({ ...transactionFilters, toDate: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all [color-scheme:dark] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleTransactionFilter}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95"
|
||||
>
|
||||
Filtrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block glass rounded-[2.5rem] overflow-hidden border border-white/5">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-white/5">
|
||||
<tr>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Fecha</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Usuario</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Detalle</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Operación</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Monto</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{data.transactions.map((tx: any) => (
|
||||
<tr key={tx.transactionID} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-8 py-4 text-xs font-medium text-gray-400">
|
||||
<div className="flex flex-col">
|
||||
<span>{parseUTCDate(tx.createdAt).toLocaleDateString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span>
|
||||
<span className="text-[10px] opacity-50">{parseUTCDate(tx.createdAt).toLocaleTimeString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-black text-white uppercase tracking-tight">{tx.userName}</span>
|
||||
<span className="text-[10px] text-gray-500 font-medium">{tx.userEmail}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4">
|
||||
<span className="text-xs text-blue-300 font-bold uppercase tracking-tight">{tx.adTitle}</span>
|
||||
<span className="text-[9px] text-gray-600 block">ID: {tx.adID}</span>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-xs font-black uppercase text-white">{tx.operationCode}</td>
|
||||
<td className="px-8 py-4 text-xs font-black text-green-400">${tx.amount.toLocaleString()}</td>
|
||||
<td className="px-8 py-4">
|
||||
<span className={`px-3 py-1 rounded-full text-[8px] font-black uppercase tracking-tighter ${tx.status === 'APPROVED' ? 'bg-green-500/20 text-green-500 border border-green-500/20' : tx.status === 'REJECTED' ? 'bg-red-500/20 text-red-500 border border-red-500/20' : 'bg-amber-500/20 text-amber-500 border border-amber-500/20'}`}>
|
||||
{tx.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Transacciones (Móvil) */}
|
||||
<div className="md:hidden space-y-4">
|
||||
{data.transactions.map((tx: any) => (
|
||||
<div key={tx.transactionID} className="glass p-5 rounded-3xl border border-white/5 space-y-4 shadow-xl">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[8px] font-black uppercase tracking-widest text-gray-500">Operación</span>
|
||||
<span className="text-xs font-black text-white uppercase leading-none">{tx.operationCode}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-tighter ${tx.status === 'APPROVED' ? 'bg-green-500/20 text-green-500 border border-green-500/20' : tx.status === 'REJECTED' ? 'bg-red-500/20 text-red-500 border border-red-500/20' : 'bg-amber-500/20 text-amber-500 border border-amber-500/20'}`}>
|
||||
{tx.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="py-3 border-y border-white/5 space-y-2">
|
||||
<p className="text-[11px] font-bold text-blue-300 uppercase leading-tight truncate">{tx.adTitle}</p>
|
||||
<div className="flex justify-between items-center text-[10px]">
|
||||
<span className="text-gray-500">{parseUTCDate(tx.createdAt).toLocaleDateString()}</span>
|
||||
<span className="text-green-400 font-black tracking-widest">${tx.amount.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600/20 flex items-center justify-center text-[10px] text-blue-400 font-black">
|
||||
{tx.userName[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-white font-bold leading-none">{tx.userName}</span>
|
||||
<span className="text-[9px] text-gray-500 font-medium truncate max-w-[150px]">{tx.userEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.transactions.length === 0 && (
|
||||
<div className="p-12 text-center glass rounded-3xl border border-white/5">
|
||||
<p className="text-gray-500 font-bold uppercase tracking-widest">Sin transacciones</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paginación Transacciones */}
|
||||
{data.total > data.pageSize && (
|
||||
<div className="flex justify-center gap-4 mt-8">
|
||||
<button
|
||||
disabled={transactionFilters.page === 1}
|
||||
onClick={() => { const p = transactionFilters.page - 1; setTransactionFilters({ ...transactionFilters, page: p }); loadData(); }}
|
||||
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
|
||||
>
|
||||
⬅️ Anterior
|
||||
</button>
|
||||
<div className="flex items-center px-6 rounded-xl bg-white/5 border border-white/10 text-[10px] font-black uppercase tracking-widest text-gray-400 font-mono">
|
||||
{data.page} / {Math.ceil(data.total / data.pageSize)}
|
||||
</div>
|
||||
<button
|
||||
disabled={transactionFilters.page >= Math.ceil(data.total / data.pageSize)}
|
||||
onClick={() => { const p = transactionFilters.page + 1; setTransactionFilters({ ...transactionFilters, page: p }); loadData(); }}
|
||||
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
|
||||
>
|
||||
Siguiente ➡️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && data.users && (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-center justify-between bg-white/5 p-6 rounded-[2rem] border border-white/5 backdrop-blur-xl">
|
||||
<div className="relative w-full md:max-w-md group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por email, nombre o usuario..."
|
||||
value={userSearch}
|
||||
onChange={e => setUserSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && loadData(1)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-12 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all focus:bg-white/10"
|
||||
/>
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-blue-500 transition-colors">🔍</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setUserPage(1); loadData(1); }}
|
||||
className="w-full md:w-auto bg-blue-600 hover:bg-blue-500 text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95"
|
||||
>
|
||||
Buscar Usuarios
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{data.users.map((u: any) => (
|
||||
<div key={u.userID} className={`glass p-8 rounded-[2rem] border transition-all ${u.isBlocked ? 'border-red-500/50 bg-red-900/10' : 'border-white/5 hover:border-blue-500/20'}`}>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 bg-blue-600/20 rounded-full flex items-center justify-center text-xl text-blue-400 font-bold">
|
||||
{u.userName ? u.userName[0].toUpperCase() : '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-black uppercase tracking-tight text-white mb-0.5 truncate">{u.userName}</h4>
|
||||
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest">{u.userType === 3 ? '🛡️ Administrador' : '👤 Particular'}</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedUser(u.userID)} className="w-8 h-8 rounded-lg bg-white/5 hover:bg-blue-600/20 text-gray-400 hover:text-blue-400 flex items-center justify-center transition-all border border-white/5">✏️</button>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs font-medium text-gray-400">
|
||||
<p className="flex justify-between gap-2 overflow-hidden"><span>Email:</span> <span className="text-white truncate">{u.email}</span></p>
|
||||
<p className="flex justify-between gap-2"><span>Nombre:</span> <span className="text-white truncate">{u.firstName} {u.lastName}</span></p>
|
||||
<p className="flex justify-between"><span>Registro:</span> <span className="text-white">{parseUTCDate(u.createdAt).toLocaleDateString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span></p>
|
||||
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-white/5 flex justify-between items-center">
|
||||
<span className={`text-[10px] font-black uppercase tracking-widest ${u.isBlocked ? 'text-red-400' : 'text-green-400'}`}>
|
||||
{u.isBlocked ? 'BLOQUEADO' : 'ACTIVO'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleToggleBlock(u.userID)}
|
||||
className={`px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all ${u.isBlocked ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}
|
||||
>
|
||||
{u.isBlocked ? 'Desbloquear' : 'Bloquear'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.total > data.pageSize && (
|
||||
<div className="flex justify-center gap-4 mt-8">
|
||||
<button
|
||||
disabled={userPage === 1}
|
||||
onClick={() => { const p = userPage - 1; setUserPage(p); loadData(p); }}
|
||||
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
|
||||
>
|
||||
⬅️ Anterior
|
||||
</button>
|
||||
<div className="flex items-center px-6 rounded-xl bg-white/5 border border-white/10 text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||||
Página {userPage} de {Math.ceil(data.total / data.pageSize)}
|
||||
</div>
|
||||
<button
|
||||
disabled={userPage >= Math.ceil(data.total / data.pageSize)}
|
||||
onClick={() => { const p = userPage + 1; setUserPage(p); loadData(p); }}
|
||||
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
|
||||
>
|
||||
Siguiente ➡️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'audit' && data.logs && (
|
||||
<div className="space-y-8">
|
||||
{/* Filtros de Auditoría */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-4 bg-white/5 p-5 md:p-6 rounded-2xl md:rounded-[2rem] border border-white/5 backdrop-blur-xl">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Evento</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej: AD_CREATED"
|
||||
value={auditFilters.actionType}
|
||||
onChange={e => setAuditFilters({ ...auditFilters, actionType: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Entidad</label>
|
||||
<select
|
||||
value={auditFilters.entity}
|
||||
onChange={e => setAuditFilters({ ...auditFilters, entity: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-900 text-white">Todas</option>
|
||||
<option value="Ad" className="bg-gray-900 text-white">Avisos</option>
|
||||
<option value="User" className="bg-gray-900 text-white">Usuarios</option>
|
||||
<option value="Transaction" className="bg-gray-900 text-white">Pagos</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">ID Usuario</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="ID..."
|
||||
value={auditFilters.userId}
|
||||
onChange={e => setAuditFilters({ ...auditFilters, userId: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Desde</label>
|
||||
<input
|
||||
type="date"
|
||||
value={auditFilters.fromDate}
|
||||
onClick={(e) => e.currentTarget.showPicker()}
|
||||
onChange={e => {
|
||||
const newFrom = e.target.value;
|
||||
const nextDay = new Date(new Date(newFrom + 'T12:00:00').setDate(new Date(newFrom + 'T12:00:00').getDate() + 1)).toISOString().split('T')[0];
|
||||
let newTo = auditFilters.toDate;
|
||||
if (newTo <= newFrom) newTo = nextDay;
|
||||
setAuditFilters({ ...auditFilters, fromDate: newFrom, toDate: newTo });
|
||||
}}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all [color-scheme:dark] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Hasta</label>
|
||||
<input
|
||||
type="date"
|
||||
value={auditFilters.toDate}
|
||||
onClick={(e) => e.currentTarget.showPicker()}
|
||||
min={new Date(new Date(auditFilters.fromDate + 'T12:00:00').setDate(new Date(auditFilters.fromDate + 'T12:00:00').getDate() + 1)).toISOString().split('T')[0]}
|
||||
onChange={e => setAuditFilters({ ...auditFilters, toDate: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all [color-scheme:dark] cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleAuditFilter}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95"
|
||||
>
|
||||
Filtrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block glass rounded-[2.5rem] overflow-hidden border border-white/5">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-white/5">
|
||||
<tr>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Fecha</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Acción</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Entidad</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Detalles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{data.logs.map((log: any) => (
|
||||
<tr key={log.auditLogID} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-8 py-4 text-xs font-medium text-gray-400 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span>{parseUTCDate(log.createdAt).toLocaleDateString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span>
|
||||
<span className="text-[10px] opacity-50">{parseUTCDate(log.createdAt).toLocaleTimeString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4">
|
||||
<span className={`px-2 py-1 rounded-lg text-[8px] font-black uppercase tracking-tighter shadow-sm
|
||||
${log.action.includes('SUCCESS') || log.action.includes('APPROVED') || log.action.includes('CREATED') ? 'bg-green-500/10 text-green-400 border border-green-500/20' :
|
||||
log.action.includes('REJECTED') || log.action.includes('DELETED') || log.action.includes('BLOCKED') ? 'bg-red-500/10 text-red-400 border border-red-500/20' :
|
||||
'bg-blue-500/10 text-blue-400 border border-blue-500/20'}`}>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-black text-gray-300 uppercase tracking-tight">{log.entity}</span>
|
||||
<span className="text-[10px] text-blue-500 font-bold">ID: #{log.entityID}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4">
|
||||
<div className="max-w-md">
|
||||
<p className="text-xs text-gray-400 leading-relaxed">{log.details}</p>
|
||||
{log.userID > 0 && <span className="text-[9px] text-gray-600 font-bold uppercase mt-1 block">Por {log.userName} (ID: {log.userID})</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Auditoría (Móvil) */}
|
||||
<div className="md:hidden space-y-4">
|
||||
{data.logs.map((log: any) => (
|
||||
<div key={log.auditLogID} className="glass p-5 rounded-3xl border border-white/5 space-y-4 shadow-xl">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[8px] font-black uppercase tracking-widest text-gray-500">{log.entity} ID: #{log.entityID}</span>
|
||||
<span className={`inline-block mt-1 px-2 py-1 rounded-lg text-[8px] font-black uppercase tracking-tighter
|
||||
${log.action.includes('SUCCESS') || log.action.includes('APPROVED') || log.action.includes('CREATED') ? 'bg-green-500/10 text-green-400 border border-green-500/20' :
|
||||
log.action.includes('REJECTED') || log.action.includes('DELETED') || log.action.includes('BLOCKED') ? 'bg-red-500/10 text-red-400 border border-red-500/20' :
|
||||
'bg-blue-500/10 text-blue-400 border border-blue-500/20'}`}>
|
||||
{log.action}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] text-white font-medium">{parseUTCDate(log.createdAt).toLocaleDateString()}</p>
|
||||
<p className="text-[8px] text-gray-600 uppercase font-black">{parseUTCDate(log.createdAt).toLocaleTimeString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/5 rounded-2xl">
|
||||
<p className="text-[10px] text-gray-400 leading-relaxed italic">"{log.details}"</p>
|
||||
</div>
|
||||
|
||||
{log.userID > 0 ? (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="text-[8px] font-black text-gray-500 uppercase">Ejecutado por:</span>
|
||||
<span className="text-[9px] text-blue-400 font-bold uppercase tracking-tight">{log.userName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[8px] text-indigo-400 font-black uppercase tracking-widest">🤖 SISTEMA</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.logs.length === 0 && (
|
||||
<div className="p-12 text-center glass rounded-3xl border border-white/5">
|
||||
<p className="text-gray-500 font-bold uppercase tracking-widest">Sin registros</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paginación Auditoría */}
|
||||
{data.total > data.pageSize && (
|
||||
<div className="flex justify-center gap-4 mt-8">
|
||||
<button
|
||||
disabled={auditFilters.page === 1}
|
||||
onClick={() => { const p = auditFilters.page - 1; setAuditFilters({ ...auditFilters, page: p }); loadData(); }}
|
||||
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
|
||||
>
|
||||
⬅️ Anterior
|
||||
</button>
|
||||
<div className="flex items-center px-6 rounded-xl bg-white/5 border border-white/10 text-[10px] font-black uppercase tracking-widest text-gray-400 font-mono">
|
||||
{data.page} / {Math.ceil(data.total / data.pageSize)}
|
||||
</div>
|
||||
<button
|
||||
disabled={auditFilters.page >= Math.ceil(data.total / data.pageSize)}
|
||||
onClick={() => { const p = auditFilters.page + 1; setAuditFilters({ ...auditFilters, page: p }); loadData(); }}
|
||||
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
|
||||
>
|
||||
Siguiente ➡️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* MODAL DETALLE TÉCNICO */}
|
||||
{selectedAdDetail && (
|
||||
<AdDetailsModal
|
||||
ad={selectedAdDetail}
|
||||
onClose={() => setSelectedAdDetail(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MODAL DE MODERACIÓN */}
|
||||
{selectedAd && (
|
||||
<ModerationModal
|
||||
adSummary={selectedAd}
|
||||
onClose={() => setSelectedAd(null)}
|
||||
onApprove={(id: number) => {
|
||||
handleApprove(id);
|
||||
setSelectedAd(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MODAL DE USUARIO */}
|
||||
{selectedUser && (
|
||||
<UserModal
|
||||
userId={selectedUser}
|
||||
onClose={() => setSelectedUser(null)}
|
||||
onUpdate={loadData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Componente para tarjetas pequeñas del dashboard
|
||||
function DashboardMiniCard({ label, value, icon, color = 'blue' }: { label: string, value: any, icon: string, color?: string }) {
|
||||
const colors: any = {
|
||||
blue: 'border-white/5',
|
||||
green: 'border-green-500/20 bg-green-500/5',
|
||||
gray: 'border-white/5 opacity-60'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`glass p-6 rounded-3xl border flex items-center gap-4 ${colors[color]}`}>
|
||||
<div className="text-2xl">{icon}</div>
|
||||
<div>
|
||||
<p className="text-2xl font-black text-white leading-none">{value}</p>
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-gray-500 mt-1">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
346
Frontend/src/pages/ExplorarPage.tsx
Normal file
346
Frontend/src/pages/ExplorarPage.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import { AdsV2Service, type AdListingDto } from '../services/ads.v2.service';
|
||||
import { getImageUrl, formatCurrency } from '../utils/app.utils';
|
||||
import SearchableSelect from '../components/SearchableSelect';
|
||||
import AdStatusBadge from '../components/AdStatusBadge';
|
||||
import {
|
||||
AUTO_SEGMENTS,
|
||||
MOTO_SEGMENTS,
|
||||
AUTO_TRANSMISSIONS,
|
||||
MOTO_TRANSMISSIONS,
|
||||
FUEL_TYPES,
|
||||
VEHICLE_CONDITIONS
|
||||
} from '../constants/vehicleOptions';
|
||||
|
||||
export default function ExplorarPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [listings, setListings] = useState<AdListingDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [minPrice, setMinPrice] = useState(searchParams.get('minPrice') || '');
|
||||
const [maxPrice, setMaxPrice] = useState(searchParams.get('maxPrice') || '');
|
||||
const [currencyFilter, setCurrencyFilter] = useState(searchParams.get('currency') || '');
|
||||
const [minYear, setMinYear] = useState(searchParams.get('minYear') || '');
|
||||
const [maxYear, setMaxYear] = useState(searchParams.get('maxYear') || '');
|
||||
const [brandId, setBrandId] = useState(searchParams.get('brandId') || '');
|
||||
const [modelId, setModelId] = useState(searchParams.get('modelId') || '');
|
||||
const [fuel, setFuel] = useState(searchParams.get('fuel') || '');
|
||||
const [transmission, setTransmission] = useState(searchParams.get('transmission') || '');
|
||||
|
||||
const [brands, setBrands] = useState<{ id: number, name: string }[]>([]);
|
||||
const [models, setModels] = useState<{ id: number, name: string }[]>([]);
|
||||
|
||||
const q = searchParams.get('q') || '';
|
||||
const c = searchParams.get('c') || 'ALL';
|
||||
|
||||
useEffect(() => {
|
||||
if (c !== 'ALL') {
|
||||
const typeId = c === 'EAUTOS' ? 1 : 2;
|
||||
AdsV2Service.getBrands(typeId).then(setBrands);
|
||||
} else {
|
||||
setBrands([]);
|
||||
}
|
||||
}, [c]);
|
||||
|
||||
useEffect(() => {
|
||||
if (brandId) {
|
||||
AdsV2Service.getModels(Number(brandId)).then(setModels);
|
||||
} else {
|
||||
setModels([]);
|
||||
}
|
||||
}, [brandId]);
|
||||
|
||||
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchListings = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await AdsV2Service.getAll({
|
||||
q,
|
||||
c: c === 'ALL' ? undefined : c,
|
||||
minPrice: minPrice ? Number(minPrice) : undefined,
|
||||
maxPrice: maxPrice ? Number(maxPrice) : undefined,
|
||||
currency: currencyFilter || undefined,
|
||||
minYear: minYear ? Number(minYear) : undefined,
|
||||
maxYear: maxYear ? Number(maxYear) : undefined,
|
||||
brandId: brandId ? Number(brandId) : undefined,
|
||||
modelId: modelId ? Number(modelId) : undefined,
|
||||
fuel: fuel || undefined,
|
||||
transmission: transmission || undefined
|
||||
});
|
||||
setListings(data);
|
||||
} catch (err) {
|
||||
setError("Error al cargar los avisos");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchListings();
|
||||
if (showMobileFilters) setShowMobileFilters(false);
|
||||
}, [searchParams]);
|
||||
|
||||
const applyFilters = () => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (minPrice) newParams.set('minPrice', minPrice); else newParams.delete('minPrice');
|
||||
if (maxPrice) newParams.set('maxPrice', maxPrice); else newParams.delete('maxPrice');
|
||||
if (currencyFilter) newParams.set('currency', currencyFilter); else newParams.delete('currency');
|
||||
if (minYear) newParams.set('minYear', minYear); else newParams.delete('minYear');
|
||||
if (maxYear) newParams.set('maxYear', maxYear); else newParams.delete('maxYear');
|
||||
if (brandId) newParams.set('brandId', brandId); else newParams.delete('brandId');
|
||||
if (modelId) newParams.set('modelId', modelId); else newParams.delete('modelId');
|
||||
if (fuel) newParams.set('fuel', fuel); else newParams.delete('fuel');
|
||||
if (transmission) newParams.set('transmission', transmission); else newParams.delete('transmission');
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setMinPrice(''); setMaxPrice(''); setMinYear(''); setMaxYear('');
|
||||
setCurrencyFilter('');
|
||||
setBrandId(''); setModelId(''); setFuel(''); setTransmission('');
|
||||
const newParams = new URLSearchParams();
|
||||
if (q) newParams.set('q', q);
|
||||
if (c !== 'ALL') newParams.set('c', c);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const handleCategoryFilter = (cat: string) => {
|
||||
const newParams = new URLSearchParams();
|
||||
if (q) newParams.set('q', q);
|
||||
if (cat !== 'ALL') newParams.set('c', cat);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-2 md:px-6 py-4 md:py-8 flex flex-col md:flex-row gap-6 md:gap-8 relative items-start">
|
||||
<button
|
||||
onClick={() => setShowMobileFilters(true)}
|
||||
className={`md:hidden fixed bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 z-[110] bg-blue-600 text-white px-6 md:px-8 py-3 md:py-4 rounded-xl md:rounded-2xl font-black uppercase tracking-widest shadow-2xl shadow-blue-600/40 border border-white/20 active:scale-95 transition-all flex items-center gap-2 md:gap-3 text-sm ${showMobileFilters ? 'opacity-0 pointer-events-none translate-y-20' : 'opacity-100 translate-y-0'}`}
|
||||
>
|
||||
<span>🔍 FILTRAR</span>
|
||||
</button>
|
||||
|
||||
{/* Sidebar Filters - NATURAL FLOW (NO STICKY, NO SCROLL INTERNO) */}
|
||||
<aside className={`
|
||||
fixed inset-0 z-[105] bg-black/80 backdrop-blur-xl transition-all duration-500 overflow-y-auto md:overflow-visible
|
||||
md:relative md:inset-auto md:bg-transparent md:backdrop-blur-none md:z-0 md:w-80 md:flex flex-col md:flex-shrink-0
|
||||
${showMobileFilters ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none translate-y-10 md:opacity-100 md:pointer-events-auto md:translate-y-0'}
|
||||
`}>
|
||||
<div className="
|
||||
glass p-6 rounded-[2rem] border border-white/5 shadow-2xl
|
||||
h-fit m-6 mt-28 md:m-0
|
||||
">
|
||||
<div className="flex justify-between items-center mb-6 border-b border-white/5 pb-4">
|
||||
<h3 className="text-xl font-black tracking-tighter uppercase">FILTROS</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-[10px] font-black uppercase tracking-widest text-white/50 hover:text-white px-3 py-2 rounded-lg border border-white/10 hover:bg-white/5 transition-all"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowMobileFilters(false)}
|
||||
className="md:hidden bg-red-500/10 text-red-400 hover:bg-red-500 hover:text-white px-3 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all border border-red-500/20"
|
||||
>
|
||||
✕ Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Categoría */}
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Tipo de Vehículo</label>
|
||||
<select
|
||||
value={c}
|
||||
onChange={(e) => handleCategoryFilter(e.target.value)}
|
||||
className="w-full bg-blue-600/10 border border-blue-500/30 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none font-bold uppercase tracking-wide cursor-pointer hover:bg-blue-600/20"
|
||||
>
|
||||
<option value="ALL" className="bg-gray-900">Todos</option>
|
||||
<option value="EAUTOS" className="bg-gray-900">Automóviles</option>
|
||||
<option value="EMOTOS" className="bg-gray-900">Motos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{c !== 'ALL' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Marca</label>
|
||||
<SearchableSelect
|
||||
options={brands}
|
||||
value={brandId}
|
||||
onChange={(val) => setBrandId(val)}
|
||||
placeholder="Todas las marcas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{brandId && (
|
||||
<div className="animate-fade-in">
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Modelo</label>
|
||||
<select
|
||||
value={modelId}
|
||||
onChange={e => setModelId(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-900 text-gray-500">Todos los modelos</option>
|
||||
{models.map(m => (
|
||||
<option key={m.id} value={m.id} className="bg-gray-900 text-white">{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Moneda</label>
|
||||
<select
|
||||
value={currencyFilter}
|
||||
onChange={e => setCurrencyFilter(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-gray-900 text-gray-500">Indistinto</option>
|
||||
<option value="ARS" className="bg-gray-900 text-white">Pesos (ARS)</option>
|
||||
<option value="USD" className="bg-gray-900 text-white">Dólares (USD)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Precio Máximo</label>
|
||||
<input placeholder="Ej: 25000" type="number" value={maxPrice} onChange={e => setMaxPrice(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Desde Año</label>
|
||||
<input placeholder="Ej: 2018" type="number" value={minYear} onChange={e => setMinYear(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Combustible</label>
|
||||
<select value={fuel} onChange={e => setFuel(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none">
|
||||
<option value="" className="bg-gray-900 text-gray-500">Todos</option>
|
||||
{FUEL_TYPES.map(f => (<option key={f} value={f} className="bg-gray-900 text-white">{f}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Transmisión</label>
|
||||
<select value={transmission} onChange={e => setTransmission(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none">
|
||||
<option value="" className="bg-gray-900 text-gray-500">Todas</option>
|
||||
{(c === 'EMOTOS' ? MOTO_TRANSMISSIONS : AUTO_TRANSMISSIONS).map(t => (
|
||||
<option key={t} value={t} className="bg-gray-900 text-white">{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Color</label>
|
||||
<input placeholder="Ej: Blanco" type="text" value={searchParams.get('color') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('color', e.target.value); else p.delete('color'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Ubicación</label>
|
||||
<input placeholder="Ej: Buenos Aires" type="text" value={searchParams.get('location') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('location', e.target.value); else p.delete('location'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
<div className={`grid ${c === 'EMOTOS' ? 'grid-cols-1' : 'grid-cols-2'} gap-2`}>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Estado</label>
|
||||
<select value={searchParams.get('condition') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('condition', e.target.value); else p.delete('condition'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 appearance-none cursor-pointer">
|
||||
<option value="" className="bg-gray-900">Todos</option>
|
||||
{VEHICLE_CONDITIONS.map(o => <option key={o} value={o} className="bg-gray-900">{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Segmento</label>
|
||||
<select value={searchParams.get('segment') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('segment', e.target.value); else p.delete('segment'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 appearance-none cursor-pointer">
|
||||
<option value="" className="bg-gray-900">Todos</option>
|
||||
{(c === 'EMOTOS' ? MOTO_SEGMENTS : AUTO_SEGMENTS).map(o => (
|
||||
<option key={o} value={o} className="bg-gray-900">{o}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c !== 'EMOTOS' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Puertas</label>
|
||||
<input placeholder="Ej: 4" type="number" value={searchParams.get('doorCount') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('doorCount', e.target.value); else p.delete('doorCount'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Dirección</label>
|
||||
<input placeholder="Ej: Hidráulica" type="text" value={searchParams.get('steering') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('steering', e.target.value); else p.delete('steering'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={applyFilters} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95 mb-4 mt-6">
|
||||
Aplicar Filtros
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="w-full md:flex-1 md:min-w-0">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 md:mb-8 gap-4 md:gap-6">
|
||||
<div className="flex-1 w-full">
|
||||
<h2 className="text-3xl md:text-4xl font-black tracking-tighter uppercase mb-2 md:mb-0">Explorar</h2>
|
||||
<div className="mt-3 md:mt-4 relative max-w-xl group">
|
||||
<input type="text" placeholder="Buscar por marca, modelo o versión..." value={q} onChange={e => { const newParams = new URLSearchParams(searchParams); if (e.target.value) newParams.set('q', e.target.value); else newParams.delete('q'); setSearchParams(newParams); }} className="w-full bg-white/5 border border-white/10 rounded-xl md:rounded-2xl px-10 md:px-12 py-3 md:py-4 text-sm text-white outline-none focus:border-blue-500 transition-all focus:bg-white/10" />
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-blue-500 transition-colors">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-bold bg-white/5 border border-white/10 px-6 py-3 rounded-full text-gray-400 uppercase tracking-widest self-end md:self-center whitespace-nowrap">
|
||||
{listings.length} vehículos
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-20"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div></div>
|
||||
) : error ? (
|
||||
<div className="glass p-12 rounded-[2.5rem] border border-red-500/20 text-center"><p className="text-red-400 font-bold">{error}</p></div>
|
||||
) : listings.length === 0 ? (
|
||||
<div className="glass p-20 rounded-[2.5rem] text-center border-dashed border-2 border-white/10">
|
||||
<span className="text-6xl mb-6 block">🔍</span>
|
||||
<h3 className="text-2xl font-bold text-gray-400 uppercase tracking-tighter">Sin coincidencias</h3>
|
||||
<p className="text-gray-600 max-w-xs mx-auto mt-2 italic">No encontramos vehículos que coincidan con los filtros seleccionados.</p>
|
||||
<button onClick={clearFilters} className="mt-8 text-blue-400 font-black uppercase text-[10px] tracking-widest">Ver todos los avisos</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-8">
|
||||
{listings.map(car => (
|
||||
<Link to={`/vehiculo/${car.id}`} key={car.id} className="glass-card rounded-2xl md:rounded-[2rem] overflow-hidden group animate-fade-in-up flex flex-col">
|
||||
<div className="aspect-[4/3] overflow-hidden relative bg-[#07090d] flex items-center justify-center border-b border-white/5">
|
||||
<img src={getImageUrl(car.image)} className="max-w-full max-h-full object-contain group-hover:scale-110 transition-transform duration-700" alt={`${car.brandName} ${car.versionName}`} loading="lazy" />
|
||||
|
||||
{/* --- BLOQUE PARA EL BADGE --- */}
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<AdStatusBadge statusId={car.statusId || 4} />
|
||||
</div>
|
||||
|
||||
{car.isFeatured && (
|
||||
<div className="absolute top-4 right-4 bg-blue-600 text-white px-4 py-1.5 rounded-full text-[8px] font-black uppercase tracking-widest shadow-lg animate-glow">
|
||||
Destacado
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 md:p-8 flex-1 flex flex-col">
|
||||
<h3 className="text-2xl font-bold text-white group-hover:text-blue-400 transition-colors uppercase tracking-tight truncate mb-2">
|
||||
{car.brandName} {car.versionName}
|
||||
</h3>
|
||||
<div className="flex justify-between items-center mt-auto">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest mb-1">{car.year} • {car.km.toLocaleString()} KM</span>
|
||||
<span className="text-white font-black text-2xl tracking-tighter">{formatCurrency(car.price, car.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
212
Frontend/src/pages/HomePage.tsx
Normal file
212
Frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { AdsV2Service, type AdListingDto } from '../services/ads.v2.service';
|
||||
import { getImageUrl, formatCurrency } from '../utils/app.utils';
|
||||
import AdStatusBadge from '../components/AdStatusBadge';
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState('ALL');
|
||||
const [featuredAds, setFeaturedAds] = useState<AdListingDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// --- ESTADOS PARA SUGERENCIAS ---
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const searchWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Cargar destacados
|
||||
useEffect(() => {
|
||||
AdsV2Service.getAll({ isFeatured: true })
|
||||
.then(data => {
|
||||
setFeaturedAds(data.slice(0, 3));
|
||||
})
|
||||
.catch(err => console.error("Error cargando destacados:", err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// --- LÓGICA PARA BUSCAR SUGERENCIAS ---
|
||||
useEffect(() => {
|
||||
if (query.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
AdsV2Service.getSearchSuggestions(query)
|
||||
.then(setSuggestions)
|
||||
.catch(console.error);
|
||||
}, 300); // Espera 300ms antes de buscar
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
// --- LÓGICA PARA CERRAR SUGERENCIAS AL HACER CLIC FUERA ---
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (searchWrapperRef.current && !searchWrapperRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Función de búsqueda actualizada para aceptar un término opcional
|
||||
const handleSearch = (searchTerm: string = query) => {
|
||||
setShowSuggestions(false);
|
||||
// Si la categoría es 'ALL', no enviamos el parámetro 'c'
|
||||
const categoryParam = category === 'ALL' ? '' : `&c=${category}`;
|
||||
navigate(`/explorar?q=${searchTerm}${categoryParam}`);
|
||||
};
|
||||
|
||||
// Función para manejar el clic en una sugerencia
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
setQuery(suggestion);
|
||||
setSuggestions([]);
|
||||
handleSearch(suggestion); // Realizar la búsqueda inmediatamente
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 pb-10 md:pb-20">
|
||||
{/* Hero Section */}
|
||||
<section className="relative min-h-[60vh] md:h-[80vh] flex items-center justify-center overflow-hidden rounded-2xl md:rounded-3xl mx-2 md:mx-4 mt-2 md:mt-4 shadow-2xl">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img
|
||||
src="./bg-car.jpg"
|
||||
className="w-full h-full object-cover opacity-40"
|
||||
alt="Hero background"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#0a0c10]/50 to-[#0a0c10]"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center px-4 max-w-4xl animate-fade-in-up">
|
||||
{/* Título optimizado para móvil */}
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-6xl xl:text-7xl font-black mb-4 md:mb-6 tracking-tighter leading-tight">
|
||||
ENCUENTRA TU <span className="text-gradient">PRÓXIMO</span> VEHÍCULO
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base md:text-xl text-gray-400 mb-6 md:mb-10 max-w-2xl mx-auto font-light px-2">
|
||||
La plataforma más avanzada para la compra y venta de Automóviles y Motos en Argentina.
|
||||
Integración total con medios impresos y digitales.
|
||||
</p>
|
||||
|
||||
{/* --- CONTENEDOR DEL BUSCADOR CON ref y onFocus --- */}
|
||||
<div className="relative max-w-3xl mx-auto" ref={searchWrapperRef}>
|
||||
{/* Botones de categoría arriba del buscador */}
|
||||
<div className="flex gap-2 mb-3 justify-center">
|
||||
<button
|
||||
onClick={() => setCategory('ALL')}
|
||||
className={`px-4 md:px-6 py-2 rounded-xl font-bold text-xs md:text-sm uppercase tracking-widest transition-all ${category === 'ALL' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/40' : 'glass text-gray-400 hover:text-white'}`}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCategory('EAUTOS')}
|
||||
className={`px-4 md:px-6 py-2 rounded-xl font-bold text-xs md:text-sm uppercase tracking-widest transition-all ${category === 'EAUTOS' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/40' : 'glass text-gray-400 hover:text-white'}`}
|
||||
>
|
||||
🚗 Automóviles
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCategory('EMOTOS')}
|
||||
className={`px-4 md:px-6 py-2 rounded-xl font-bold text-xs md:text-sm uppercase tracking-widest transition-all ${category === 'EMOTOS' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/40' : 'glass text-gray-400 hover:text-white'}`}
|
||||
>
|
||||
🏍️ Motos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass p-2 md:p-2 rounded-xl md:rounded-2xl flex flex-col md:flex-row gap-2 shadow-2xl">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Marca o modelo..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="bg-transparent border-none px-4 md:px-6 py-3 md:py-4 flex-1 outline-none text-white text-base md:text-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSearch()}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white px-6 md:px-10 py-3 md:py-4 rounded-lg md:rounded-xl font-bold text-sm md:text-base transition-all transform hover:scale-105 shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
BUSCAR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* --- DROPDOWN DE SUGERENCIAS --- */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="absolute top-full w-full mt-2 bg-[#1a1d24]/90 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl overflow-hidden z-20 animate-fade-in">
|
||||
<ul>
|
||||
{suggestions.map((s, i) => (
|
||||
<li
|
||||
key={i}
|
||||
onClick={() => handleSuggestionClick(s)}
|
||||
className="px-6 py-3 text-left text-white hover:bg-blue-600 cursor-pointer transition-colors border-b border-white/5 last:border-0"
|
||||
>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Grid Dinámica */}
|
||||
<section className="container mx-auto px-4 md:px-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-6 md:mb-10 gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-2">Avisos <span className="text-gradient">Destacados</span></h2>
|
||||
<p className="text-gray-400 text-base md:text-lg italic">Las mejores ofertas seleccionadas para vos.</p>
|
||||
</div>
|
||||
<Link to="/explorar" className="text-blue-400 hover:text-white transition text-sm md:text-base">Ver todos →</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
) : featuredAds.length === 0 ? (
|
||||
<div className="text-center p-10 glass rounded-3xl border border-white/5">
|
||||
<p className="text-gray-500 text-xl font-bold uppercase tracking-widest">No hay avisos destacados por el momento.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-8">
|
||||
{featuredAds.map(car => (
|
||||
<Link to={`/vehiculo/${car.id}`} key={car.id} className="glass-card rounded-2xl md:rounded-3xl overflow-hidden group">
|
||||
<div className="aspect-[4/3] overflow-hidden relative bg-[#07090d] flex items-center justify-center border-b border-white/5">
|
||||
<img
|
||||
src={getImageUrl(car.image)}
|
||||
className="max-w-full max-h-full object-contain group-hover:scale-110 transition-transform duration-700"
|
||||
alt={`${car.brandName} ${car.versionName}`}
|
||||
/>
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<AdStatusBadge statusId={car.statusId || 4} />
|
||||
</div>
|
||||
{car.isFeatured && (
|
||||
<div className="absolute top-4 right-4 bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full uppercase tracking-widest shadow-lg shadow-blue-600/40">DESTACADO</div>
|
||||
)}
|
||||
<div className="absolute bottom-4 right-4 bg-black/60 backdrop-blur-md text-white px-4 py-2 rounded-xl border border-white/10">
|
||||
<span className="text-xl font-bold">{formatCurrency(car.price, car.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-2xl font-bold text-white group-hover:text-blue-400 transition-colors uppercase tracking-tight truncate w-full">
|
||||
{car.brandName} {car.versionName}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[10px] text-gray-400 font-black tracking-widest uppercase">
|
||||
<span className="bg-gray-800/80 px-3 py-1.5 rounded-lg border border-white/5">{car.year}</span>
|
||||
<span className="bg-gray-800/80 px-3 py-1.5 rounded-lg border border-white/5">{car.km.toLocaleString()} KM</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
607
Frontend/src/pages/MisAvisosPage.tsx
Normal file
607
Frontend/src/pages/MisAvisosPage.tsx
Normal file
@@ -0,0 +1,607 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AdsV2Service, type AdListingDto } from '../services/ads.v2.service';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ChatService, type ChatMessage } from '../services/chat.service';
|
||||
import ChatModal from '../components/ChatModal';
|
||||
import { getImageUrl, parseUTCDate } from '../utils/app.utils';
|
||||
import { AD_STATUSES, STATUS_CONFIG } from '../constants/adStatuses';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
|
||||
type TabType = 'avisos' | 'favoritos' | 'mensajes';
|
||||
|
||||
export default function MisAvisosPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('avisos');
|
||||
const [avisos, setAvisos] = useState<AdListingDto[]>([]);
|
||||
const [favoritos, setFavoritos] = useState<AdListingDto[]>([]);
|
||||
const [mensajes, setMensajes] = useState<ChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { user, fetchUnreadCount } = useAuth();
|
||||
|
||||
const [selectedChat, setSelectedChat] = useState<{ adId: number, name: string, otherUserId: number } | null>(null);
|
||||
|
||||
const [modalConfig, setModalConfig] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
adId: number | null;
|
||||
newStatus: number | null;
|
||||
isDanger: boolean;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
adId: null,
|
||||
newStatus: null,
|
||||
isDanger: false
|
||||
});
|
||||
|
||||
// Función para forzar chequeo manual desde Gestión
|
||||
const handleVerifyPayment = async (adId: number) => {
|
||||
try {
|
||||
const res = await AdsV2Service.checkPaymentStatus(adId);
|
||||
if (res.status === 'approved') {
|
||||
alert("¡Pago confirmado! El aviso pasará a moderación.");
|
||||
cargarAvisos(user!.id);
|
||||
} else if (res.status === 'rejected') {
|
||||
alert("El pago fue rechazado. Puedes intentar pagar nuevamente.");
|
||||
cargarAvisos(user!.id); // Debería volver a estado Draft/1
|
||||
} else {
|
||||
alert("El pago sigue pendiente de aprobación por la tarjeta.");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Error verificando el pago.");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
cargarMensajes(user.id);
|
||||
if (activeTab === 'avisos') cargarAvisos(user.id);
|
||||
else if (activeTab === 'favoritos') cargarFavoritos(user.id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, activeTab]);
|
||||
|
||||
const initiateStatusChange = (adId: number, newStatus: number) => {
|
||||
let title = 'Cambiar Estado';
|
||||
let message = '¿Estás seguro de realizar esta acción?';
|
||||
let isDanger = false;
|
||||
|
||||
// 1. ELIMINAR
|
||||
if (newStatus === AD_STATUSES.DELETED) {
|
||||
title = '¿Eliminar Aviso?';
|
||||
message = 'Esta acción eliminará el aviso permanentemente. No se puede deshacer.\n\n¿Estás seguro de continuar?';
|
||||
isDanger = true;
|
||||
}
|
||||
// 2. PAUSAR
|
||||
else if (newStatus === AD_STATUSES.PAUSED) {
|
||||
title = 'Pausar Publicación';
|
||||
message = 'Al pausar el aviso:\n\n• Dejará de ser visible en los listados.\n• Los usuarios NO podrán contactarte.\n\nPodrás reactivarlo cuando quieras, dentro de la vigencia de publicación.';
|
||||
}
|
||||
// 3. VENDIDO
|
||||
else if (newStatus === AD_STATUSES.SOLD) {
|
||||
title = '¡Felicitaciones!';
|
||||
message = 'Al marcar como VENDIDO:\n\n• Se deshabilitarán nuevas consultas.\n• El aviso mostrará la etiqueta "Vendido" al público.\n\n¿Confirmas que ya vendiste el vehículo?';
|
||||
}
|
||||
// 4. RESERVADO
|
||||
else if (newStatus === AD_STATUSES.RESERVED) {
|
||||
title = 'Reservar Vehículo';
|
||||
message = 'Al reservar el aviso:\n\n• Se indicará a los interesados que el vehículo está reservado.\n• Se bloquearán nuevos contactos hasta que lo actives o vendas.\n\n¿Deseas continuar?';
|
||||
}
|
||||
// 5. ACTIVAR (Desde Pausado/Reservado)
|
||||
else if (newStatus === AD_STATUSES.ACTIVE) {
|
||||
title = 'Reactivar Aviso';
|
||||
message = 'El aviso volverá a estar visible para todos y recibirás consultas nuevamente.';
|
||||
}
|
||||
|
||||
setModalConfig({
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
adId,
|
||||
newStatus,
|
||||
isDanger
|
||||
});
|
||||
};
|
||||
|
||||
// Acción real al confirmar en el modal
|
||||
const confirmStatusChange = async () => {
|
||||
const { adId, newStatus } = modalConfig;
|
||||
if (!adId || !newStatus) return;
|
||||
|
||||
try {
|
||||
setModalConfig({ ...modalConfig, isOpen: false }); // Cerrar modal primero
|
||||
await AdsV2Service.changeStatus(adId, newStatus);
|
||||
if (user) cargarAvisos(user.id);
|
||||
} catch (error) {
|
||||
alert('Error al actualizar estado');
|
||||
}
|
||||
};
|
||||
|
||||
const cargarAvisos = async (userId: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await AdsV2Service.getAll({ userId });
|
||||
setAvisos(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cargarFavoritos = async (userId: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await AdsV2Service.getFavorites(userId);
|
||||
setFavoritos(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cargarMensajes = async (userId: number) => {
|
||||
try {
|
||||
const data = await ChatService.getInbox(userId);
|
||||
setMensajes(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Marcar como leídos en DB
|
||||
const openChatForAd = async (adId: number, adTitle: string) => {
|
||||
if (!user) return;
|
||||
const relatedMsg = mensajes.find(m => m.adID === adId);
|
||||
|
||||
if (relatedMsg) {
|
||||
const otherId = relatedMsg.senderID === user.id ? relatedMsg.receiverID : relatedMsg.senderID;
|
||||
|
||||
// Identificar mensajes no leídos para este chat
|
||||
const unreadMessages = mensajes.filter(m => m.adID === adId && !m.isRead && m.receiverID === user.id);
|
||||
|
||||
if (unreadMessages.length > 0) {
|
||||
// Optimización visual: actualiza la UI localmente de inmediato
|
||||
setMensajes(prev => prev.map(m =>
|
||||
unreadMessages.some(um => um.messageID === m.messageID) ? { ...m, isRead: true } : m
|
||||
));
|
||||
|
||||
try {
|
||||
// Crea un array de promesas para todas las llamadas a la API
|
||||
const markAsReadPromises = unreadMessages.map(m =>
|
||||
m.messageID ? ChatService.markAsRead(m.messageID) : Promise.resolve()
|
||||
);
|
||||
|
||||
// Espera a que TODAS las llamadas al backend terminen
|
||||
await Promise.all(markAsReadPromises);
|
||||
|
||||
// SOLO DESPUÉS de que el backend confirme, actualizamos el contador global
|
||||
await fetchUnreadCount();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error al marcar mensajes como leídos:", error);
|
||||
// Opcional: podrías revertir el estado local si la API falla
|
||||
}
|
||||
}
|
||||
|
||||
// Abrir el modal de chat
|
||||
setSelectedChat({ adId, name: adTitle, otherUserId: otherId });
|
||||
} else {
|
||||
alert("No tienes mensajes activos para este aviso.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFavorite = async (adId: number) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await AdsV2Service.removeFavorite(user.id, adId);
|
||||
cargarFavoritos(user.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseChat = () => {
|
||||
setSelectedChat(null);
|
||||
if (user) {
|
||||
cargarMensajes(user.id); // Recarga la lista de mensajes por si llegaron nuevos mientras estaba abierto
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-24 text-center animate-fade-in-up">
|
||||
<div className="glass p-12 rounded-[3rem] max-w-2xl mx-auto border border-white/5 shadow-2xl">
|
||||
<span className="text-7xl mb-8 block">🔒</span>
|
||||
<h2 className="text-5xl font-black mb-4 uppercase tracking-tighter">Área Privada</h2>
|
||||
<p className="text-gray-400 mb-10 text-lg italic">
|
||||
Para gestionar tus publicaciones, primero debes iniciar sesión.
|
||||
</p>
|
||||
<Link to="/publicar" className="bg-blue-600 hover:bg-blue-500 text-white px-12 py-5 rounded-[2rem] font-bold uppercase tracking-widest transition-all inline-block shadow-lg shadow-blue-600/20">
|
||||
Identificarse
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalVisitas = avisos.reduce((acc, curr) => acc + (curr.viewsCounter || 0), 0);
|
||||
const avisosActivos = avisos.filter(a => a.statusId === 4).length;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-12 animate-fade-in-up min-h-screen">
|
||||
|
||||
<header className="flex flex-col md:flex-row justify-between items-start md:items-end mb-16 gap-8">
|
||||
<div>
|
||||
<h2 className="text-5xl font-black tracking-tighter uppercase mb-4">Mis <span className="text-blue-500">Avisos</span></h2>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-14 h-14 bg-gradient-to-tr from-blue-600 to-cyan-400 rounded-2xl flex items-center justify-center text-white text-xl font-black shadow-xl shadow-blue-600/20">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white text-xl font-black block leading-none">{user.firstName} {user.lastName}</span>
|
||||
<span className="text-gray-500 text-[10px] uppercase font-black tracking-[0.3em]">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full md:w-auto bg-white/5 p-1 rounded-xl md:rounded-2xl border border-white/5 backdrop-blur-xl overflow-x-auto no-scrollbar gap-0.5 md:gap-0">
|
||||
{(['avisos', 'favoritos', 'mensajes'] as TabType[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 md:flex-none px-2.5 md:px-8 py-2 md:py-3 rounded-lg md:rounded-xl text-[9px] md:text-[10px] font-black uppercase tracking-widest transition-all whitespace-nowrap ${activeTab === tab ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20' : 'text-gray-500 hover:text-white'}`}
|
||||
>
|
||||
{tab === 'avisos' ? '📦 Mis Avisos' : tab === 'favoritos' ? '⭐ Favoritos' : '💬 Mensajes'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="animate-fade-in space-y-8 md:space-y-12">
|
||||
|
||||
{activeTab === 'avisos' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-6 mb-8 md:mb-12">
|
||||
<MetricCard label="Visualizaciones" value={totalVisitas} icon="👁️" />
|
||||
<MetricCard label="Activos" value={avisosActivos} icon="✅" />
|
||||
<MetricCard label="Favoritos" value={favoritos.length} icon="⭐" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-24">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'avisos' && (
|
||||
<div className="space-y-6">
|
||||
{avisos.filter(a => a.statusId !== 9).length === 0 ? (
|
||||
<div className="glass p-12 md:p-24 rounded-3xl md:rounded-[3rem] text-center border-dashed border-2 border-white/10">
|
||||
<span className="text-4xl md:text-5xl mb-6 block">📂</span>
|
||||
<h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">No tienes avisos</h3>
|
||||
<Link to="/publicar" className="mt-8 text-blue-400 font-black uppercase text-xs tracking-widest inline-block border-b border-blue-400 pb-1">Crear mi primer aviso</Link>
|
||||
</div>
|
||||
) : (
|
||||
avisos.filter(a => a.statusId !== 9).map((av, index) => {
|
||||
const hasMessages = mensajes.some(m => m.adID === av.id);
|
||||
const hasUnread = mensajes.some(m => m.adID === av.id && !m.isRead && m.receiverID === user.id);
|
||||
|
||||
return (
|
||||
// 'relative z-index' dinámico
|
||||
// Esto permite que el dropdown se salga de la tarjeta sin cortarse.
|
||||
// Usamos un z-index decreciente para que los dropdowns de arriba tapen a las tarjetas de abajo.
|
||||
<div
|
||||
key={av.id}
|
||||
className="glass p-6 rounded-[2.5rem] flex flex-col md:flex-row items-center gap-8 border border-white/5 hover:border-blue-500/20 transition-all relative"
|
||||
style={{ zIndex: 50 - index }}
|
||||
>
|
||||
|
||||
<div className="w-full md:w-64 h-40 bg-gray-900 rounded-3xl overflow-hidden relative flex-shrink-0 shadow-xl">
|
||||
<img src={getImageUrl(av.image)} className="w-full h-full object-cover" alt={`${av.brandName} ${av.versionName}`} />
|
||||
<div className="absolute top-3 left-3 bg-black/60 backdrop-blur-md px-2 py-1 rounded-lg border border-white/10">
|
||||
<span className="text-[9px] font-bold text-white">#{av.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full text-center md:text-left">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-2xl font-black text-white uppercase tracking-tighter truncate max-w-md">
|
||||
{av.brandName} {av.versionName}
|
||||
</h3>
|
||||
<span className="text-blue-400 font-bold text-lg">{av.currency} {av.price.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 justify-center md:justify-start">
|
||||
<div className="bg-white/5 border border-white/5 px-3 py-1.5 rounded-lg flex items-center gap-2">
|
||||
<span className="text-[10px] text-gray-500 font-bold uppercase">Año</span>
|
||||
<span className="text-xs text-white font-bold">{av.year}</span>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/5 px-3 py-1.5 rounded-lg flex items-center gap-2">
|
||||
<span className="text-[10px] text-gray-500 font-bold uppercase">Visitas</span>
|
||||
<span className="text-xs text-white font-bold">{av.viewsCounter || 0}</span>
|
||||
</div>
|
||||
{av.isFeatured && (
|
||||
<div className="bg-blue-600/20 border border-blue-500/30 px-3 py-1.5 rounded-lg">
|
||||
<span className="text-[9px] text-blue-300 font-black uppercase tracking-widest">⭐ Destacado</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-auto flex flex-col gap-3 min-w-[180px]">
|
||||
|
||||
{/* CASO 1: BORRADOR (1) -> Botón de Pagar */}
|
||||
{av.statusId === AD_STATUSES.DRAFT && (
|
||||
<Link
|
||||
to={`/publicar?edit=${av.id}`}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white text-xs font-black uppercase tracking-widest rounded-xl px-4 py-3 text-center shadow-lg shadow-blue-600/20 transition-all"
|
||||
>
|
||||
Continuar Pago ➔
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* CASO 2: PAGO PENDIENTE (2) -> Botón de Verificar */}
|
||||
{av.statusId === AD_STATUSES.PAYMENT_PENDING && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-400 px-4 py-2 rounded-xl text-center">
|
||||
<span className="block text-[10px] font-black uppercase tracking-widest">⏳ Pago Pendiente</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleVerifyPayment(av.id)}
|
||||
className="bg-white/5 hover:bg-white/10 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-2.5 rounded-xl border border-white/10 transition-all hover:border-white/20 flex items-center justify-center gap-2"
|
||||
>
|
||||
🔄 Verificar Ahora
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CASO 3: EN REVISIÓN (3) -> Cartel informativo */}
|
||||
{av.statusId === AD_STATUSES.MODERATION_PENDING && (
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 text-blue-300 px-4 py-3 rounded-xl text-center">
|
||||
<span className="block text-[10px] font-black uppercase tracking-widest">⏳ En Revisión</span>
|
||||
<span className="text-[8px] opacity-70">No editable</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CASO 4: VENCIDO (8) -> Botón de Republicar */}
|
||||
{av.statusId === AD_STATUSES.EXPIRED && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="bg-gray-500/10 border border-gray-500/20 text-gray-400 px-4 py-2 rounded-xl text-center">
|
||||
<span className="block text-[10px] font-black uppercase tracking-widest">⛔ Finalizado</span>
|
||||
</div>
|
||||
<Link
|
||||
to={`/publicar?edit=${av.id}`}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-2.5 rounded-xl border border-transparent shadow-lg shadow-blue-600/20 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
🔄 Republicar
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CASO 5: ACTIVOS/PAUSADOS/OTROS (StatusDropdown) */}
|
||||
{av.statusId !== AD_STATUSES.DRAFT &&
|
||||
av.statusId !== AD_STATUSES.PAYMENT_PENDING &&
|
||||
av.statusId !== AD_STATUSES.MODERATION_PENDING &&
|
||||
av.statusId !== AD_STATUSES.EXPIRED && (
|
||||
<StatusDropdown
|
||||
currentStatus={av.statusId || AD_STATUSES.ACTIVE}
|
||||
onChange={(newStatus) => initiateStatusChange(av.id, newStatus)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* BOTONES COMUNES (Siempre visibles) */}
|
||||
<div className="grid grid-cols-1 gap-2 mt-1">
|
||||
<Link
|
||||
to={`/vehiculo/${av.id}`}
|
||||
className="bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white border border-white/5 px-4 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest text-center transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>👁️ Ver Detalle</span>
|
||||
</Link>
|
||||
|
||||
{hasMessages && (
|
||||
<button
|
||||
onClick={() => openChatForAd(av.id, `${av.brandName} ${av.versionName}`)}
|
||||
className="relative bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white border border-white/5 px-4 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all"
|
||||
>
|
||||
💬 Mensajes
|
||||
{hasUnread && (
|
||||
<span className="absolute top-3 right-3 w-2 h-2 bg-red-500 rounded-full animate-pulse shadow-lg shadow-red-500/50"></span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'favoritos' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{favoritos.length === 0 ? (
|
||||
<div className="col-span-full glass p-12 md:p-24 rounded-3xl md:rounded-[3rem] text-center border-dashed border-2 border-white/10">
|
||||
<span className="text-4xl md:text-5xl mb-6 block">⭐</span>
|
||||
<h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">No tienes favoritos</h3>
|
||||
<Link to="/explorar" className="mt-8 text-blue-400 font-black uppercase text-xs tracking-widest inline-block border-b border-blue-400 pb-1">Explorar vehículos</Link>
|
||||
</div>
|
||||
) : (
|
||||
favoritos.map((fav) => (
|
||||
<div key={fav.id} className="glass rounded-[2rem] overflow-hidden border border-white/5 flex flex-col group hover:border-blue-500/30 transition-all">
|
||||
<div className="relative h-48">
|
||||
<img src={getImageUrl(fav.image)} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" alt={`${fav.brandName} ${fav.versionName}`} />
|
||||
<button onClick={() => handleRemoveFavorite(fav.id)} className="absolute top-4 right-4 w-10 h-10 bg-black/50 backdrop-blur-md rounded-xl flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-all shadow-xl">×</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-black text-white uppercase tracking-tighter mb-1 truncate">{fav.brandName} {fav.versionName}</h3>
|
||||
<p className="text-blue-400 font-extrabold text-xl mb-4">{fav.currency} {fav.price.toLocaleString()}</p>
|
||||
<Link to={`/vehiculo/${fav.id}`} className="block w-full bg-blue-600/10 hover:bg-blue-600 text-blue-400 hover:text-white p-3 rounded-xl text-[10px] font-black uppercase tracking-widest text-center transition-all border border-blue-600/20">Ver Detalle</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'mensajes' && (
|
||||
<div className="space-y-4">
|
||||
{mensajes.length === 0 ? (
|
||||
<div className="glass p-12 md:p-24 rounded-3xl md:rounded-[3rem] text-center border-dashed border-2 border-white/10">
|
||||
<span className="text-4xl md:text-5xl mb-6 block">💬</span>
|
||||
<h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">No tienes mensajes</h3>
|
||||
<p className="text-gray-600 mt-2 max-w-sm mx-auto italic text-lg">Los moderadores te contactarán por aquí si es necesario.</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.values(mensajes.reduce((acc: any, curr) => {
|
||||
const key = curr.adID;
|
||||
if (!acc[key]) acc[key] = { msg: curr, count: 0, unread: false };
|
||||
acc[key].count++;
|
||||
if (!curr.isRead && curr.receiverID === user.id) acc[key].unread = true;
|
||||
if (new Date(curr.sentAt!) > new Date(acc[key].msg.sentAt!)) acc[key].msg = curr;
|
||||
return acc;
|
||||
}, {})).map((item: any) => {
|
||||
const aviso = avisos.find(a => a.id === item.msg.adID);
|
||||
const tituloAviso = aviso ? `${aviso.brandName} ${aviso.versionName}` : `Aviso #${item.msg.adID}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.msg.adID}
|
||||
onClick={() => openChatForAd(item.msg.adID, tituloAviso)}
|
||||
className="glass p-6 rounded-2xl flex items-center gap-6 border border-white/5 hover:border-blue-500/30 transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-blue-600/20 rounded-full flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">🛡️</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h4 className="font-black uppercase tracking-tighter text-white">
|
||||
{tituloAviso}
|
||||
</h4>
|
||||
<span className="text-[10px] text-gray-500 font-bold uppercase">{parseUTCDate(item.msg.sentAt!).toLocaleDateString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 line-clamp-1">
|
||||
{item.msg.senderID === user.id ? 'Tú: ' : ''}{item.msg.messageText}
|
||||
</p>
|
||||
</div>
|
||||
{item.unread && (
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full shadow-lg shadow-red-500/50"></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedChat && user && (
|
||||
<ChatModal
|
||||
isOpen={!!selectedChat}
|
||||
onClose={handleCloseChat}
|
||||
adId={selectedChat.adId}
|
||||
adTitle={selectedChat.name}
|
||||
sellerId={selectedChat.otherUserId}
|
||||
currentUserId={user.id}
|
||||
/>
|
||||
)}
|
||||
{/* MODAL DE CONFIRMACIÓN */}
|
||||
<ConfirmationModal
|
||||
isOpen={modalConfig.isOpen}
|
||||
title={modalConfig.title}
|
||||
message={modalConfig.message}
|
||||
onConfirm={confirmStatusChange}
|
||||
onCancel={() => setModalConfig({ ...modalConfig, isOpen: false })}
|
||||
isDanger={modalConfig.isDanger}
|
||||
confirmText={modalConfig.newStatus === AD_STATUSES.SOLD ? "¡Sí, vendido!" : "Confirmar"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DROPDOWN DE ESTADO
|
||||
function StatusDropdown({ currentStatus, onChange }: { currentStatus: number, onChange: (val: number) => void }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fallback seguro si currentStatus no tiene config
|
||||
const currentConfig = STATUS_CONFIG[currentStatus] || {
|
||||
label: 'Desconocido',
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-500/10',
|
||||
border: 'border-gray-500/20',
|
||||
icon: '❓'
|
||||
};
|
||||
|
||||
const ALLOWED_STATUSES = [
|
||||
AD_STATUSES.ACTIVE,
|
||||
AD_STATUSES.PAUSED,
|
||||
AD_STATUSES.RESERVED,
|
||||
AD_STATUSES.SOLD,
|
||||
AD_STATUSES.DELETED
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={wrapperRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl border ${currentConfig.bg} ${currentConfig.border} ${currentConfig.color} transition-all hover:brightness-110`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{currentConfig.icon}</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">{currentConfig.label}</span>
|
||||
</div>
|
||||
<span className="text-xs">▼</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-[100] w-full mt-2 bg-[#1a1d24] border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-fade-in ring-1 ring-white/5">
|
||||
{ALLOWED_STATUSES.map((statusId) => {
|
||||
const config = STATUS_CONFIG[statusId];
|
||||
|
||||
// PROTECCIÓN CONTRA EL ERROR DE "UNDEFINED"
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={statusId}
|
||||
onClick={() => { onChange(statusId); setIsOpen(false); }}
|
||||
className={`w-full text-left px-4 py-3 text-[10px] font-bold uppercase tracking-widest hover:bg-white/5 transition-colors border-b border-white/5 last:border-0 flex items-center gap-2 ${statusId === currentStatus ? 'text-white bg-white/5' : 'text-gray-400'}`}
|
||||
>
|
||||
<span className="text-sm">{config.icon}</span>
|
||||
{config.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, icon }: { label: string, value: any, icon: string }) {
|
||||
return (
|
||||
<div className="glass p-4 md:p-8 rounded-2xl md:rounded-[2rem] border border-white/5 flex flex-row items-center gap-4 md:gap-6 text-left">
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-white/5 rounded-xl md:rounded-2xl flex items-center justify-center text-xl md:text-3xl shadow-inner border border-white/5">{icon}</div>
|
||||
<div>
|
||||
<span className="text-2xl md:text-3xl font-black text-white tracking-tighter block leading-none mb-1">{value.toLocaleString()}</span>
|
||||
<span className="text-[9px] md:text-[10px] font-black uppercase tracking-widest text-gray-500 block leading-tight">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
Frontend/src/pages/PerfilPage.tsx
Normal file
144
Frontend/src/pages/PerfilPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ProfileService } from '../services/profile.service';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function PerfilPage() {
|
||||
const { user, refreshSession } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phoneNumber: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, []);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const data = await ProfileService.getProfile();
|
||||
setFormData({
|
||||
firstName: data.firstName || '',
|
||||
lastName: data.lastName || '',
|
||||
phoneNumber: data.phoneNumber || ''
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error loading profile", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await ProfileService.updateProfile(formData);
|
||||
alert('Perfil actualizado con éxito');
|
||||
if (refreshSession) refreshSession();
|
||||
} catch (err) {
|
||||
alert('Error al actualizar el perfil');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center p-40">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-12 max-w-4xl">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-5xl font-black tracking-tighter uppercase mb-2">Mi <span className="text-blue-500">Perfil</span></h1>
|
||||
<p className="text-gray-500 font-bold tracking-widest uppercase text-xs">Gestiona tu información personal</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Sidebar Info */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<div className="glass p-8 rounded-[2rem] border border-white/5 text-center">
|
||||
<div className="w-24 h-24 bg-blue-600/20 rounded-full flex items-center justify-center text-4xl text-blue-400 font-bold mx-auto mb-4 border border-blue-500/20 shadow-lg shadow-blue-500/10">
|
||||
{user?.username?.[0].toUpperCase()}
|
||||
</div>
|
||||
<h2 className="text-xl font-black text-white uppercase tracking-tight">{user?.username}</h2>
|
||||
<p className="text-xs text-gray-500 font-medium mb-6">{user?.email}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={`px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest ${user?.userType === 3 ? 'bg-amber-500/10 text-amber-500 border border-amber-500/20' : 'bg-blue-600/10 text-blue-400 border border-blue-600/20'}`}>
|
||||
{user?.userType === 3 ? '🛡️ Administrador' : '👤 Usuario Particular'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
<div className="lg:col-span-2">
|
||||
<form onSubmit={handleSubmit} className="glass p-8 rounded-[2.5rem] border border-white/5 space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Nombre</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.firstName}
|
||||
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
|
||||
placeholder="Tu nombre"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Apellido</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.lastName}
|
||||
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
|
||||
placeholder="Tu apellido"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Teléfono de Contacto</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.phoneNumber}
|
||||
onChange={e => setFormData({ ...formData, phoneNumber: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
|
||||
placeholder="Ej: +54 9 11 1234 5678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Email <span className="text-[8px] text-gray-600 font-normal">(No editable)</span></label>
|
||||
<input
|
||||
type="email"
|
||||
value={user?.email}
|
||||
disabled
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-gray-500 outline-none cursor-not-allowed font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-white/5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full md:w-auto bg-blue-600 hover:bg-blue-500 text-white py-4 px-12 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar Cambios'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
Frontend/src/pages/PublicarAvisoPage.tsx
Normal file
266
Frontend/src/pages/PublicarAvisoPage.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'; // Importar useNavigate
|
||||
import { AvisosService } from '../services/avisos.service';
|
||||
import { AdsV2Service } from '../services/ads.v2.service';
|
||||
import { AuthService, type UserSession } from '../services/auth.service';
|
||||
import type { DatosAvisoDto } from '../types/aviso.types';
|
||||
import FormularioAviso from '../components/FormularioAviso';
|
||||
import LoginModal from '../components/LoginModal';
|
||||
|
||||
const TAREAS_DISPONIBLES = [
|
||||
{ id: 'EAUTOS', label: 'Automóviles', icon: '🚗', description: 'Venta de Autos, Camionetas y Utilitarios' },
|
||||
{ id: 'EMOTOS', label: 'Motos', icon: '🏍️', description: 'Venta de Motos, Cuatriciclos y Náutica' },
|
||||
];
|
||||
|
||||
export default function PublicarAvisoPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate(); // Hook de navegación
|
||||
const editId = searchParams.get('edit');
|
||||
|
||||
const [categorySelection, setCategorySelection] = useState<string>('');
|
||||
const [tarifas, setTarifas] = useState<DatosAvisoDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [planSeleccionado, setPlanSeleccionado] = useState<DatosAvisoDto | null>(null);
|
||||
const [fixedCategory, setFixedCategory] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<UserSession | null>(AuthService.getCurrentUser());
|
||||
|
||||
useEffect(() => {
|
||||
if (editId) {
|
||||
cargarAvisoParaEdicion(parseInt(editId));
|
||||
}
|
||||
}, [editId]);
|
||||
|
||||
const cargarAvisoParaEdicion = async (id: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ad = await AdsV2Service.getById(id);
|
||||
|
||||
// Determinamos la categoría para cargar las tarifas correspondientes
|
||||
const categoryCode = ad.vehicleTypeID === 1 ? 'EAUTOS' : 'EMOTOS';
|
||||
|
||||
setCategorySelection(categoryCode);
|
||||
|
||||
// 🟢 FIX: Bloquear el cambio de categoría
|
||||
setFixedCategory(categoryCode);
|
||||
|
||||
// 🟢 FIX: NO seleccionamos plan automáticamente.
|
||||
// Dejamos que el usuario elija el plan en las tarjetas.
|
||||
// (Eliminamos todo el bloque de setPlanSeleccionado)
|
||||
|
||||
/* BLOQUE ELIMINADO:
|
||||
const tarifasData = await AvisosService.obtenerConfiguracion('EMOTORES', ad.isFeatured ? 1 : 0);
|
||||
const tarifaReal = tarifasData[0];
|
||||
if (!tarifaReal) throw new Error("Tarifa no encontrada");
|
||||
setPlanSeleccionado({ ... });
|
||||
*/
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Error al cargar el aviso.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!categorySelection) return;
|
||||
|
||||
const cargarTarifas = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [simple, destacado] = await Promise.all([
|
||||
AvisosService.obtenerConfiguracion('EMOTORES', 0),
|
||||
AvisosService.obtenerConfiguracion('EMOTORES', 1)
|
||||
]);
|
||||
|
||||
const planes = [...simple, ...destacado];
|
||||
planes.sort((a, b) => a.importeTotsiniva - b.importeTotsiniva);
|
||||
|
||||
setTarifas(planes);
|
||||
} catch (err) {
|
||||
setError("Error al cargar tarifas.");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
cargarTarifas();
|
||||
}, [categorySelection]);
|
||||
|
||||
const handleSelectPlan = (plan: DatosAvisoDto) => {
|
||||
const vehicleTypeId = categorySelection === 'EAUTOS' ? 1 : 2;
|
||||
const nombrePlanAmigable = plan.paquete === 1 ? 'PLAN DESTACADO' : 'PLAN ESTÁNDAR';
|
||||
|
||||
setPlanSeleccionado({
|
||||
...plan,
|
||||
idRubro: vehicleTypeId,
|
||||
nomavi: nombrePlanAmigable
|
||||
});
|
||||
};
|
||||
|
||||
// Manejador centralizado de éxito
|
||||
const handleSuccess = (adId: number, isAdminAction: boolean = false) => {
|
||||
const status = isAdminAction ? 'admin_created' : 'approved';
|
||||
navigate(`/pago-confirmado?status=${status}&adId=${adId}`);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20 min-h-[60vh]">
|
||||
<LoginModal onSuccess={(u) => setUser(u)} onClose={() => navigate('/')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ELIMINADO: Bloque if (publicacionExitosa) { return ... }
|
||||
|
||||
if (planSeleccionado) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto py-8 px-6">
|
||||
<header className="flex justify-between items-center mb-10">
|
||||
<button onClick={() => setPlanSeleccionado(null)} className="text-gray-500 hover:text-white uppercase text-[10px] font-black tracking-widest flex items-center gap-2 transition-colors">
|
||||
← Volver a Planes
|
||||
</button>
|
||||
<div className="glass px-4 py-2 rounded-xl text-xs border border-white/5">
|
||||
Publicando como: <span className="text-blue-400 font-bold">{user.username}</span>
|
||||
</div>
|
||||
</header>
|
||||
<FormularioAviso
|
||||
plan={planSeleccionado}
|
||||
onCancel={() => setPlanSeleccionado(null)}
|
||||
onSuccess={handleSuccess} // Usamos la redirección
|
||||
editId={editId ? parseInt(editId) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 md:px-6 py-8 md:py-12">
|
||||
<header className="mb-8 md:mb-16 text-center md:text-left">
|
||||
<h2 className="text-3xl md:text-6xl font-black tracking-tighter uppercase mb-2">Comienza a <span className="text-gradient">Vender</span></h2>
|
||||
<p className="text-gray-500 text-sm md:text-lg italic">Selecciona una categoría para ver los planes de publicación.</p>
|
||||
</header>
|
||||
|
||||
{/* SECCIÓN DE CATEGORÍA */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8 mb-10 md:mb-20 text-white">
|
||||
{TAREAS_DISPONIBLES.map(t => {
|
||||
// Lógica de bloqueo
|
||||
const isDisabled = fixedCategory && fixedCategory !== t.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => !isDisabled && setCategorySelection(t.id)}
|
||||
disabled={!!isDisabled} // Deshabilitar botón
|
||||
className={`
|
||||
glass-card p-6 md:p-10 rounded-[2rem] md:rounded-[2.5rem] flex items-center justify-between group transition-all text-left
|
||||
${categorySelection === t.id ? 'border-blue-500 scale-[1.02] shadow-2xl shadow-blue-600/10 bg-white/5' : 'hover:bg-white/5'}
|
||||
${isDisabled ? 'opacity-30 cursor-not-allowed grayscale' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
<span className="text-[10px] md:text-xs font-black uppercase tracking-widest text-blue-400 mb-1 md:mb-2 block">Categoría</span>
|
||||
<h3 className="text-2xl md:text-4xl font-bold mb-1 md:mb-2 uppercase tracking-tight">{t.label}</h3>
|
||||
<p className="text-gray-500 font-light text-xs md:text-sm">{t.description}</p>
|
||||
</div>
|
||||
<span className="text-4xl md:text-6xl group-hover:scale-110 transition-transform duration-300">{t.icon}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{/* LISTA DE PLANES */}
|
||||
{categorySelection && (
|
||||
<section className="animate-fade-in-up">
|
||||
<div className="flex justify-between items-end mb-10 border-b border-white/5 pb-4">
|
||||
<div>
|
||||
<h3 className="text-3xl font-black uppercase tracking-tighter">Planes <span className="text-blue-400">Disponibles</span></h3>
|
||||
<p className="text-gray-500 text-xs mt-1 uppercase tracking-widest">Para {categorySelection === 'EAUTOS' ? 'Automóviles' : 'Motos'}</p>
|
||||
</div>
|
||||
<span className="text-gray-600 text-[10px] font-bold uppercase tracking-widest">Precios finales (IVA Incluido)</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 text-red-400 p-6 rounded-[2rem] border border-red-500/20 mb-10 text-center">
|
||||
<p className="font-bold uppercase tracking-widest text-xs mb-2">Error de Conexión</p>
|
||||
<p className="italic text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-20"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{tarifas.map((tarifa, idx) => {
|
||||
const precioRaw = tarifa.importeTotsiniva > 0
|
||||
? tarifa.importeTotsiniva * 1.105
|
||||
: tarifa.importeSiniva * 1.105;
|
||||
const precioFinal = Math.round(precioRaw);
|
||||
const esDestacado = tarifa.paquete === 1;
|
||||
const tituloPlan = esDestacado ? "DESTACADO" : "ESTÁNDAR";
|
||||
const descripcionPlan = esDestacado
|
||||
? "Máxima visibilidad. Tu aviso aparecerá en el carrusel de inicio y primeros lugares de búsqueda."
|
||||
: "Presencia esencial. Tu aviso aparecerá en el listado general de búsqueda.";
|
||||
|
||||
return (
|
||||
<div key={idx} onClick={() => handleSelectPlan(tarifa)}
|
||||
className={`glass-card p-8 rounded-[2.5rem] flex flex-col group cursor-pointer relative overflow-hidden transition-all hover:-translate-y-2 hover:shadow-2xl ${esDestacado ? 'border-blue-500/30 hover:border-blue-500 hover:shadow-blue-900/20' : 'hover:border-white/30'}`}>
|
||||
|
||||
<div className={`absolute top-0 right-0 text-white text-[9px] font-black px-6 py-2 rounded-bl-3xl uppercase tracking-widest shadow-lg ${esDestacado ? 'bg-gradient-to-bl from-blue-600 to-cyan-500 animate-glow' : 'bg-white/10 text-gray-400'}`}>
|
||||
{esDestacado ? 'RECOMENDADO' : 'BÁSICO'}
|
||||
</div>
|
||||
|
||||
<h4 className={`text-3xl font-black uppercase tracking-tighter mb-4 mt-4 ${esDestacado ? 'text-blue-400' : 'text-white'}`}>
|
||||
{tituloPlan}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-4 mb-10 flex-1">
|
||||
<p className="text-gray-400 text-sm leading-relaxed border-b border-white/5 pb-4 min-h-[60px]">
|
||||
{descripcionPlan}
|
||||
</p>
|
||||
|
||||
<ul className="space-y-3">
|
||||
<li className="flex justify-between text-xs text-gray-300 items-center">
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">Plataforma</span>
|
||||
<span className="font-bold bg-white/5 px-2 py-1 rounded text-[10px]">SOLO WEB</span>
|
||||
</li>
|
||||
<li className="flex justify-between text-xs text-gray-300 items-center">
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">Duración</span>
|
||||
<span className="font-bold">{tarifa.cantidadDias} Días</span>
|
||||
</li>
|
||||
<li className="flex justify-between text-xs text-gray-300 items-center">
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">Fotos</span>
|
||||
<span className="font-bold text-green-400">Hasta 5</span>
|
||||
</li>
|
||||
<li className="flex justify-between text-xs text-gray-300 items-center">
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">Visibilidad</span>
|
||||
<span className={`font-bold ${esDestacado ? 'text-blue-400' : 'text-gray-300'}`}>{esDestacado ? 'ALTA ⭐' : 'NORMAL'}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-6 border-t border-white/5">
|
||||
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest block mb-1">Precio Final</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-black tracking-tighter text-white">
|
||||
${precioFinal.toLocaleString('es-AR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-gray-500">ARS</span>
|
||||
</div>
|
||||
<button className={`w-full mt-6 text-white py-4 rounded-xl font-bold uppercase text-xs tracking-widest transition-all shadow-lg ${esDestacado ? 'bg-blue-600 hover:bg-blue-500 shadow-blue-600/20' : 'bg-white/5 hover:bg-white/10'}`}>
|
||||
Seleccionar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
Frontend/src/pages/ResetPasswordPage.tsx
Normal file
141
Frontend/src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
// --- ICONOS SVG ---
|
||||
const EyeIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeSlashIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckCircleIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-green-500">
|
||||
<path fillRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const NeutralCircleIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-gray-600">
|
||||
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm0 18a8.25 8.25 0 110-16.5 8.25 8.25 0 010 16.5z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showNewPass, setShowNewPass] = useState(false);
|
||||
const [showConfirmPass, setShowConfirmPass] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<'IDLE' | 'SUCCESS' | 'ERROR'>('IDLE');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
// Validaciones
|
||||
const validations = {
|
||||
length: newPassword.length >= 8,
|
||||
upper: /[A-Z]/.test(newPassword),
|
||||
number: /\d/.test(newPassword),
|
||||
special: /[\W_]/.test(newPassword),
|
||||
match: newPassword.length > 0 && newPassword === confirmPassword
|
||||
};
|
||||
|
||||
const handleReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
if (!validations.length || !validations.upper || !validations.number || !validations.special || !validations.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await AuthService.resetPassword(token, newPassword);
|
||||
setStatus('SUCCESS');
|
||||
setTimeout(() => navigate('/'), 3000);
|
||||
} catch (err: any) {
|
||||
setStatus('ERROR');
|
||||
setMessage(err.response?.data?.message || 'El enlace es inválido o ha expirado.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) return <div className="text-white text-center p-20">Token inválido.</div>;
|
||||
|
||||
const RequirementItem = ({ isValid, text }: { isValid: boolean, text: string }) => (
|
||||
<li className={`flex items-center gap-2 text-xs transition-colors ${isValid ? 'text-green-400' : 'text-gray-500'}`}>
|
||||
{isValid ? <CheckCircleIcon /> : <NeutralCircleIcon />}
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="glass p-10 rounded-[2.5rem] border border-white/10 w-full max-w-md shadow-2xl relative overflow-hidden">
|
||||
|
||||
{status === 'SUCCESS' ? (
|
||||
<div className="text-center animate-fade-in">
|
||||
<div className="text-5xl mb-6">🔒</div>
|
||||
<h2 className="text-2xl font-black uppercase text-white mb-2">¡Contraseña Restablecida!</h2>
|
||||
<p className="text-gray-400 text-sm mb-6">Tu clave se actualizó correctamente. Redirigiendo al inicio...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-3xl font-black uppercase tracking-tighter mb-2 text-center">Nueva Contraseña</h2>
|
||||
<p className="text-center text-xs text-gray-500 mb-8 font-bold tracking-widest uppercase">Recuperación de cuenta</p>
|
||||
|
||||
{status === 'ERROR' && (
|
||||
<div className="bg-red-500/20 text-red-300 p-3 rounded-xl mb-6 text-xs font-bold border border-red-500/20 text-center">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleReset} className="space-y-5">
|
||||
<div className="relative">
|
||||
<input required type={showNewPass ? "text" : "password"} value={newPassword} onChange={e => setNewPassword(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl pl-4 pr-12 py-3.5 text-white outline-none focus:border-blue-500 transition-all placeholder:text-gray-600"
|
||||
placeholder="Nueva contraseña" />
|
||||
<button type="button" onClick={() => setShowNewPass(!showNewPass)} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
|
||||
{showNewPass ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input required type={showConfirmPass ? "text" : "password"} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl pl-4 pr-12 py-3.5 text-white outline-none focus:border-blue-500 transition-all placeholder:text-gray-600"
|
||||
placeholder="Repetir contraseña" />
|
||||
<button type="button" onClick={() => setShowConfirmPass(!showConfirmPass)} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
|
||||
{showConfirmPass ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1 pl-1">
|
||||
<RequirementItem isValid={validations.length} text="Mínimo 8 caracteres" />
|
||||
<RequirementItem isValid={validations.upper} text="1 Mayúscula" />
|
||||
<RequirementItem isValid={validations.number} text="1 Número" />
|
||||
<RequirementItem isValid={validations.special} text="1 Símbolo (!@#)" />
|
||||
<RequirementItem isValid={validations.match} text="Coinciden" />
|
||||
</ul>
|
||||
|
||||
<button disabled={loading} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-bold uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 disabled:opacity-50 mt-4">
|
||||
{loading ? 'Guardando...' : 'Cambiar Contraseña'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
Frontend/src/pages/SeguridadPage.tsx
Normal file
29
Frontend/src/pages/SeguridadPage.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import ConfigPanel from '../components/ConfigPanel';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function SeguridadPage() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-24 text-center">
|
||||
<h2 className="text-3xl font-bold text-white">Acceso Denegado</h2>
|
||||
<Link to="/" className="text-blue-400 mt-4 block">Volver al inicio</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-12 max-w-4xl animate-fade-in-up">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-5xl font-black tracking-tighter uppercase mb-2">Seguridad de <span className="text-blue-500">Cuenta</span></h1>
|
||||
<p className="text-gray-500 font-bold tracking-widest uppercase text-xs">Gestiona tu contraseña y autenticación de dos factores</p>
|
||||
</div>
|
||||
|
||||
<ConfigPanel user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
Frontend/src/pages/SuccessPage.tsx
Normal file
113
Frontend/src/pages/SuccessPage.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { AdsV2Service } from '../services/ads.v2.service';
|
||||
|
||||
export default function SuccessPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStatus = searchParams.get('status') || 'approved';
|
||||
const adId = searchParams.get('adId');
|
||||
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCheckStatus = async () => {
|
||||
if (!adId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await AdsV2Service.checkPaymentStatus(Number(adId));
|
||||
setStatus(res.status);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Configuración visual según estado
|
||||
const configMap: any = {
|
||||
approved: {
|
||||
icon: '✅',
|
||||
color: 'green',
|
||||
title: '¡Pago Confirmado!',
|
||||
desc: 'Tu aviso ha sido procesado con éxito y pasado a revisión, será verificado por un moderador en breve.',
|
||||
bg: 'bg-green-500/20',
|
||||
border: 'border-green-500/20'
|
||||
},
|
||||
in_process: {
|
||||
icon: '⏳',
|
||||
color: 'amber',
|
||||
title: 'Pago Pendiente',
|
||||
desc: 'Tu pago se encuentra en proceso de revisión por la entidad financiera. Esto puede demorar unos minutos u horas.',
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500/20'
|
||||
},
|
||||
rejected: {
|
||||
icon: '❌',
|
||||
color: 'red',
|
||||
title: 'Pago Rechazado',
|
||||
desc: 'El pago no pudo completarse. Por favor intenta nuevamente con otro medio de pago.',
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500/20'
|
||||
},
|
||||
// ESTADO PARA ADMIN
|
||||
admin_created: {
|
||||
icon: '🛡️',
|
||||
color: 'blue',
|
||||
title: '¡Gestión Completada!',
|
||||
desc: 'El aviso ha sido creado y asignado al usuario correctamente. Ya se encuentra activo en la plataforma.',
|
||||
bg: 'bg-blue-500/20',
|
||||
border: 'border-blue-500/20'
|
||||
}
|
||||
};
|
||||
|
||||
const config = configMap[status] || configMap.approved;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 sm:px-6 py-12 md:py-24 text-center animate-fade-in-up">
|
||||
<div className={`glass p-8 md:p-16 rounded-[2rem] md:rounded-[4rem] max-w-3xl mx-auto border shadow-2xl relative overflow-hidden ${config.border}`}>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className={`w-16 h-16 md:w-24 md:h-24 rounded-2xl md:rounded-3xl flex items-center justify-center mx-auto mb-6 md:mb-10 border shadow-lg ${config.bg} ${config.border}`}>
|
||||
<span className="text-3xl md:text-5xl animate-bounce">{config.icon}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl md:text-6xl font-black mb-4 md:mb-6 uppercase tracking-tighter leading-tight md:leading-none text-white whitespace-normal px-2">
|
||||
{config.title}
|
||||
</h2>
|
||||
|
||||
<p className="text-base md:text-xl text-gray-400 mb-8 md:mb-12 max-w-xl mx-auto font-medium leading-relaxed italic px-4">
|
||||
{config.desc}
|
||||
</p>
|
||||
|
||||
{/* Botón de Re-Check para estados pendientes */}
|
||||
{status === 'in_process' && (
|
||||
<button
|
||||
onClick={handleCheckStatus}
|
||||
disabled={loading}
|
||||
className="mb-8 bg-amber-500 hover:bg-amber-600 text-black px-6 py-3 rounded-xl font-bold uppercase tracking-widest transition-all shadow-lg shadow-amber-500/20 disabled:opacity-50 text-xs md:text-sm"
|
||||
>
|
||||
{loading ? 'Verificando...' : '🔄 Actualizar Estado'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 max-w-lg mx-auto">
|
||||
<Link to="/mis-avisos" className="bg-white/5 hover:bg-white/10 text-white px-6 md:px-8 py-4 md:py-5 rounded-xl md:rounded-2xl font-bold uppercase tracking-widest transition-all border border-white/5 flex items-center justify-center gap-3 text-xs md:text-sm">
|
||||
Ir a Gestión
|
||||
</Link>
|
||||
|
||||
{status === 'rejected' ? (
|
||||
<Link to={`/publicar?edit=${adId}`} className="bg-blue-600 hover:bg-blue-500 text-white px-6 md:px-8 py-4 md:py-5 rounded-xl md:rounded-2xl font-bold uppercase tracking-widest transition-all shadow-lg flex items-center justify-center gap-3 text-xs md:text-sm">
|
||||
Reintentar Pago
|
||||
</Link>
|
||||
) : (
|
||||
// Si está aprobado y tenemos ID, vamos al detalle, sino a explorar
|
||||
<Link to={adId ? `/vehiculo/${adId}` : "/explorar"} className="bg-blue-600 hover:bg-blue-500 text-white px-6 md:px-8 py-4 md:py-5 rounded-xl md:rounded-2xl font-bold uppercase tracking-widest transition-all shadow-lg flex items-center justify-center gap-3 text-xs md:text-sm">
|
||||
Ver aviso
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
Frontend/src/pages/VehiculoDetailPage.tsx
Normal file
281
Frontend/src/pages/VehiculoDetailPage.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { AdsV2Service } from '../services/ads.v2.service';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import ChatModal from '../components/ChatModal';
|
||||
import { FaWhatsapp, FaMapMarkerAlt, FaInfoCircle, FaShareAlt } from 'react-icons/fa';
|
||||
import { AD_STATUSES } from '../constants/adStatuses';
|
||||
import AdStatusBadge from '../components/AdStatusBadge';
|
||||
|
||||
export default function VehiculoDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [vehicle, setVehicle] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activePhoto, setActivePhoto] = useState(0);
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const user = AuthService.getCurrentUser();
|
||||
|
||||
const viewRegistered = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetail = async () => {
|
||||
if (!id) return;
|
||||
if (viewRegistered.current) return;
|
||||
viewRegistered.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await AdsV2Service.getById(Number(id));
|
||||
setVehicle(data);
|
||||
if (user) {
|
||||
const favorites = await AdsV2Service.getFavorites(user.id);
|
||||
setIsFavorite(favorites.some((f: any) => f.id === Number(id)));
|
||||
}
|
||||
} catch (err) {
|
||||
setError("No se pudo cargar la información del vehículo.");
|
||||
viewRegistered.current = false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDetail();
|
||||
}, [id, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { viewRegistered.current = false; };
|
||||
}, [id]);
|
||||
|
||||
const handleFavoriteToggle = async () => {
|
||||
if (!user) return alert("Debes iniciar sesión para guardar favoritos");
|
||||
try {
|
||||
if (isFavorite) await AdsV2Service.removeFavorite(user.id, Number(id));
|
||||
else await AdsV2Service.addFavorite(user.id, Number(id)!);
|
||||
setIsFavorite(!isFavorite);
|
||||
} catch (err) { console.error(err); }
|
||||
};
|
||||
|
||||
const getWhatsAppLink = (phone: string, title: string) => {
|
||||
if (!phone) return '#';
|
||||
let number = phone.replace(/[^\d]/g, '');
|
||||
if (number.startsWith('0')) number = number.substring(1);
|
||||
if (!number.startsWith('54')) number = `549${number}`;
|
||||
const message = `Hola, vi tu aviso "${title}" en Motores Argentinos y me interesa.`;
|
||||
return `https://wa.me/${number}?text=${encodeURIComponent(message)}`;
|
||||
};
|
||||
|
||||
const handleShare = (platform: 'wa' | 'fb' | 'copy') => {
|
||||
const url = window.location.href;
|
||||
const vehicleTitle = `${vehicle.brand?.name || ''} ${vehicle.versionName}`.trim();
|
||||
const text = `Mira este ${vehicleTitle} en Motores Argentinos!`;
|
||||
switch (platform) {
|
||||
case 'wa': window.open(`https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`, '_blank'); break;
|
||||
case 'fb': window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, '_blank'); break;
|
||||
case 'copy': navigator.clipboard.writeText(url); alert('Enlace copiado al portapapeles'); break;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex flex-col items-center justify-center p-40 gap-6">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
|
||||
<span className="text-gray-500 font-black uppercase tracking-widest text-xs animate-pulse">Cargando...</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error || !vehicle) return <div className="text-white p-20 text-center">{error || "Vehículo no encontrado"}</div>;
|
||||
|
||||
const isOwnerAdmin = vehicle.ownerUserType === 3;
|
||||
const isAdActive = vehicle.statusID === AD_STATUSES.ACTIVE;
|
||||
const isContactable = isAdActive && !isOwnerAdmin;
|
||||
|
||||
const getImageUrl = (path: string) => {
|
||||
if (!path) return "/placeholder-car.png";
|
||||
return path.startsWith('http') ? path : `${import.meta.env.VITE_STATIC_BASE_URL}${path}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 md:px-6 py-6 md:py-12 animate-fade-in-up">
|
||||
<nav className="flex gap-2 text-xs font-bold uppercase tracking-widest text-gray-500 mb-6 md:mb-8 items-center overflow-x-auto whitespace-nowrap">
|
||||
<Link to="/" className="hover:text-white transition-colors">Inicio</Link> /
|
||||
<Link to="/explorar" className="hover:text-white transition-colors">Explorar</Link> /
|
||||
<span className="text-blue-400 truncate">{vehicle.brand?.name} {vehicle.versionName || 'Detalle'}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-12 relative items-start">
|
||||
|
||||
{/* COLUMNA IZQUIERDA: Galería + Descripción (Desktop) */}
|
||||
<div className="lg:col-span-2 space-y-8 md:space-y-12 order-1 lg:order-1">
|
||||
{/* BLOQUE 1: Galería y Fotos */}
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
<div className="glass rounded-2xl md:rounded-[3rem] overflow-hidden border border-white/5 relative h-[300px] md:h-[500px] shadow-2xl group bg-black/40 flex items-center justify-center">
|
||||
<img
|
||||
src={getImageUrl(vehicle.photos?.[activePhoto]?.filePath)}
|
||||
className={`max-h-full max-w-full object-contain transition-all duration-1000 group-hover:scale-105 ${!isAdActive ? 'grayscale' : ''}`}
|
||||
alt={vehicle.versionName}
|
||||
/>
|
||||
<div className="absolute top-3 md:top-6 left-3 md:left-6 flex flex-col items-start gap-2">
|
||||
<AdStatusBadge statusId={vehicle.statusID} />
|
||||
{vehicle.isFeatured && <span className="bg-gradient-to-r from-blue-600 to-cyan-500 text-white px-3 md:px-4 py-1 md:py-1.5 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-widest shadow-lg animate-glow flex items-center gap-1">⭐ DESTACADO</span>}
|
||||
{vehicle.location && <span className="bg-black/60 backdrop-blur-md text-white px-3 md:px-4 py-1 md:py-1.5 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-widest border border-white/10 flex items-center gap-1.5"><FaMapMarkerAlt className="text-blue-500" /> {vehicle.location}</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFavoriteToggle}
|
||||
className={`absolute top-3 md:top-6 right-3 md:right-6 w-12 h-12 md:w-14 md:h-14 rounded-xl md:rounded-2xl flex items-center justify-center text-xl md:text-2xl transition-all shadow-xl backdrop-blur-xl border ${isFavorite ? 'bg-red-600 border-red-500/50 text-white' : 'bg-black/40 border-white/10 text-white hover:bg-white hover:text-red-500'}`}
|
||||
>
|
||||
{isFavorite ? '❤️' : '🤍'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{vehicle.photos?.length > 1 && (
|
||||
<div className="flex gap-2 md:gap-4 overflow-x-auto pb-2 scrollbar-hide no-scrollbar">
|
||||
{vehicle.photos.map((p: any, idx: number) => (
|
||||
<button key={idx} onClick={() => setActivePhoto(idx)} className={`relative w-24 md:w-28 h-16 md:h-18 rounded-lg md:rounded-2xl overflow-hidden flex-shrink-0 border-2 transition-all ${activePhoto === idx ? 'border-blue-500 scale-105 shadow-lg' : 'border-white/5 opacity-50 hover:opacity-100'}`}>
|
||||
<img src={getImageUrl(p.filePath)} className="w-full h-full object-cover" alt="" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* BLOQUE 3: Información General y Técnica (Acomodado debajo de la galería) */}
|
||||
<div className="glass p-6 md:p-12 rounded-2xl md:rounded-[3rem] border border-white/5 relative overflow-hidden group shadow-2xl">
|
||||
{vehicle.description && (
|
||||
<div className="mb-12 pb-12 border-b border-white/5">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-14 h-14 bg-blue-600/10 rounded-2xl flex items-center justify-center text-blue-400 text-2xl border border-blue-500/20 shadow-inner">
|
||||
<span className="text-2xl">📝</span>
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-black uppercase tracking-tighter text-white">Descripción del <span className="text-blue-500">Vendedor</span></h3>
|
||||
</div>
|
||||
<p className="text-gray-300 leading-relaxed font-light whitespace-pre-wrap text-base md:text-lg">
|
||||
{vehicle.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<div className="w-14 h-14 bg-blue-600/10 rounded-2xl flex items-center justify-center text-blue-400 text-2xl border border-blue-500/20 shadow-inner">
|
||||
<FaInfoCircle />
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-black uppercase tracking-tighter text-white">Información <span className="text-blue-500">Técnica</span></h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 md:gap-8">
|
||||
<TechnicalItem label="Kilómetros" value={`${vehicle.km?.toLocaleString()} KM`} icon="🏎️" />
|
||||
<TechnicalItem label="Combustible" value={vehicle.fuelType} icon="⛽" />
|
||||
<TechnicalItem label="Transmisión" value={vehicle.transmission} icon="⚙️" />
|
||||
<TechnicalItem label="Color" value={vehicle.color} icon="🎨" />
|
||||
<TechnicalItem label="Segmento" value={vehicle.segment} icon="🚗" />
|
||||
{vehicle.condition && <TechnicalItem label="Estado" value={vehicle.condition} icon="✨" />}
|
||||
{vehicle.doorCount && <TechnicalItem label="Puertas" value={vehicle.doorCount} icon="🚪" />}
|
||||
{vehicle.engineSize && <TechnicalItem label="Motor" value={vehicle.engineSize} icon="⚡" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SIDEBAR: Precio y Contacto (Desktop: Columna 3) */}
|
||||
<div className="lg:col-span-1 order-2">
|
||||
<div className="glass p-6 md:p-10 rounded-2xl md:rounded-[3rem] border border-blue-500/20 lg:sticky lg:top-28 shadow-2xl relative overflow-hidden">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="bg-blue-600/20 text-blue-400 px-3 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest border border-blue-500/20">
|
||||
{vehicle.year}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-white/10"></div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-black tracking-tighter uppercase leading-tight mb-4 text-white">
|
||||
<span className="text-blue-500 mr-2">{vehicle.brand?.name}</span>
|
||||
{vehicle.versionName}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-400 text-[10px] font-black uppercase tracking-widest bg-white/5 px-4 py-2 rounded-xl border border-white/5 w-fit">
|
||||
<FaMapMarkerAlt className="text-blue-500" />
|
||||
{vehicle.location || "Ubicación Privada"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-white/5 to-transparent rounded-2xl md:rounded-3xl p-6 md:p-8 mb-8 border border-white/10 shadow-inner group">
|
||||
<span className="text-gray-500 text-[10px] font-black tracking-widest uppercase block mb-1 opacity-60">Precio</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-blue-400 text-3xl md:text-5xl font-black tracking-tighter">{vehicle.currency} {vehicle.price?.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isContactable ? (
|
||||
<div className="space-y-4">
|
||||
<a href={getWhatsAppLink(vehicle.contactPhone, `${vehicle.brand?.name} ${vehicle.versionName}`)} target="_blank" rel="noopener noreferrer"
|
||||
className="w-full glass border border-green-500/30 hover:bg-green-600 text-white py-5 rounded-2xl font-black uppercase tracking-widest transition-all shadow-lg shadow-green-600/20 flex items-center justify-center gap-3 group hover:border-green-500/50">
|
||||
<FaWhatsapp className="text-3xl group-hover:scale-110 transition-transform text-green-400 group-hover:text-white" />
|
||||
<span>Contactar</span>
|
||||
</a>
|
||||
|
||||
{vehicle.contactPhone && (
|
||||
<div className="w-full bg-white/5 py-4 rounded-xl border border-white/10 flex items-center justify-center gap-3 text-gray-300 font-black uppercase tracking-[0.2em] text-[10px] shadow-sm">
|
||||
<span className="text-blue-400">📞</span>
|
||||
<span className="opacity-60 font-bold">Llamar:</span>
|
||||
<span className="tracking-widest">{vehicle.contactPhone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 text-center">
|
||||
<div className="text-3xl mb-3">
|
||||
{isOwnerAdmin ? 'ℹ️' :
|
||||
vehicle.statusID === AD_STATUSES.MODERATION_PENDING ? '⏳' :
|
||||
vehicle.statusID === AD_STATUSES.PAYMENT_PENDING ? '💳' :
|
||||
vehicle.statusID === AD_STATUSES.SOLD ? '🤝' : '🔒'}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white uppercase tracking-tight mb-2">
|
||||
{isOwnerAdmin ? 'Contacto en descripción' :
|
||||
vehicle.statusID === AD_STATUSES.MODERATION_PENDING ? 'Aviso en Revisión' :
|
||||
vehicle.statusID === AD_STATUSES.PAYMENT_PENDING ? 'Pago Pendiente' :
|
||||
vehicle.statusID === AD_STATUSES.SOLD ? 'Vehículo Vendido' : 'No disponible'}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 leading-relaxed">
|
||||
{isOwnerAdmin
|
||||
? 'Revisa la descripción para contactar al vendedor.'
|
||||
: vehicle.statusID === AD_STATUSES.MODERATION_PENDING
|
||||
? 'Este aviso está siendo verificado por un moderador. Estará activo pronto.'
|
||||
: vehicle.statusID === AD_STATUSES.PAYMENT_PENDING
|
||||
? 'El pago de este aviso aún no ha sido procesado completamente.'
|
||||
: vehicle.statusID === AD_STATUSES.SOLD
|
||||
? 'Este vehículo ya ha sido vendido a otro usuario.'
|
||||
: 'Este vehículo ya no se encuentra disponible para la venta.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => handleShare('copy')} className="w-full mt-6 py-4 rounded-xl border border-white/5 text-[9px] font-black uppercase tracking-[0.2em] text-gray-500 hover:text-white hover:bg-white/5 transition-all flex items-center justify-center gap-2">
|
||||
<FaShareAlt /> Compartir Aviso
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vehicle && user && isContactable && (
|
||||
<ChatModal
|
||||
isOpen={isChatOpen}
|
||||
onClose={() => setIsChatOpen(false)}
|
||||
adId={vehicle.id}
|
||||
adTitle={vehicle.versionName}
|
||||
sellerId={vehicle.userID}
|
||||
currentUserId={user.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TechnicalItem({ label, value, icon }: { label: string, value: string, icon: string }) {
|
||||
if ((value === undefined || value === null || value === '') || value === 'N/A') return null;
|
||||
return (
|
||||
<div className="bg-white/5 p-4 rounded-xl md:rounded-2xl border border-white/5 hover:bg-white/10 transition-colors">
|
||||
<span className="text-gray-500 text-[9px] md:text-[10px] uppercase font-black tracking-widest block mb-1">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm md:text-base">{icon}</span>
|
||||
<span className="text-white font-bold text-xs md:text-sm">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
Frontend/src/pages/VerifyEmailPage.tsx
Normal file
57
Frontend/src/pages/VerifyEmailPage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<'LOADING' | 'SUCCESS' | 'ERROR'>('LOADING');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('ERROR');
|
||||
setMessage('Token no proporcionado');
|
||||
return;
|
||||
}
|
||||
|
||||
AuthService.verifyEmail(token)
|
||||
.then(() => {
|
||||
setStatus('SUCCESS');
|
||||
setTimeout(() => navigate('/'), 3000); // Redirigir al home
|
||||
})
|
||||
.catch((err) => {
|
||||
setStatus('ERROR');
|
||||
setMessage(err.response?.data?.message || 'Error al verificar email');
|
||||
});
|
||||
}, [token, navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="glass p-10 rounded-[2.5rem] border border-white/10 text-center max-w-md w-full shadow-2xl">
|
||||
{status === 'LOADING' && (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-6"></div>
|
||||
<h2 className="text-2xl font-black uppercase tracking-tighter">Verificando...</h2>
|
||||
</>
|
||||
)}
|
||||
{status === 'SUCCESS' && (
|
||||
<>
|
||||
<div className="text-5xl mb-6">✅</div>
|
||||
<h2 className="text-2xl font-black uppercase tracking-tighter text-green-400 mb-2">¡Cuenta Activada!</h2>
|
||||
<p className="text-gray-400 text-sm">Ya puedes iniciar sesión. Redirigiendo...</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'ERROR' && (
|
||||
<>
|
||||
<div className="text-5xl mb-6">❌</div>
|
||||
<h2 className="text-2xl font-black uppercase tracking-tighter text-red-400 mb-2">Error</h2>
|
||||
<p className="text-gray-400 text-sm">{message}</p>
|
||||
<button onClick={() => navigate('/')} className="mt-6 text-blue-400 font-bold uppercase text-xs tracking-widest">Ir al inicio</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user