Init Commit
This commit is contained in:
31
Frontend/.gitignore
vendored
Normal file
31
Frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
appsettings.Development.json
|
||||
#.env
|
||||
#.env.local
|
||||
#.env.development.local
|
||||
#.env.test.local
|
||||
#.env.production.local
|
||||
31
Frontend/Dockerfile
Normal file
31
Frontend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# Etapa de construcción
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar archivos de dependencia
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copiar código fuente
|
||||
COPY . .
|
||||
|
||||
# Argumentos de construcción para variables de entorno
|
||||
# Se pueden pasar vía docker-compose o comando build
|
||||
ARG VITE_API_BASE_URL
|
||||
ARG VITE_STATIC_BASE_URL
|
||||
ARG VITE_MP_PUBLIC_KEY
|
||||
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ENV VITE_STATIC_BASE_URL=$VITE_STATIC_BASE_URL
|
||||
ENV VITE_MP_PUBLIC_KEY=$VITE_MP_PUBLIC_KEY
|
||||
|
||||
# Construir la aplicación
|
||||
RUN npm run build
|
||||
|
||||
# Etapa de producción con Nginx
|
||||
FROM nginx:stable-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
73
Frontend/README.md
Normal file
73
Frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
Frontend/eslint.config.js
Normal file
23
Frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
14
Frontend/index.html
Normal file
14
Frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo-ma.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Motores Argentinos</title>
|
||||
<script src="https://sdk.mercadopago.com/js/v2"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
Frontend/nginx.conf
Normal file
34
Frontend/nginx.conf
Normal file
@@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy interno al Backend (Nombre del servicio en docker-compose)
|
||||
location /api/ {
|
||||
proxy_pass http://motores-backend:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy para las imágenes servidas por el backend (fotos de vehículos)
|
||||
location /uploads/ {
|
||||
proxy_pass http://motores-backend:8080/uploads/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
4200
Frontend/package-lock.json
generated
Normal file
4200
Frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
Frontend/package.json
Normal file
39
Frontend/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mercadopago/sdk-react": "^1.0.7",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"axios": "^1.13.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
BIN
Frontend/public/bg-car.jpg
Normal file
BIN
Frontend/public/bg-car.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
64
Frontend/public/logo-ma.svg
Normal file
64
Frontend/public/logo-ma.svg
Normal file
@@ -0,0 +1,64 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Sombra paralela para levantar el logo del fondo -->
|
||||
<filter id="dropShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="10"/>
|
||||
<feOffset dx="0" dy="12" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.8"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Degradado Cromo Principal (Cara Frontal) -->
|
||||
<linearGradient id="chromeFace" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f0f0f0;stop-opacity:1" />
|
||||
<stop offset="45%" style="stop-color:#8c8c8c;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#2b2b2b;stop-opacity:1" />
|
||||
<stop offset="52%" style="stop-color:#000000;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#595959;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Degradado Bisel (Bordes 3D iluminados) -->
|
||||
<linearGradient id="bevelLight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#bfbfbf;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#404040;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Degradado Lateral (Profundidad oscura) -->
|
||||
<linearGradient id="sideDark" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#1a1a1a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#000000;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Grupo Principal sin margen extra para aprovechar todo el espacio -->
|
||||
<g>
|
||||
|
||||
<!-- 1. CAPA DE PROFUNDIDAD (Extrusión) -->
|
||||
<!-- Ajustada para seguir la nueva forma centrada -->
|
||||
<path d="M20,480 L20,30 L135,30 L256,300 L377,30 L492,30 L492,480 L377,480 L377,250 L256,450 L135,250 L135,480 Z"
|
||||
fill="url(#sideDark)" transform="translate(10, 10)" opacity="0.8"/>
|
||||
|
||||
<!-- 2. CAPA BORDE / BISEL (Marco Brillante - Base) -->
|
||||
<!-- Coordenadas expandidas: X va de 20 a 492 (ancho total 472px) -->
|
||||
<g filter="url(#dropShadow)">
|
||||
<path d="M20,480 L20,30 L135,30 L256,300 L377,30 L492,30 L492,480 L377,480 L377,250 L256,450 L135,250 L135,480 Z"
|
||||
fill="url(#bevelLight)" stroke="#111" stroke-width="1"/>
|
||||
|
||||
<!-- 3. CAPA FRONTAL (Cara visible) -->
|
||||
<!-- Inset calculado proporcionalmente para mantener el bisel visible -->
|
||||
<path d="M40,465 L40,50 L120,50 L256,340 L392,50 L472,50 L472,465 L392,465 L392,270 L256,480 L120,270 L120,465 Z"
|
||||
fill="url(#chromeFace)" stroke="#333" stroke-width="1" opacity="0.95"/>
|
||||
|
||||
<!-- 4. DETALLE DE BRILLO EXTRA (Destello superior) -->
|
||||
<!-- Reposicionado a los nuevos hombros de la M -->
|
||||
<path d="M40,50 L120,50 L160,150 L80,150 Z" fill="white" opacity="0.2"/>
|
||||
<path d="M472,50 L392,50 L352,150 L432,150 Z" fill="white" opacity="0.2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
BIN
Frontend/public/placeholder-car.png
Normal file
BIN
Frontend/public/placeholder-car.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
336
Frontend/src/App.tsx
Normal file
336
Frontend/src/App.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import HomePage from './pages/HomePage';
|
||||
import ExplorarPage from './pages/ExplorarPage';
|
||||
import VehiculoDetailPage from './pages/VehiculoDetailPage';
|
||||
import PublicarAvisoPage from './pages/PublicarAvisoPage';
|
||||
import MisAvisosPage from './pages/MisAvisosPage';
|
||||
import SuccessPage from './pages/SuccessPage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
import LoginModal from './components/LoginModal';
|
||||
import { type UserSession } from './services/auth.service';
|
||||
import VerifyEmailPage from './pages/VerifyEmailPage';
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage';
|
||||
import PerfilPage from './pages/PerfilPage';
|
||||
import SeguridadPage from './pages/SeguridadPage';
|
||||
import { FaHome, FaSearch, FaCar, FaUser, FaShieldAlt } from 'react-icons/fa';
|
||||
import { initMercadoPago } from '@mercadopago/sdk-react';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
|
||||
function AdminGuard({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div className="min-h-screen flex items-center justify-center bg-[#0a0c10]"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div></div>;
|
||||
if (!user || user.userType !== 3) return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// COMPONENTE NAVBAR CON DROPDOWN
|
||||
function Navbar() {
|
||||
const { user, logout, login, unreadCount } = useAuth();
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
const isAdmin = user?.userType === 3;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
setShowUserMenu(false);
|
||||
setShowMobileMenu(false);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleMobileNavClick = (path: string) => {
|
||||
setShowMobileMenu(false);
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const handleLoginSuccess = (userSession: UserSession) => {
|
||||
login(userSession);
|
||||
setShowLoginModal(false);
|
||||
};
|
||||
|
||||
const getLinkClass = (path: string) => {
|
||||
const isActive = location.pathname === path;
|
||||
return `transition-all duration-300 font-bold tracking-widest text-s hover:text-white ${isActive ? 'text-blue-400 drop-shadow-[0_0_8px_rgba(59,130,246,0.5)]' : 'text-gray-300'}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="sticky top-0 z-[100] border-b border-white/10 bg-[#0a0c10]/80 backdrop-blur-xl shadow-2xl transition-all duration-300">
|
||||
<div className="container mx-auto px-6 h-20 flex justify-between items-center">
|
||||
<Link to="/" className="flex items-center gap-3 group">
|
||||
<div className="w-10 h-10 bg-gradient-to-tr from-blue-600 to-cyan-400 rounded-xl flex items-center justify-center shadow-lg shadow-blue-600/20 group-hover:shadow-blue-500/40 group-hover:scale-105 transition-all duration-300">
|
||||
<span className="text-white font-black text-xl">M</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-black tracking-tighter leading-none text-white group-hover:text-blue-100 transition-colors">MOTORES</span>
|
||||
<span className="text-[10px] tracking-[0.3em] font-bold text-blue-400 leading-none group-hover:text-blue-300 transition-colors">ARGENTINOS</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-10">
|
||||
<Link to="/" className={getLinkClass('/')}>HOME</Link>
|
||||
<Link to="/explorar" className={getLinkClass('/explorar')}>EXPLORAR</Link>
|
||||
<Link to="/vender" className={`transition-all duration-300 font-bold tracking-widest hover:text-white ${location.pathname === '/vender' || location.pathname === '/publicar' ? 'text-blue-400 drop-shadow-[0_0_8px_rgba(59,130,246,0.5)]' : 'text-gray-300'}`}>
|
||||
PUBLICAR
|
||||
</Link>
|
||||
|
||||
{/* --- 2. ENLACE DE GESTIÓN --- */}
|
||||
<Link to="/mis-avisos" className="relative">
|
||||
<span className={getLinkClass('/mis-avisos')}>MIS AVISOS</span>
|
||||
{user && unreadCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-3.5 w-4 h-4 bg-red-600 text-white text-[9px] font-bold rounded-full flex items-center justify-center border-2 border-[#0a0c10] animate-pulse">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{isAdmin && (
|
||||
<Link to="/admin" className={`transition-all duration-300 font-bold tracking-widest text-xs flex items-center gap-2 hover:text-white ${location.pathname === '/admin' ? 'text-blue-400' : 'text-gray-300'}`}>
|
||||
<span className="text-sm">🛡️</span> ADMIN
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Botón de menú hamburguesa para móvil - Mejorado */}
|
||||
<button
|
||||
onClick={() => setShowMobileMenu(!showMobileMenu)}
|
||||
className="md:hidden w-11 h-11 rounded-xl glass border border-white/10 flex flex-col items-center justify-center gap-1.5 group focus:outline-none hover:border-blue-500/50 transition-all shadow-lg"
|
||||
aria-label="Menú de navegación"
|
||||
>
|
||||
<span className={`w-5 h-0.5 bg-gradient-to-r from-blue-400 to-cyan-400 rounded-full transition-all duration-300 ${showMobileMenu ? 'rotate-45 translate-y-2' : 'group-hover:w-6'}`}></span>
|
||||
<span className={`w-5 h-0.5 bg-gradient-to-r from-blue-400 to-cyan-400 rounded-full transition-all duration-300 ${showMobileMenu ? 'opacity-0' : 'group-hover:w-6'}`}></span>
|
||||
<span className={`w-5 h-0.5 bg-gradient-to-r from-blue-400 to-cyan-400 rounded-full transition-all duration-300 ${showMobileMenu ? '-rotate-45 -translate-y-2' : 'group-hover:w-6'}`}></span>
|
||||
</button>
|
||||
|
||||
{user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
onBlur={() => setTimeout(() => setShowUserMenu(false), 200)}
|
||||
className="flex items-center gap-3 group focus:outline-none"
|
||||
>
|
||||
<div className="text-right hidden lg:block">
|
||||
<span className="text-[9px] font-bold text-gray-400 uppercase tracking-widest block group-hover:text-blue-400 transition-colors">Hola</span>
|
||||
<span className="text-xs font-black text-white uppercase tracking-wider group-hover:text-blue-200 transition-colors">
|
||||
{user.firstName || user.username}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl border border-white/10 flex items-center justify-center group-hover:border-blue-500/50 transition-all shadow-lg">
|
||||
<span className="text-sm">👤</span>
|
||||
</div>
|
||||
</button>
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 bg-[#161a22] border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-fade-in z-50">
|
||||
<Link to="/perfil" className="block px-5 py-3 text-sm text-gray-300 hover:bg-white/5 hover:text-white transition-colors border-b border-white/5">
|
||||
👤 Mi Perfil
|
||||
</Link>
|
||||
<Link to="/seguridad" className="block px-5 py-3 text-sm text-gray-300 hover:bg-white/5 hover:text-white transition-colors border-b border-white/5">
|
||||
🛡️ Seguridad
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full text-left px-5 py-3 text-xs font-black text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors uppercase tracking-widest"
|
||||
>
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowLoginModal(true)}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white px-4 md:px-7 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/30 hover:shadow-blue-500/50 hover:-translate-y-0.5 flex items-center gap-2"
|
||||
>
|
||||
Ingresar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Menú móvil overlay MODERNIZADO */}
|
||||
{showMobileMenu && (
|
||||
<div
|
||||
className="fixed inset-0 top-[70px] z-[90] bg-black/95 backdrop-blur-3xl md:hidden animate-fade-in-up flex flex-col p-5 overflow-y-auto border-t border-white/10"
|
||||
>
|
||||
<div className="flex flex-col gap-3 mt-2">
|
||||
<button
|
||||
onClick={() => handleMobileNavClick('/')}
|
||||
className={`group flex items-center gap-3.5 p-2.5 rounded-2xl transition-all ${location.pathname === '/' ? 'bg-white/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-lg transition-all ${location.pathname === '/' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white/5 text-gray-400 group-hover:bg-white/10 group-hover:text-white'}`}>
|
||||
<FaHome />
|
||||
</div>
|
||||
<span className={`text-lg font-black uppercase italic tracking-tighter ${location.pathname === '/' ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
|
||||
Home
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleMobileNavClick('/explorar')}
|
||||
className={`group flex items-center gap-3.5 p-2.5 rounded-2xl transition-all ${location.pathname === '/explorar' ? 'bg-white/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-lg transition-all ${location.pathname === '/explorar' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white/5 text-gray-400 group-hover:bg-white/10 group-hover:text-white'}`}>
|
||||
<FaSearch />
|
||||
</div>
|
||||
<span className={`text-lg font-black uppercase italic tracking-tighter ${location.pathname === '/explorar' ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
|
||||
Explorar
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleMobileNavClick('/vender')}
|
||||
className={`group flex items-center gap-3.5 p-2.5 rounded-2xl transition-all ${(location.pathname === '/vender' || location.pathname === '/publicar') ? 'bg-white/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-lg transition-all ${(location.pathname === '/vender' || location.pathname === '/publicar') ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white/5 text-gray-400 group-hover:bg-white/10 group-hover:text-white'}`}>
|
||||
<FaCar />
|
||||
</div>
|
||||
<span className={`text-lg font-black uppercase italic tracking-tighter ${(location.pathname === '/vender' || location.pathname === '/publicar') ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
|
||||
Publicar
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleMobileNavClick('/mis-avisos')}
|
||||
className={`group flex items-center gap-3.5 p-2.5 rounded-2xl transition-all relative ${location.pathname === '/mis-avisos' ? 'bg-white/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-lg transition-all ${location.pathname === '/mis-avisos' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white/5 text-gray-400 group-hover:bg-white/10 group-hover:text-white'}`}>
|
||||
<FaUser />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-lg font-black uppercase italic tracking-tighter ${location.pathname === '/mis-avisos' ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
|
||||
Mis Avisos
|
||||
</span>
|
||||
{user && unreadCount > 0 && (
|
||||
<span className="text-xs font-bold text-red-400 uppercase tracking-widest mt-1">
|
||||
{unreadCount} mensaje(s) nuevo(s)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{user && unreadCount > 0 && (
|
||||
<span className="absolute top-6 right-6 w-3 h-3 bg-red-500 rounded-full animate-pulse shadow-lg shadow-red-500/50"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => handleMobileNavClick('/admin')}
|
||||
className={`group flex items-center gap-3.5 p-2.5 rounded-2xl transition-all ${location.pathname === '/admin' ? 'bg-white/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-lg transition-all ${location.pathname === '/admin' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white/5 text-gray-400 group-hover:bg-white/10 group-hover:text-white'}`}>
|
||||
<FaShieldAlt />
|
||||
</div>
|
||||
<span className={`text-lg font-black uppercase italic tracking-tighter ${location.pathname === '/admin' ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
|
||||
Admin
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-10 pb-6 text-center">
|
||||
<p className="text-xs text-gray-600 font-bold uppercase tracking-[0.2em]">Motores Argentinos v2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLoginModal && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-md animate-fade-in p-4">
|
||||
<div className="relative w-full max-w-md">
|
||||
<LoginModal
|
||||
onSuccess={handleLoginSuccess}
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLegal() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const baseEdition = 5858;
|
||||
const baseDate = new Date('2026-01-21T00:00:00');
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
baseDate.setHours(0, 0, 0, 0);
|
||||
const diffTime = today.getTime() - baseDate.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
const currentEdition = baseEdition + (diffDays > 0 ? diffDays : 0);
|
||||
|
||||
return (
|
||||
<footer className="border-t border-white/5 py-8 md:py-12 bg-black/40 mt-auto backdrop-blur-lg">
|
||||
<div className="container mx-auto px-4 md:px-6 text-center">
|
||||
<div className="flex flex-col gap-2 md:gap-2 text-[11px] md:text-[10px] text-gray-500 uppercase tracking-wider font-medium leading-relaxed max-w-4xl mx-auto">
|
||||
<p>© {currentYear} MotoresArgentinos. Todos los derechos reservados. <span className="text-gray-400 font-bold ml-1">Edición número: {currentEdition}.</span></p>
|
||||
<p>Registro DNDA Nº: RL-2024-70042723-APN-DNDA#MJ - Propietario: Publiéxito S.A.</p>
|
||||
<p>Director: Leonardo Mario Forclaz - 46 N 423 - La Plata - Pcia. de Bs. As.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function MainLayout() {
|
||||
const { loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0c10] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0c10] text-gray-100 font-sans selection:bg-blue-500/30 flex flex-col">
|
||||
<Navbar />
|
||||
<main className="relative flex-grow">
|
||||
<div className="fixed top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-600/5 blur-[120px] rounded-full z-0 pointer-events-none"></div>
|
||||
<div className="fixed bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-cyan-400/5 blur-[120px] rounded-full z-0 pointer-events-none"></div>
|
||||
<div className="relative z-10">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/explorar" element={<ExplorarPage />} />
|
||||
<Route path="/vehiculo/:id" element={<VehiculoDetailPage />} />
|
||||
<Route path="/publicar" element={<PublicarAvisoPage />} />
|
||||
<Route path="/vender" element={<PublicarAvisoPage />} />
|
||||
<Route path="/mis-avisos" element={<MisAvisosPage />} />
|
||||
<Route path="/pago-confirmado" element={<SuccessPage />} />
|
||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||
<Route path="/verificar-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/perfil" element={<PerfilPage />} />
|
||||
<Route path="/seguridad" element={<SeguridadPage />} />
|
||||
<Route path="/admin" element={
|
||||
<AdminGuard>
|
||||
<AdminPage />
|
||||
</AdminGuard>
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
<FooterLegal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
const mpPublicKey = import.meta.env.VITE_MP_PUBLIC_KEY;
|
||||
if (mpPublicKey) initMercadoPago(mpPublicKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<MainLayout />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
124
Frontend/src/components/AdDetailsModal.tsx
Normal file
124
Frontend/src/components/AdDetailsModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { parseUTCDate, formatCurrency } from '../utils/app.utils';
|
||||
import { STATUS_CONFIG } from '../constants/adStatuses';
|
||||
|
||||
interface Props {
|
||||
ad: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AdDetailsModal({ ad, onClose }: Props) {
|
||||
if (!ad) return null;
|
||||
|
||||
const status = STATUS_CONFIG[ad.statusID] || { label: 'Desconocido', color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-white/10' };
|
||||
|
||||
// Helper para fecha de pago
|
||||
const paymentDate = ad.paidDate
|
||||
? parseUTCDate(ad.paidDate).toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '-';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2000] flex items-start md:items-center justify-center p-4 md:p-8 pt-24 md:pt-8 animate-fade-in overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={onClose}></div>
|
||||
|
||||
<div className="relative bg-[#12141a] w-full max-w-2xl max-h-[85vh] md:max-h-[90vh] flex flex-col rounded-[2rem] border border-white/10 shadow-2xl overflow-hidden animate-scale-up">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 md:px-8 py-5 md:py-6 border-b border-white/5 bg-[#161a22] flex justify-between items-center shrink-0">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1 md:mb-2">
|
||||
<span className={`px-2 md:px-3 py-0.5 md:py-1 rounded-lg text-[8px] md:text-[10px] font-black uppercase tracking-widest border ${status.bg} ${status.color} ${status.border}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="text-gray-500 text-[10px] md:text-xs font-bold">ID #{ad.adID}</span>
|
||||
</div>
|
||||
<h2 className="text-xl md:text-2xl font-black uppercase tracking-tight text-white line-clamp-1">{ad.title}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="w-9 h-9 md:w-10 md:h-10 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-all shrink-0">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Contenido con Scroll Interno */}
|
||||
<div className="flex-1 overflow-y-auto p-5 md:p-8 custom-scrollbar">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
|
||||
{/* Columna 1: Datos Financieros */}
|
||||
<div className="space-y-6">
|
||||
<div className="glass p-3 rounded-2xl border border-white/5 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-green-500/5 rounded-full blur-xl -mr-5 -mt-5"></div>
|
||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-green-400 mb-4 flex items-center gap-2">💰 Transacción</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-end border-b border-white/5 pb-3">
|
||||
<span className="text-xs text-gray-500 font-medium">Monto Abonado</span>
|
||||
<span className="text-xl text-white font-black tracking-tight">
|
||||
{ad.paidAmount ? formatCurrency(ad.paidAmount, 'ARS') : <span className="text-gray-600 text-xs text-right">Sin pago registrado</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-gray-500 font-medium">Fecha de Pago</span>
|
||||
<span className="text-xs text-gray-300 font-bold">{paymentDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métricas */}
|
||||
<div className="glass p-3 rounded-2xl border border-white/5">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-purple-400 mb-4">Métricas</h4>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-center">
|
||||
<span className="block text-2xl font-black text-white">{ad.views}</span>
|
||||
<span className="text-[9px] text-gray-500 uppercase tracking-widest">Visitas</span>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-white/10"></div>
|
||||
<div className="text-center">
|
||||
<span className="block text-lg font-bold text-gray-300">{ad.legacyID || '-'}</span>
|
||||
<span className="text-[9px] text-gray-500 uppercase tracking-widest">ID Operación</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columna 2: Línea de Tiempo */}
|
||||
<div className="glass p-3 rounded-2xl border border-white/5">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-blue-400 mb-6">Ciclo de Vida</h4>
|
||||
<div className="space-y-6 relative before:absolute before:left-2 before:top-2 before:bottom-2 before:w-px before:bg-white/10">
|
||||
<TimelineItem date={ad.createdAt} label="Creado / Borrador" active={!!ad.createdAt} />
|
||||
<TimelineItem date={ad.publishedAt} label="Aprobado / Publicado" active={!!ad.publishedAt} highlight />
|
||||
<TimelineItem date={ad.expiresAt} label="Vencimiento Programado" active={!!ad.expiresAt} future={new Date(ad.expiresAt) > new Date()} />
|
||||
{ad.deletedAt && <TimelineItem date={ad.deletedAt} label="Eliminado (Soft Delete)" active={true} color="text-red-400" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 bg-[#161a22] border-t border-white/5 flex justify-between items-center">
|
||||
<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-blue-400 font-bold text-xs">
|
||||
👤
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-white">{ad.userName}</span>
|
||||
<span className="text-[10px] text-gray-500">{ad.userEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href={`/vehiculo/${ad.adID}`} target="_blank" className="bg-white/5 hover:bg-white/10 border border-white/10 text-white px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all">
|
||||
Ver en Web ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineItem({ date, label, active, highlight, future, color = 'text-gray-400' }: any) {
|
||||
if (!date && !future) return null;
|
||||
|
||||
return (
|
||||
<div className="relative pl-8">
|
||||
<div className={`absolute left-0 top-1.5 w-4 h-4 rounded-full border-2 ${active ? (highlight ? 'border-green-500 bg-green-500/20' : 'border-blue-500 bg-[#12141a]') : 'border-gray-700 bg-[#12141a]'}`}></div>
|
||||
<p className={`text-xs font-bold ${active ? (highlight ? 'text-green-400' : 'text-white') : 'text-gray-600'}`}>
|
||||
{date ? parseUTCDate(date).toLocaleDateString('es-AR', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'}
|
||||
</p>
|
||||
<p className={`text-[10px] uppercase tracking-widest font-bold ${color}`}>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
Frontend/src/components/AdStatusBadge.tsx
Normal file
26
Frontend/src/components/AdStatusBadge.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { AD_STATUSES, STATUS_CONFIG } from '../constants/adStatuses';
|
||||
|
||||
interface Props {
|
||||
statusId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AdStatusBadge({ statusId, className = '' }: Props) {
|
||||
// Si está activo, no mostramos badge en las tarjetas públicas (Home/Explorar) para mantener limpieza.
|
||||
if (statusId === AD_STATUSES.ACTIVE) return null;
|
||||
|
||||
const config = STATUS_CONFIG[statusId];
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
flex items-center gap-1.5 px-3 py-1.5 rounded-lg border backdrop-blur-md shadow-lg shadow-black/40
|
||||
${config.bg} ${config.color} ${config.border}
|
||||
font-black uppercase tracking-widest text-[10px] select-none
|
||||
${className}
|
||||
`}>
|
||||
<span className="text-xs drop-shadow-md">{config.icon}</span>
|
||||
<span className="drop-shadow-md">{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
Frontend/src/components/ChangePasswordModal.tsx
Normal file
141
Frontend/src/components/ChangePasswordModal.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// --- ICONOS SVG (Reutilizados) ---
|
||||
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 XCircleIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-red-500"><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.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z" 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 ChangePasswordModal({ onClose }: Props) {
|
||||
const [currentPass, setCurrentPass] = useState('');
|
||||
const [newPass, setNewPass] = useState('');
|
||||
const [confirmPass, setConfirmPass] = useState('');
|
||||
|
||||
// Visibilidad
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Validaciones
|
||||
const validations = {
|
||||
length: newPass.length >= 8,
|
||||
upper: /[A-Z]/.test(newPass),
|
||||
number: /\d/.test(newPass),
|
||||
special: /[\W_]/.test(newPass),
|
||||
match: newPass.length > 0 && newPass === confirmPass
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!validations.length || !validations.upper || !validations.number || !validations.special || !validations.match) {
|
||||
setError('La nueva contraseña no cumple con los requisitos.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await AuthService.changePassword(currentPass, newPass);
|
||||
setSuccess(true);
|
||||
setTimeout(() => onClose(), 2000); // Cerrar automáticamente tras éxito
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al cambiar contraseña');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const RequirementItem = ({ isValid, text }: { isValid: boolean, text: string }) => {
|
||||
const isNeutral = newPass.length === 0;
|
||||
return (
|
||||
<li className={`flex items-center gap-2 text-xs transition-colors duration-300 ${isValid ? 'text-green-400' : isNeutral ? 'text-gray-500' : 'text-red-400'}`}>
|
||||
{isNeutral ? <NeutralCircleIcon /> : isValid ? <CheckCircleIcon /> : <XCircleIcon />}
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in p-4">
|
||||
<div className="glass px-8 pt-5 pb-8 rounded-3xl border border-white/10 shadow-2xl max-w-md w-full relative">
|
||||
<button onClick={onClose} className="absolute top-4 right-4 text-gray-500 hover:text-white">✕</button>
|
||||
|
||||
<h2 className="text-2xl font-black uppercase tracking-tighter mb-1 text-center">Cambiar Contraseña</h2>
|
||||
<p className="text-[10px] text-gray-500 text-center mb-6 uppercase tracking-widest">Seguridad de la cuenta</p>
|
||||
|
||||
{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 flex items-center gap-2">
|
||||
<XCircleIcon /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success ? (
|
||||
<div className="bg-green-500/20 text-green-300 p-6 rounded-xl text-center border border-green-500/20 animate-fade-in-up">
|
||||
<div className="flex justify-center mb-2"><CheckCircleIcon /></div>
|
||||
<p className="font-bold text-lg">¡Contraseña Actualizada!</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Contraseña Actual */}
|
||||
<div className="relative">
|
||||
<input required type={showCurrent ? "text" : "password"} value={currentPass} onChange={e => setCurrentPass(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl pl-4 pr-10 py-3 text-sm text-white outline-none focus:border-blue-500 placeholder:text-gray-600"
|
||||
placeholder="Contraseña Actual" />
|
||||
<button type="button" onClick={() => setShowCurrent(!showCurrent)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
|
||||
{showCurrent ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr className="border-white/5 my-2" />
|
||||
|
||||
{/* Nueva Contraseña */}
|
||||
<div className="relative">
|
||||
<input required type={showNew ? "text" : "password"} value={newPass} onChange={e => setNewPass(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl pl-4 pr-10 py-3 text-sm text-white outline-none focus:border-green-500 placeholder:text-gray-600"
|
||||
placeholder="Nueva Contraseña" />
|
||||
<button type="button" onClick={() => setShowNew(!showNew)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
|
||||
{showNew ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Confirmar Nueva */}
|
||||
<div className="relative">
|
||||
<input required type={showConfirm ? "text" : "password"} value={confirmPass} onChange={e => setConfirmPass(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl pl-4 pr-10 py-3 text-sm text-white outline-none focus:border-green-500 placeholder:text-gray-600"
|
||||
placeholder="Repetir Nueva Contraseña" />
|
||||
<button type="button" onClick={() => setShowConfirm(!showConfirm)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
|
||||
{showConfirm ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Validaciones Visuales */}
|
||||
<div className="px-1 py-2">
|
||||
<ul className="space-y-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="Las contraseñas coinciden" />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button disabled={loading} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-xl font-bold uppercase tracking-widest text-xs transition-all shadow-lg shadow-blue-600/20 disabled:opacity-50">
|
||||
{loading ? 'Actualizando...' : 'Confirmar Cambio'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
Frontend/src/components/ChatModal.tsx
Normal file
132
Frontend/src/components/ChatModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { ChatService, type ChatMessage } from '../services/chat.service';
|
||||
import { parseUTCDate } from '../utils/app.utils';
|
||||
|
||||
interface ChatModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
adId: number;
|
||||
adTitle: string;
|
||||
sellerId: number;
|
||||
currentUserId: number;
|
||||
}
|
||||
|
||||
export default function ChatModal({ isOpen, onClose, adId, adTitle, sellerId, currentUserId }: ChatModalProps) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadMessages();
|
||||
const interval = setInterval(loadMessages, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
const data = await ChatService.getConversation(adId, currentUserId, sellerId);
|
||||
setMessages(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await ChatService.sendMessage({
|
||||
adID: adId,
|
||||
senderID: currentUserId,
|
||||
receiverID: sellerId,
|
||||
messageText: newMessage
|
||||
});
|
||||
setNewMessage('');
|
||||
loadMessages();
|
||||
} catch (err) {
|
||||
alert('Error al enviar mensaje');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center p-4 md:p-6 bg-black/80 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-[#12141a] w-full max-w-lg rounded-[2rem] border border-white/10 shadow-2xl flex flex-col max-h-[85vh] overflow-hidden animate-scale-up">
|
||||
|
||||
{/* Header Neutro */}
|
||||
<div className="p-5 border-b border-white/5 flex justify-between items-center bg-[#1a1d24]">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Ícono Genérico de Mensaje */}
|
||||
<div className="w-10 h-10 bg-white/5 rounded-xl flex items-center justify-center text-lg border border-white/5">
|
||||
💬
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-white mb-0.5">Mensajes del Aviso</h3>
|
||||
<p className="text-[10px] text-gray-400 truncate max-w-[200px]">{adTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 hover:text-white hover:bg-white/10 transition-all font-bold">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Cuerpo de Mensajes */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-5 space-y-3 bg-[#0a0c10]"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="h-full flex flex-col justify-center items-center text-center opacity-40 space-y-3">
|
||||
<span className="text-3xl grayscale">📝</span>
|
||||
<p className="text-[10px] uppercase font-black tracking-widest text-gray-500">No hay mensajes previos</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((m, idx) => {
|
||||
const isMine = m.senderID === currentUserId;
|
||||
return (
|
||||
<div key={idx} className={`flex ${isMine ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] p-3 rounded-xl text-sm shadow-sm ${isMine ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-[#1f222b] text-gray-200 border border-white/5 rounded-tl-none'}`}>
|
||||
<p className="leading-snug">{m.messageText}</p>
|
||||
<span className="text-[9px] font-medium uppercase mt-1 block opacity-50 text-right">
|
||||
{parseUTCDate(m.sentAt!).toLocaleTimeString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour: '2-digit', minute: '2-digit', hour12: false })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<form onSubmit={handleSend} className="p-4 bg-[#1a1d24] border-t border-white/5 gap-2 flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Escribe aquí..."
|
||||
className="flex-1 bg-black/30 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-blue-500 transition-all placeholder:text-gray-600"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !newMessage.trim()}
|
||||
className="w-12 h-12 bg-blue-600 hover:bg-blue-500 rounded-xl flex items-center justify-center text-white shadow-lg transition-all disabled:opacity-50 disabled:grayscale"
|
||||
>
|
||||
{loading ? <div className="animate-spin h-4 w-4 border-2 border-white/30 border-t-white rounded-full"></div> : '➤'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
Frontend/src/components/ConfigPanel.tsx
Normal file
196
Frontend/src/components/ConfigPanel.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { AuthService, type UserSession } from '../services/auth.service';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import ChangePasswordModal from './ChangePasswordModal';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
// Iconos
|
||||
const CopyIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4"><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.381a9.06 9.06 0 001.5-.124A9.06 9.06 0 0021 15m-7.5-10.381V7.5a1.125 1.125 0 001.125 1.125h3.375" /></svg>);
|
||||
const CheckIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4"><path fillRule="evenodd" d="M19.916 4.626a.75.75 0 01.208 1.04l-9 13.5a.75.75 0 01-1.154.114l-6-6a.75.75 0 011.06-1.06l5.353 5.353 8.493-12.739a.75.75 0 011.04-.208z" clipRule="evenodd" /></svg>);
|
||||
|
||||
export default function ConfigPanel({ user }: { user: UserSession }) {
|
||||
const { refreshSession } = useAuth(); // Para actualizar si isMFAEnabled cambia en el contexto
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
|
||||
// Estados MFA
|
||||
const [mfaStep, setMfaStep] = useState<'IDLE' | 'QR'>('IDLE');
|
||||
const [qrUri, setQrUri] = useState('');
|
||||
const [secretKey, setSecretKey] = useState(''); // El código manual
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [msgMfa, setMsgMfa] = useState({ text: '', type: '' }); // type: 'success' | 'error'
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMfaActive = (user as any).isMFAEnabled;
|
||||
|
||||
const handleInitMfa = async () => {
|
||||
if (isMfaActive) {
|
||||
if (!window.confirm("Al reconfigurar, el código anterior dejará de funcionar en tu otro dispositivo. ¿Continuar?")) return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMsgMfa({ text: '', type: '' });
|
||||
try {
|
||||
const data = await AuthService.initMFA();
|
||||
setQrUri(data.qrUri);
|
||||
setSecretKey(data.secret);
|
||||
setMfaStep('QR');
|
||||
} catch {
|
||||
setMsgMfa({ text: "Error iniciando configuración.", type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyMfa = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await AuthService.verifyMFA(user.username, mfaCode);
|
||||
setMsgMfa({ text: "¡MFA Activado correctamente!", type: 'success' });
|
||||
setMfaStep('IDLE');
|
||||
setMfaCode('');
|
||||
await refreshSession(); // Actualizar estado global
|
||||
} catch {
|
||||
setMsgMfa({ text: "Código incorrecto. Intenta nuevamente.", type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableMfa = async () => {
|
||||
if (!window.confirm("¿Seguro que deseas desactivar la protección de dos factores? Tu cuenta será menos segura.")) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await AuthService.disableMFA();
|
||||
setMsgMfa({ text: "MFA Desactivado.", type: 'success' });
|
||||
await refreshSession();
|
||||
} catch {
|
||||
setMsgMfa({ text: "Error al desactivar MFA.", type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(secretKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
|
||||
{/* SECCIÓN CONTRASEÑA */}
|
||||
<section className="glass p-8 rounded-[2rem] border border-white/5 flex flex-col items-center justify-center text-center relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-600 to-transparent opacity-50"></div>
|
||||
<div className="w-16 h-16 bg-white/5 rounded-2xl flex items-center justify-center text-3xl mb-4 shadow-inner border border-white/5">
|
||||
🔑
|
||||
</div>
|
||||
<h3 className="text-xl font-bold uppercase mb-2 text-white">Contraseña</h3>
|
||||
<p className="text-sm text-gray-400 mb-6 max-w-xs leading-relaxed">
|
||||
Mantén tu cuenta segura actualizando tu contraseña periódicamente.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowPasswordModal(true)}
|
||||
className="bg-white/5 hover:bg-white/10 border border-white/10 text-white px-8 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest w-full transition-all hover:border-white/20 active:scale-95"
|
||||
>
|
||||
Cambiar Contraseña
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* SECCIÓN MFA */}
|
||||
<section className={`glass p-8 rounded-[2rem] border relative overflow-hidden flex flex-col items-center transition-all ${isMfaActive ? 'border-green-500/20 bg-green-900/5' : 'border-white/5'}`}>
|
||||
|
||||
{/* Indicador de Estado */}
|
||||
<div className={`absolute top-4 right-4 px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border ${isMfaActive ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-gray-500/10 text-gray-500 border-white/10'}`}>
|
||||
{isMfaActive ? 'Protegido' : 'No Activo'}
|
||||
</div>
|
||||
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl mb-4 transition-colors ${isMfaActive ? 'bg-green-500/20 text-green-400 shadow-[0_0_20px_rgba(34,197,94,0.2)]' : 'bg-blue-600/10 text-blue-500'}`}>
|
||||
🛡️
|
||||
</div>
|
||||
<h3 className="text-xl font-bold uppercase mb-2 text-white">Doble Factor (2FA)</h3>
|
||||
|
||||
{mfaStep === 'IDLE' ? (
|
||||
<div className="text-center w-full flex-1 flex flex-col">
|
||||
<p className="text-sm text-gray-400 mb-6 max-w-xs mx-auto leading-relaxed">
|
||||
{isMfaActive
|
||||
? "Tu cuenta está protegida. Se solicita un código cada vez que inicias sesión en un dispositivo nuevo."
|
||||
: "Añade una capa extra de seguridad. Requerirá un código de tu celular al iniciar sesión."}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto space-y-3">
|
||||
{isMfaActive ? (
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleDisableMfa} disabled={loading} className="flex-1 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-4 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all">
|
||||
{loading ? '...' : 'Desactivar'}
|
||||
</button>
|
||||
<button onClick={handleInitMfa} disabled={loading} className="flex-1 bg-white/5 hover:bg-white/10 text-white border border-white/10 px-4 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all">
|
||||
Reconfigurar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleInitMfa} disabled={loading} className="bg-blue-600 hover:bg-blue-500 text-white px-8 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest w-full shadow-lg shadow-blue-600/20 transition-all hover:scale-[1.02]">
|
||||
{loading ? 'Cargando...' : 'Activar MFA'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{msgMfa.text && (
|
||||
<p className={`text-[10px] font-bold uppercase tracking-wide mt-4 animate-fade-in ${msgMfa.type === 'error' ? 'text-red-400' : 'text-green-400'}`}>
|
||||
{msgMfa.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center w-full animate-fade-in">
|
||||
<div className="bg-white p-3 rounded-2xl inline-block mb-4 shadow-xl border-4 border-white">
|
||||
<QRCodeSVG value={qrUri} size={140} />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-300 font-bold mb-2">1. Escanea el código</p>
|
||||
<p className="text-[10px] text-gray-500 mb-4 max-w-[200px] mx-auto">Usa Google Authenticator o Authy en tu celular.</p>
|
||||
|
||||
{/* CÓDIGO MANUAL */}
|
||||
<div className="bg-black/40 border border-white/10 rounded-xl p-3 mb-6 relative group w-full overflow-hidden">
|
||||
<p className="text-[8px] text-gray-500 uppercase font-bold tracking-widest mb-1">O ingresa el código manual</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<code className="text-blue-400 font-mono text-sm tracking-wider select-all break-all text-left flex-1">{secretKey}</code>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-all shrink-0"
|
||||
title="Copiar"
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-300 font-bold mb-2">2. Ingresa el token</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="000 000"
|
||||
maxLength={6}
|
||||
value={mfaCode}
|
||||
onChange={e => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-center text-2xl font-black text-white mb-4 tracking-[0.4em] outline-none focus:border-blue-500 transition-colors placeholder:opacity-20"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setMfaStep('IDLE')} className="flex-1 bg-white/5 hover:text-white text-gray-500 py-3 rounded-xl text-[10px] font-bold uppercase transition-colors">Cancelar</button>
|
||||
<button onClick={handleVerifyMfa} disabled={loading || mfaCode.length < 6} className="flex-1 bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-xl text-[10px] font-bold uppercase transition-colors disabled:opacity-50 shadow-lg shadow-blue-600/20">
|
||||
{loading ? '...' : 'Activar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{showPasswordModal && (
|
||||
<ChangePasswordModal onClose={() => setShowPasswordModal(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
Frontend/src/components/ConfirmationModal.tsx
Normal file
85
Frontend/src/components/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: React.ReactNode;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDanger?: boolean; // Para pintar el botón de rojo si es eliminar
|
||||
}
|
||||
|
||||
export default function ConfirmationModal({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = "Confirmar",
|
||||
cancelText = "Cancelar",
|
||||
isDanger = false
|
||||
}: Props) {
|
||||
|
||||
// Cerrar con tecla ESC
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
if (isOpen) window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onCancel]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 animate-fade-in">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
></div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative bg-[#12141a] w-full max-w-md p-8 rounded-[2rem] border border-white/10 shadow-2xl animate-scale-up text-center">
|
||||
|
||||
{/* Icono decorativo según tipo */}
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6 text-3xl shadow-lg border border-white/5
|
||||
${isDanger ? 'bg-red-500/10 text-red-500' : 'bg-blue-600/10 text-blue-400'}
|
||||
`}>
|
||||
{isDanger ? '⚠️' : 'ℹ️'}
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-white mb-4">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<div className="text-sm text-gray-400 leading-relaxed mb-8 whitespace-pre-line">
|
||||
{message}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white py-3.5 rounded-xl font-bold uppercase text-[10px] tracking-widest transition-all"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 text-white py-3.5 rounded-xl font-bold uppercase text-[10px] tracking-widest transition-all shadow-lg
|
||||
${isDanger
|
||||
? 'bg-red-600 hover:bg-red-500 shadow-red-600/20'
|
||||
: 'bg-blue-600 hover:bg-blue-500 shadow-blue-600/20'}
|
||||
`}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
378
Frontend/src/components/CreditCardForm.tsx
Normal file
378
Frontend/src/components/CreditCardForm.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import VisualCreditCard from './VisualCreditCard';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MercadoPago: any;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
amount: number;
|
||||
onPaymentSuccess: (details: any) => Promise<void>;
|
||||
onPaymentError: (error: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface IdentificationType {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const FALLBACK_DOC_TYPES = [
|
||||
{ id: 'DNI', name: 'DNI' }, { id: 'CUIT', name: 'CUIT' }, { id: 'CUIL', name: 'CUIL' }
|
||||
];
|
||||
|
||||
export default function CreditCardForm({ amount, onPaymentSuccess, onPaymentError, onCancel }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [docTypes, setDocTypes] = useState<IdentificationType[]>(FALLBACK_DOC_TYPES);
|
||||
const [mpInstance, setMpInstance] = useState<any>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<{ id: string, name: string, thumbnail: string } | null>(null);
|
||||
const [isCvvFocused, setIsCvvFocused] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
cardNumber: '',
|
||||
cardholderName: '',
|
||||
cardExpiration: '',
|
||||
securityCode: '',
|
||||
identificationType: 'DNI',
|
||||
identificationNumber: '',
|
||||
email: ''
|
||||
});
|
||||
|
||||
// --- PATRÓN SINGLETON PARA MERCADOPAGO ---
|
||||
// El script de seguridad de MP (Armor) lanza errores de IndexedDB si se reinicializa múltiples veces.
|
||||
useEffect(() => {
|
||||
const publicKey = import.meta.env.VITE_MP_PUBLIC_KEY;
|
||||
if (window.MercadoPago) {
|
||||
if (!(window as any).mpInstanceGlobal) {
|
||||
try {
|
||||
(window as any).mpInstanceGlobal = new window.MercadoPago(publicKey);
|
||||
} catch (e) {
|
||||
console.error("Error al crear instancia de MP:", e);
|
||||
}
|
||||
}
|
||||
setMpInstance((window as any).mpInstanceGlobal);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mpInstance) {
|
||||
mpInstance.getIdentificationTypes()
|
||||
.then((types: IdentificationType[]) => {
|
||||
if (types?.length > 0) setDocTypes(types);
|
||||
})
|
||||
.catch(() => console.warn("Usando fallback de tipos de documento."));
|
||||
}
|
||||
}, [mpInstance]);
|
||||
|
||||
const handleBinLookup = async (bin: string) => {
|
||||
if (bin.length < 6 || !mpInstance) {
|
||||
setPaymentMethod(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await mpInstance.getPaymentMethods({ bin });
|
||||
if (response && response.results && response.results.length > 0) {
|
||||
setPaymentMethod(response.results[0]);
|
||||
}
|
||||
} catch (error) { console.error(error); }
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
let finalValue = value;
|
||||
|
||||
if (name === 'cardNumber') {
|
||||
const cleanValue = value.replace(/[^\d]/g, '');
|
||||
finalValue = cleanValue.replace(/(.{4})/g, '$1 ').trim();
|
||||
|
||||
if (cleanValue.length >= 6) {
|
||||
handleBinLookup(cleanValue.substring(0, 6));
|
||||
} else {
|
||||
setPaymentMethod(null);
|
||||
}
|
||||
} else if (name === 'cardExpiration') {
|
||||
const cleanValue = value.replace(/[^\d]/g, '');
|
||||
if (cleanValue.length <= 2) {
|
||||
finalValue = cleanValue;
|
||||
} else {
|
||||
finalValue = `${cleanValue.slice(0, 2)}/${cleanValue.slice(2, 4)}`;
|
||||
}
|
||||
}
|
||||
|
||||
setFormData(prev => ({ ...prev, [name]: finalValue }));
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
alert("Por seguridad, ingresa los datos manualmente.");
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!mpInstance || !paymentMethod) {
|
||||
onPaymentError("Verifica los datos de la tarjeta.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
const [expMonth, expYear] = formData.cardExpiration.split('/');
|
||||
|
||||
try {
|
||||
const tokenResponse = await mpInstance.createCardToken({
|
||||
cardNumber: formData.cardNumber.replace(/\s/g, ''),
|
||||
cardholderName: formData.cardholderName,
|
||||
cardExpirationMonth: expMonth,
|
||||
cardExpirationYear: `20${expYear}`,
|
||||
securityCode: formData.securityCode,
|
||||
identificationType: formData.identificationType,
|
||||
identificationNumber: formData.identificationNumber,
|
||||
});
|
||||
|
||||
if (!tokenResponse?.id) throw new Error("Datos de tarjeta inválidos.");
|
||||
|
||||
// 1. Intentamos obtener el issuer desde la respuesta del token
|
||||
let finalIssuerId = tokenResponse.issuer_id;
|
||||
|
||||
// 2. Si no viene en el token, intentamos buscarlo explícitamente
|
||||
if (!finalIssuerId && mpInstance) {
|
||||
try {
|
||||
const bin = formData.cardNumber.replace(/\s/g, '').substring(0, 6);
|
||||
const issuers = await mpInstance.getIssuers({ paymentMethodId: paymentMethod.id, bin });
|
||||
|
||||
if (issuers && issuers.length > 0) {
|
||||
finalIssuerId = issuers[0].id; // Tomamos el primero encontrado
|
||||
}
|
||||
} catch (issuerErr) {
|
||||
console.warn("No se pudo obtener issuer explícito", issuerErr);
|
||||
}
|
||||
}
|
||||
|
||||
const issuerIdToSend = finalIssuerId ? String(finalIssuerId) : "";
|
||||
|
||||
await onPaymentSuccess({
|
||||
token: tokenResponse.id,
|
||||
transactionAmount: amount,
|
||||
paymentMethodId: paymentMethod.id,
|
||||
installments: 1,
|
||||
issuerId: issuerIdToSend,
|
||||
payerEmail: formData.email
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
let msg = "Error procesando el pago.";
|
||||
if (err.cause?.[0]?.code) {
|
||||
const code = err.cause[0].code;
|
||||
if (code === "205") msg = "Revisá el número de tarjeta.";
|
||||
else if (['208', '209'].includes(code)) msg = "Revisá la fecha de vencimiento.";
|
||||
else if (['212', '213', '214'].includes(code)) msg = "Documento inválido.";
|
||||
else if (code === "221") msg = "Nombre y apellido requeridos.";
|
||||
else if (code === "224") msg = "Código de seguridad (CVV) inválido.";
|
||||
}
|
||||
onPaymentError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [expMonth, expYear] = formData.cardExpiration.split('/');
|
||||
|
||||
// 🟢 VALIDACIÓN EN TIEMPO REAL
|
||||
// Verifica que todos los campos tengan contenido válido antes de habilitar el botón
|
||||
const isFormValid =
|
||||
formData.cardNumber.replace(/\s/g, '').length >= 15 && // Tarjetas suelen ser 15 o 16 dígitos
|
||||
formData.cardholderName.trim().length > 2 &&
|
||||
formData.cardExpiration.length === 5 && // Formato MM/AA completo
|
||||
formData.securityCode.length >= 3 && // CVV mínimo
|
||||
formData.identificationNumber.trim().length >= 6 && // DNI razonable
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) && // Regex básico de email
|
||||
paymentMethod !== null; // Tarjeta reconocida
|
||||
|
||||
return (
|
||||
<div className="flex flex-col-reverse lg:grid lg:grid-cols-12 gap-6 md:gap-8 items-start animate-fade-in">
|
||||
|
||||
{/* --- COLUMNA IZQUIERDA: FORMULARIO --- */}
|
||||
<form id="payment-form" onSubmit={handleSubmit} className="w-full lg:col-span-7 space-y-4 md:space-y-6 order-2 lg:order-1">
|
||||
|
||||
<div className="bg-[#161a22] p-5 md:p-8 rounded-[1.5rem] md:rounded-[2rem] border border-white/5 shadow-xl">
|
||||
<h4 className="text-[10px] md:text-xs font-black uppercase tracking-widest text-gray-400 md:text-gray-500 mb-6 md:mb-8 border-b border-white/5 pb-4 flex items-center gap-2">
|
||||
<span>💳</span> Datos de Facturación
|
||||
</h4>
|
||||
|
||||
<div className="space-y-5 md:space-y-6">
|
||||
{/* Número */}
|
||||
<div>
|
||||
<div className="flex justify-between items-end mb-1.5 md:mb-2 ml-1">
|
||||
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 block">Número de Tarjeta</label>
|
||||
{/* Logo en móvil: al lado del label para no tapar el número */}
|
||||
{paymentMethod && (
|
||||
<div className="md:hidden animate-fade-in">
|
||||
<img src={paymentMethod.thumbnail} alt="card brand" className="h-4 object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
name="cardNumber"
|
||||
placeholder="0000 0000 0000 0000"
|
||||
maxLength={19}
|
||||
className="w-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-white font-mono text-base md:text-lg tracking-widest outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/50 transition-all placeholder:text-gray-700 group-hover:border-white/20 text-center"
|
||||
value={formData.cardNumber} onChange={handleInputChange} onPaste={handlePaste} required
|
||||
/>
|
||||
{/* Logo en desktop: dentro del input */}
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none transition-opacity duration-300 hidden md:block">
|
||||
{paymentMethod ? (
|
||||
<img src={paymentMethod.thumbnail} alt="card brand" className="h-6 object-contain" />
|
||||
) : (
|
||||
<div className="w-8 h-5 bg-white/5 rounded"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nombre */}
|
||||
<div>
|
||||
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 mb-1.5 md:mb-2 block ml-1">Nombre del Titular</label>
|
||||
<input
|
||||
type="text"
|
||||
name="cardholderName"
|
||||
placeholder="COMO FIGURA EN LA TARJETA"
|
||||
className="w-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-white text-xs md:text-sm font-bold uppercase outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 hover:border-white/20"
|
||||
value={formData.cardholderName} onChange={handleInputChange} required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grid Vencimiento + CVV */}
|
||||
<div className="grid grid-cols-2 gap-4 md:gap-6">
|
||||
<div>
|
||||
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 mb-1.5 md:mb-2 block ml-1">Vencimiento</label>
|
||||
<input
|
||||
type="text"
|
||||
name="cardExpiration"
|
||||
placeholder="MM/AA"
|
||||
maxLength={5}
|
||||
className="w-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-white text-center font-mono text-base md:text-lg outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 hover:border-white/20"
|
||||
value={formData.cardExpiration} onChange={handleInputChange} onPaste={handlePaste} required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 mb-1.5 md:mb-2 block ml-1">CVV / CVC</label>
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
name="securityCode"
|
||||
placeholder="123"
|
||||
maxLength={4}
|
||||
className="w-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-white text-center font-mono text-base md:text-lg tracking-widest outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 group-hover:border-white/20"
|
||||
value={formData.securityCode} onChange={handleInputChange} onPaste={handlePaste} required
|
||||
onFocus={() => setIsCvvFocused(true)}
|
||||
onBlur={() => setIsCvvFocused(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DNI - FILA COMPLETA */}
|
||||
<div>
|
||||
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 mb-1.5 md:mb-2 block ml-1">Documento del Titular</label>
|
||||
<div className="flex gap-2 md:gap-3">
|
||||
<div className="relative w-24 md:w-32 shrink-0">
|
||||
<select
|
||||
name="identificationType"
|
||||
className="w-full h-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-3 md:px-4 py-3.5 md:py-4 text-[10px] md:text-xs font-bold outline-none focus:border-blue-500 cursor-pointer appearance-none text-center hover:border-white/20 text-white"
|
||||
value={formData.identificationType}
|
||||
onChange={handleInputChange}
|
||||
>
|
||||
{docTypes.map(d => <option key={d.id} value={d.id} className="bg-gray-900">{d.name}</option>)}
|
||||
</select>
|
||||
<span className="absolute right-2 md:right-3 top-1/2 -translate-y-1/2 text-[8px] md:text-[10px] text-gray-500 pointer-events-none">▼</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="identificationNumber"
|
||||
placeholder="Nº Documento"
|
||||
className="flex-1 min-w-0 bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-white text-sm outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 hover:border-white/20"
|
||||
value={formData.identificationNumber}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EMAIL - FILA COMPLETA */}
|
||||
<div>
|
||||
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 mb-1.5 md:mb-2 block ml-1">Email para el comprobante</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="ejemplo@email.com"
|
||||
className="w-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-sm text-white outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 hover:border-white/20"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* --- COLUMNA DERECHA: VISUAL + BOTONES --- */}
|
||||
<div className="w-full lg:col-span-5 order-1 lg:order-2 flex flex-col gap-6 md:gap-8 lg:sticky lg:top-8">
|
||||
|
||||
{/* Tarjeta Visual */}
|
||||
<div className="perspective-[1000px] w-full max-w-sm mx-auto lg:max-w-none">
|
||||
<div className="relative group cursor-default transform transition-transform hover:scale-[1.02] duration-500">
|
||||
<div className="absolute -inset-1 bg-blue-600/20 rounded-2xl blur-lg opacity-10 group-hover:opacity-30 transition duration-700"></div>
|
||||
|
||||
<div className="scale-90 sm:scale-100 origin-center">
|
||||
<VisualCreditCard
|
||||
cardNumber={formData.cardNumber}
|
||||
cardholderName={formData.cardholderName}
|
||||
cardExpirationMonth={expMonth || ''}
|
||||
cardExpirationYear={expYear || ''}
|
||||
cvc={formData.securityCode}
|
||||
isFlipped={isCvvFocused}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 md:mt-6 text-center px-4">
|
||||
<p className="text-gray-500 text-[10px] md:text-xs leading-relaxed">
|
||||
Revisa que los datos coincidan exactamente con tu tarjeta física para evitar rechazos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOTONES DE ACCIÓN */}
|
||||
<div className="flex flex-col gap-3 w-full max-w-sm mx-auto lg:max-w-none">
|
||||
<button
|
||||
type="submit"
|
||||
form="payment-form"
|
||||
// 🟢 AHORA SE DESHABILITA SI !isFormValid
|
||||
disabled={loading || !isFormValid}
|
||||
className={`w-full text-white py-4 md:py-5 rounded-xl md:rounded-2xl font-black uppercase tracking-widest text-xs md:text-sm shadow-xl transition-all flex items-center justify-center gap-3
|
||||
${loading || !isFormValid
|
||||
? 'bg-gray-700 opacity-50 cursor-not-allowed grayscale'
|
||||
: 'bg-green-600 hover:bg-green-500 shadow-green-900/20 hover:scale-[1.02] active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{loading ? <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div> : 'CONFIRMAR PAGO'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="w-full bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white py-3.5 md:py-4 rounded-xl md:rounded-2xl font-bold uppercase tracking-widest text-[10px] md:text-xs transition-all border border-white/5 hover:border-white/10"
|
||||
>
|
||||
Cancelar Operación
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1043
Frontend/src/components/FormularioAviso.tsx
Normal file
1043
Frontend/src/components/FormularioAviso.tsx
Normal file
File diff suppressed because it is too large
Load Diff
694
Frontend/src/components/LoginModal.tsx
Normal file
694
Frontend/src/components/LoginModal.tsx
Normal file
@@ -0,0 +1,694 @@
|
||||
// src/components/LoginModal.tsx
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AuthService, type UserSession } from '../services/auth.service';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
interface Props {
|
||||
onSuccess: (user: UserSession) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// --- 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 flex-shrink-0">
|
||||
<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 XCircleIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-red-500 flex-shrink-0">
|
||||
<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.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z" 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 flex-shrink-0">
|
||||
<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 LoginModal({ onSuccess, onClose }: Props) {
|
||||
// Toggle entre Login y Registro
|
||||
const [mode, setMode] = useState<'LOGIN' | 'REGISTER'>('LOGIN');
|
||||
|
||||
// Estados de Login
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
// Para controlar si mostramos la opción de reenviar email no verificado
|
||||
const [showResend, setShowResend] = useState(false);
|
||||
const [unverifiedEmail, setUnverifiedEmail] = useState('');
|
||||
|
||||
// Estados de recuperación de clave
|
||||
const [forgotEmail, setForgotEmail] = useState('');
|
||||
|
||||
// Estados de Registro
|
||||
const [regData, setRegData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
username: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
// Estados para Migración / Nueva Clave
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showNewPass, setShowNewPass] = useState(false);
|
||||
const [showConfirmPass, setShowConfirmPass] = useState(false);
|
||||
|
||||
// Estados Generales
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [qrData, setQrData] = useState<{ uri: string, secret: string } | null>(null);
|
||||
const [step, setStep] = useState<'LOGIN' | 'MIGRATE' | 'MFA' | 'MFA_SETUP' | 'FORGOT' | 'MFA_PROMPT'>('LOGIN');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [tempUser, setTempUser] = useState<UserSession | null>(null);
|
||||
|
||||
// Validaciones
|
||||
const activePassword = step === 'MIGRATE' ? newPassword : (mode === 'REGISTER' ? regData.password : '');
|
||||
const activeConfirm = step === 'MIGRATE' ? confirmPassword : (mode === 'REGISTER' ? regData.confirmPassword : '');
|
||||
|
||||
const validations = {
|
||||
length: activePassword.length >= 8,
|
||||
upper: /[A-Z]/.test(activePassword),
|
||||
number: /\d/.test(activePassword),
|
||||
special: /[\W_]/.test(activePassword),
|
||||
match: activePassword.length > 0 && activePassword === activeConfirm
|
||||
};
|
||||
|
||||
const handleSafeClose = () => {
|
||||
if (step === 'MFA_PROMPT' && tempUser) {
|
||||
onSuccess(tempUser);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccessMessage('');
|
||||
setShowResend(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await AuthService.login(username, password);
|
||||
|
||||
if (res.status === 'MIGRATION_REQUIRED') {
|
||||
setStep('MIGRATE');
|
||||
} else if (res.status === 'MFA_SETUP_REQUIRED') {
|
||||
setQrData({ uri: res.qrUri, secret: res.secret });
|
||||
setStep('MFA_SETUP');
|
||||
} else if (res.status === 'TOTP_REQUIRED') {
|
||||
setStep('MFA');
|
||||
} else if (res.status === 'SUCCESS' && res.user) {
|
||||
if (res.recommendMfa) {
|
||||
setTempUser(res.user);
|
||||
setStep('MFA_PROMPT');
|
||||
} else {
|
||||
onSuccess(res.user);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || 'Error al iniciar sesión';
|
||||
setError(msg);
|
||||
if (msg.includes("verificar tu email") || msg === "EMAIL_NOT_VERIFIED") {
|
||||
setShowResend(true);
|
||||
setUnverifiedEmail(username);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgot = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccessMessage('');
|
||||
|
||||
try {
|
||||
const res = await AuthService.forgotPassword(forgotEmail);
|
||||
setSuccessMessage(res.message);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al procesar la solicitud.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendClick = async () => {
|
||||
if (!unverifiedEmail) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await AuthService.resendVerification(unverifiedEmail);
|
||||
setSuccessMessage("Correo reenviado. Revisa tu bandeja de entrada.");
|
||||
setShowResend(false);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Error al reenviar.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccessMessage('');
|
||||
|
||||
// Validación de Email básica
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(regData.email)) {
|
||||
setError('Por favor, ingresa un correo electrónico válido.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validación de Username (longitud)
|
||||
if (regData.username.length < 4) {
|
||||
setError('El usuario debe tener al menos 4 caracteres.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validación de Contraseña (Requisitos)
|
||||
if (!validations.length || !validations.upper || !validations.number || !validations.special || !validations.match) {
|
||||
setError('Por favor, verifique los requisitos de la contraseña.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await AuthService.register({
|
||||
username: regData.username,
|
||||
email: regData.email,
|
||||
firstName: regData.firstName,
|
||||
lastName: regData.lastName,
|
||||
phoneNumber: regData.phone,
|
||||
password: regData.password
|
||||
});
|
||||
|
||||
setSuccessMessage('¡Cuenta creada con éxito! Revisa tu email para activarla.');
|
||||
setRegData({ firstName: '', lastName: '', email: '', username: '', phone: '', password: '', confirmPassword: '' });
|
||||
setTimeout(() => setMode('LOGIN'), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al crear la cuenta.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyMFA = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const userToVerify = tempUser?.username || username;
|
||||
const user = await AuthService.verifyMFA(userToVerify, mfaCode);
|
||||
if (user) {
|
||||
onSuccess(user);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError('Código inválido o expirado');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!validations.length || !validations.upper || !validations.number || !validations.special) {
|
||||
setError('La contraseña no cumple con los requisitos de seguridad.');
|
||||
return;
|
||||
}
|
||||
if (!validations.match) {
|
||||
setError('Las contraseñas no coinciden.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await AuthService.migratePassword(username, newPassword);
|
||||
setSuccessMessage("¡Contraseña actualizada con éxito! Por favor, inicie sesión.");
|
||||
setStep('LOGIN');
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (err: any) {
|
||||
setError('Error al actualizar contraseña. Intente nuevamente.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetupMfaClick = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await AuthService.initMFA();
|
||||
setQrData({ uri: data.qrUri, secret: data.secret });
|
||||
setStep('MFA_SETUP');
|
||||
} catch (err) {
|
||||
setError("No se pudo iniciar la configuración de seguridad.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipMfa = () => {
|
||||
if (tempUser) onSuccess(tempUser);
|
||||
};
|
||||
|
||||
// --- COMPONENTE DE REQUISITOS (EN 2 COLUMNAS) ---
|
||||
const RequirementItem = ({ isValid, text }: { isValid: boolean, text: string }) => {
|
||||
const isNeutral = activePassword.length === 0;
|
||||
return (
|
||||
<li className={`flex items-center gap-1.5 text-[10px] transition-colors duration-300 ${isValid ? 'text-green-400' : isNeutral ? 'text-gray-500' : 'text-red-400'}`}>
|
||||
{isNeutral ? <NeutralCircleIcon /> : isValid ? <CheckCircleIcon /> : <XCircleIcon />}
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
// COMPONENTE DE LISTA DE REQUISITOS (GRID)
|
||||
const PasswordRequirements = () => (
|
||||
<div className="bg-black/20 p-3 rounded-xl border border-white/5 mt-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500 mb-2">Seguridad de la clave:</p>
|
||||
<ul className="grid grid-cols-2 gap-y-1 gap-x-2">
|
||||
<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 (!@#)" />
|
||||
<div className="col-span-2">
|
||||
<RequirementItem isValid={validations.match} text="Las contraseñas coinciden" />
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
const getTitle = () => {
|
||||
if (step === 'MIGRATE') return 'Renovar Clave';
|
||||
if (step === 'MFA' || step === 'MFA_SETUP') return 'Seguridad';
|
||||
if (step === 'FORGOT') return 'Recuperar Clave';
|
||||
if (step === 'MFA_PROMPT') return 'Protege tu Cuenta';
|
||||
return mode === 'LOGIN' ? 'Ingresar' : 'Crear Cuenta';
|
||||
};
|
||||
|
||||
return (
|
||||
// CAMBIO: max-w-lg (más ancho) y overflow-hidden para evitar scrollbars feos
|
||||
<div className="glass px-8 py-6 rounded-3xl border border-white/10 shadow-2xl max-w-lg w-full animate-fade-in-up relative overflow-hidden">
|
||||
|
||||
{loading && <div className="absolute top-0 left-0 w-full h-1 bg-blue-500 animate-pulse"></div>}
|
||||
|
||||
<button
|
||||
onClick={handleSafeClose}
|
||||
className="absolute top-5 right-5 text-gray-500 hover:text-white font-bold uppercase text-[20px] tracking-widest flex items-center gap-2 transition-colors z-50"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-3xl font-black uppercase tracking-tighter mb-2">{getTitle()}</h2>
|
||||
|
||||
{step === 'LOGIN' && (
|
||||
<div className="inline-flex bg-white/5 rounded-xl p-1 border border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMode('LOGIN'); setError(''); setSuccessMessage(''); setShowResend(false); }}
|
||||
className={`px-6 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all ${mode === 'LOGIN' ? 'bg-blue-600 text-white shadow-lg' : 'text-gray-500 hover:text-white'}`}
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMode('REGISTER'); setError(''); setSuccessMessage(''); setShowResend(false); }}
|
||||
className={`px-6 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all ${mode === 'REGISTER' ? 'bg-blue-600 text-white shadow-lg' : 'text-gray-500 hover:text-white'}`}
|
||||
>
|
||||
Registrarse
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 flex items-center gap-2 animate-shake">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5 flex-shrink-0">
|
||||
<path fillRule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="bg-green-500/20 text-green-300 p-3 rounded-xl mb-6 text-xs font-bold border border-green-500/20 flex items-center gap-2 animate-fade-in-up">
|
||||
<CheckCircleIcon />
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- FORMULARIO LOGIN --- */}
|
||||
{step === 'LOGIN' && mode === 'LOGIN' && (
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div>
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-1.5 ml-1">Usuario</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-white outline-none focus:border-blue-500 transition-all placeholder:text-gray-600 focus:bg-white/10"
|
||||
placeholder="Tu nombre de usuario"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-1.5 ml-1">Contraseña</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-white outline-none focus:border-blue-500 transition-all placeholder:text-gray-600 focus:bg-white/10"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<div className="text-right mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('FORGOT'); setError(''); setSuccessMessage(''); }}
|
||||
className="text-[10px] text-gray-500 hover:text-blue-400 font-bold uppercase tracking-widest transition-colors"
|
||||
>
|
||||
¿Olvidaste tu contraseña?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:scale-100">
|
||||
{loading ? 'Verificando...' : 'Entrar'}
|
||||
</button>
|
||||
|
||||
{showResend && (
|
||||
<div className="bg-amber-500/10 p-4 rounded-xl border border-amber-500/20 text-center animate-fade-in">
|
||||
<p className="text-[10px] text-amber-200 mb-3">
|
||||
¿No recibiste el correo de activación?
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendClick}
|
||||
disabled={loading}
|
||||
className="bg-amber-500/20 hover:bg-amber-500/30 text-amber-400 text-xs font-bold py-2 px-4 rounded-lg uppercase tracking-widest transition-all w-full"
|
||||
>
|
||||
{loading ? 'Enviando...' : 'Reenviar Email'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-gray-500 text-center italic mt-2">
|
||||
Plataforma Motores V2 • Acceso Unificado
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* --- FORMULARIO OLVIDÉ CLAVE --- */}
|
||||
{step === 'FORGOT' && (
|
||||
<form onSubmit={handleForgot} className="space-y-5">
|
||||
<div className="bg-blue-600/10 p-5 rounded-2xl border border-blue-500/20 mb-2">
|
||||
<p className="text-xs text-blue-200 font-medium leading-relaxed text-center">
|
||||
Ingresa tu email o usuario. Te enviaremos un enlace para restablecer tu clave.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-1.5 ml-1">Email / Usuario</label>
|
||||
<input required type="text" value={forgotEmail} onChange={e => setForgotEmail(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-white outline-none focus:border-blue-500 transition-all placeholder:text-gray-600"
|
||||
placeholder="ejemplo@email.com" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{loading ? 'Enviando...' : 'Enviar Enlace'}
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={() => { setStep('LOGIN'); setError(''); setSuccessMessage(''); }} className="w-full text-[10px] text-gray-500 font-bold uppercase tracking-widest hover:text-white transition-all mt-4">
|
||||
← Volver al Login
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* --- FORMULARIO REGISTRO --- */}
|
||||
{step === 'LOGIN' && mode === 'REGISTER' && (
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-1">Nombre</label>
|
||||
<input required type="text" value={regData.firstName} onChange={e => setRegData({ ...regData, firstName: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-sm outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-1">Apellido</label>
|
||||
<input required type="text" value={regData.lastName} onChange={e => setRegData({ ...regData, lastName: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-sm outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-1">Email</label>
|
||||
<input required type="email" value={regData.email} onChange={e => setRegData({ ...regData, email: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-sm outline-none focus:border-blue-500" placeholder="email@ejemplo.com" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-1">Usuario</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
value={regData.username}
|
||||
onChange={e => {
|
||||
const cleanValue = e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
setRegData({ ...regData, username: cleanValue });
|
||||
}}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-sm outline-none focus:border-blue-500 placeholder:text-gray-700"
|
||||
placeholder="solo letras y números"
|
||||
/>
|
||||
<p className="text-[8px] text-gray-600 mt-1 ml-1">
|
||||
* Sin espacios.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-1">Teléfono</label>
|
||||
<input required type="tel" value={regData.phone} onChange={e => setRegData({ ...regData, phone: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-sm outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PASSWORD Y CONFIRMACIÓN */}
|
||||
<div className="space-y-3 pt-2 border-t border-white/5">
|
||||
<div className="relative">
|
||||
<input required type={showNewPass ? "text" : "password"} value={regData.password} onChange={e => setRegData({ ...regData, password: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl pl-3 pr-10 py-2 text-white text-sm outline-none focus:border-blue-500 placeholder:text-gray-600" placeholder="Contraseña" />
|
||||
<button type="button" onClick={() => setShowNewPass(!showNewPass)} className="absolute right-3 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={regData.confirmPassword} onChange={e => setRegData({ ...regData, confirmPassword: e.target.value })}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl pl-3 pr-10 py-2 text-white text-sm outline-none focus:border-blue-500 placeholder:text-gray-600" placeholder="Confirmar Contraseña" />
|
||||
<button type="button" onClick={() => setShowConfirmPass(!showConfirmPass)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
|
||||
{showConfirmPass ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LISTA DE REQUISITOS EN GRID */}
|
||||
<PasswordRequirements />
|
||||
|
||||
</div>
|
||||
|
||||
<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 hover:scale-[1.02] active:scale-95 disabled:opacity-50">
|
||||
{loading ? 'Registrando...' : 'Crear Cuenta'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* --- MFA SETUP --- */}
|
||||
{step === 'MFA_SETUP' && qrData && (
|
||||
<form onSubmit={handleVerifyMFA} className="space-y-6">
|
||||
<div className="bg-blue-600/10 p-6 rounded-[2rem] border border-blue-500/20 text-center">
|
||||
<p className="text-xs text-blue-300 font-bold uppercase tracking-widest mb-4">Configuración de Seguridad</p>
|
||||
<div className="bg-white p-3 rounded-2xl inline-block mb-4 shadow-xl">
|
||||
<QRCodeSVG value={qrData.uri} size={160} />
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mb-2 max-w-[200px] mx-auto">Escanea con Google Authenticator o Authy</p>
|
||||
<div className="bg-black/30 p-2 rounded-lg border border-white/5">
|
||||
<code className="text-[12px] text-blue-400 break-all font-mono tracking-widest">{qrData.secret}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-2 text-center">Código de verificación</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={mfaCode}
|
||||
onChange={e => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-4 text-center text-4xl font-black tracking-[0.5em] text-white outline-none focus:border-blue-500 transition-all placeholder:opacity-10"
|
||||
/>
|
||||
</div>
|
||||
<button disabled={loading} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-bold uppercase transition-all shadow-lg shadow-blue-600/20">
|
||||
{loading ? 'Activando...' : 'Activar y Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* --- MFA LOGIN --- */}
|
||||
{step === 'MFA' && (
|
||||
<form onSubmit={handleVerifyMFA} className="space-y-6">
|
||||
<div className="bg-blue-600/10 p-6 rounded-[2rem] border border-blue-500/20 text-center">
|
||||
<div className="w-16 h-16 bg-blue-500/20 rounded-2xl flex items-center justify-center mx-auto mb-4 text-3xl">🛡️</div>
|
||||
<p className="text-xs text-blue-300 font-bold uppercase tracking-widest">Autenticación de 2 Factores</p>
|
||||
<p className="text-[10px] text-blue-200/60 mt-2">
|
||||
Tu cuenta está protegida. Ingresa el código temporal de tu aplicación.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="000 000"
|
||||
value={mfaCode}
|
||||
onChange={e => setMfaCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-4 text-center text-3xl font-black tracking-[0.2em] text-white outline-none focus:border-blue-500 transition-all placeholder:opacity-10"
|
||||
/>
|
||||
</div>
|
||||
<button disabled={loading} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-bold uppercase transition-all shadow-lg shadow-blue-600/20">
|
||||
{loading ? 'Verificando...' : 'Confirmar Acceso'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setStep('LOGIN'); setPassword(''); }} className="w-full text-[10px] text-gray-500 font-bold uppercase tracking-widest hover:text-white transition-all">
|
||||
← Volver al login
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* --- MIGRACIÓN PASSWORD --- */}
|
||||
{step === 'MIGRATE' && (
|
||||
<form onSubmit={handleMigrate} className="space-y-5">
|
||||
<div className="bg-amber-500/10 p-5 rounded-[2rem] border border-amber-500/20 mb-2">
|
||||
<p className="text-xs text-amber-200 font-medium leading-relaxed">
|
||||
<strong className="block text-amber-400 mb-1 uppercase text-[10px] tracking-widest">Actualización Requerida</strong>
|
||||
Bienvenido a la nueva plataforma. Por seguridad, detectamos que tu usuario proviene del sistema anterior.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Nueva Contraseña */}
|
||||
<div>
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-1.5 ml-1">Nueva Contraseña</label>
|
||||
<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-green-500 transition-all placeholder:text-gray-600"
|
||||
placeholder="Escribe tu nueva clave"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPass(!showNewPass)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"
|
||||
>
|
||||
{showNewPass ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repetir Contraseña */}
|
||||
<div>
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-1.5 ml-1">Repetir Contraseña</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
required
|
||||
type={showConfirmPass ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className={`w-full bg-white/5 border rounded-xl pl-4 pr-12 py-3.5 text-white outline-none transition-all placeholder:text-gray-600 ${confirmPassword && confirmPassword !== newPassword ? 'border-red-500/50' : 'border-white/10 focus:border-green-500'
|
||||
}`}
|
||||
placeholder="Confirma tu nueva clave"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPass(!showConfirmPass)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"
|
||||
>
|
||||
{showConfirmPass ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requisitos visuales reutilizados */}
|
||||
<PasswordRequirements />
|
||||
</div>
|
||||
|
||||
<button disabled={loading} className="w-full bg-green-600 hover:bg-green-500 text-white py-4 rounded-xl font-bold uppercase tracking-widest transition-all shadow-lg shadow-green-600/20 mt-4 hover:scale-[1.02] active:scale-95">
|
||||
{loading ? 'Actualizando...' : 'Establecer Nueva Clave'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* --- MFA PROMPT --- */}
|
||||
{step === 'MFA_PROMPT' && (
|
||||
<div className="space-y-6 text-center animate-fade-in px-2">
|
||||
|
||||
<div className="relative w-20 h-20 mx-auto mb-6">
|
||||
<div className="absolute inset-0 bg-blue-500 rounded-full blur-xl opacity-20 animate-pulse"></div>
|
||||
<div className="relative bg-gradient-to-br from-blue-600 to-cyan-400 w-full h-full rounded-2xl flex items-center justify-center text-4xl shadow-xl border border-white/10 rotate-3 hover:rotate-6 transition-transform">
|
||||
🛡️
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tight mb-2">¡Protege tu Cuenta!</h3>
|
||||
<p className="text-xs text-gray-400 leading-relaxed font-medium">
|
||||
Detectamos que no tienes activada la autenticación de dos pasos.
|
||||
<br /><br />
|
||||
<span className="text-blue-300">Actívala ahora para evitar accesos no autorizados y asegurar tus publicaciones.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<button
|
||||
onClick={handleSetupMfaClick}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-blue-600/20 hover:scale-[1.02] active:scale-95 flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>🚀</span> Activar Protección
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSkipMfa}
|
||||
className="w-full text-gray-500 hover:text-white py-3 font-bold uppercase tracking-widest text-[10px] transition-all hover:bg-white/5 rounded-xl"
|
||||
>
|
||||
Recordar más tarde
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
Frontend/src/components/MercadoPagoLogo.tsx
Normal file
35
Frontend/src/components/MercadoPagoLogo.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// src/components/MercadoPagoLogo.tsx
|
||||
|
||||
export default function MercadoPagoLogo({ className = "h-8" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
id="logos"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1048.82 425.2"
|
||||
aria-label="Mercado Pago"
|
||||
>
|
||||
<defs>
|
||||
<style>{`.cls-1{fill:#0a0080;}.cls-1,.cls-2,.cls-3{stroke-width:0px;}.cls-2{fill:#fff;}.cls-3{fill:#00bcff;}`}</style>
|
||||
</defs>
|
||||
<path className="cls-3" d="m274.38,116.94c-77.83,0-140.91,40.36-140.91,90.15s63.09,94.05,140.91,94.05,140.91-44.27,140.91-94.05-63.09-90.15-140.91-90.15Z" />
|
||||
<path className="cls-2" d="m228.53,179.22c-.07.14-1.45,1.56-.55,2.71,2.18,2.78,8.91,4.38,15.72,2.85,4.05-.91,9.25-5.04,14.28-9.03,5.45-4.33,10.86-8.67,16.3-10.39,5.76-1.83,9.45-1.05,11.89-.31,2.67.8,5.82,2.56,10.84,6.32,9.45,7.1,47.43,40.26,54,45.99,5.28-2.39,30.47-12.56,62.39-19.6-2.78-17.02-13.01-33.25-28.72-45.99-21.89,9.19-50.42,14.7-76.58,1.93-.13-.05-14.29-6.75-28.25-6.42-20.75.48-29.74,9.46-39.25,18.97l-12.05,12.99Z" />
|
||||
<path className="cls-2" d="m349.44,220.97c-.45-.4-44.67-39.09-54.69-46.62-5.8-4.35-9.02-5.46-12.41-5.89-1.76-.23-4.2.1-5.9.57-4.66,1.27-10.75,5.34-16.16,9.63-5.6,4.46-10.88,8.66-15.79,9.76-6.26,1.4-13.91-.25-17.4-2.61-1.41-.95-2.41-2.05-2.89-3.16-1.29-2.99,1.09-5.38,1.48-5.78l12.2-13.2c1.42-1.41,2.85-2.83,4.31-4.23-3.94.51-7.58,1.52-11.12,2.5-4.42,1.24-8.68,2.42-12.98,2.42-1.8,0-11.42-1.58-13.25-2.07-11.05-3.02-23.56-5.97-38.04-12.73-17.35,12.91-28.65,28.77-32,46.56,2.49.66,9.02,2.15,10.71,2.52,39.26,8.73,51.49,17.72,53.71,19.6,2.4-2.67,5.87-4.36,9.73-4.36,4.35,0,8.26,2.19,10.64,5.56,2.25-1.78,5.35-3.3,9.36-3.29,1.82,0,3.71.34,5.62.98,4.43,1.52,6.72,4.47,7.9,7.14,1.48-.67,3.31-1.17,5.46-1.16,2.12,0,4.32.48,6.53,1.44,7.24,3.11,8.36,10.22,7.71,15.58.52-.06,1.04-.08,1.56-.08,8.58,0,15.56,6.98,15.56,15.57,0,2.66-.68,5.16-1.86,7.35,2.34,1.31,8.29,4.28,13.52,3.62,4.17-.53,5.76-1.95,6.32-2.76.39-.55.8-1.2.42-1.66l-11.08-12.3s-1.82-1.73-1.22-2.39c.62-.68,1.75.3,2.55.96,5.64,4.71,12.52,11.81,12.52,11.81.12.08.57.98,3.12,1.43,2.19.39,6.07.17,8.76-2.04.67-.56,1.35-1.25,1.93-1.97-.05.04-.09.08-.13.1,2.84-3.63-.32-7.29-.32-7.29l-12.93-14.52s-1.85-1.71-1.22-2.4c.56-.6,1.75.3,2.56.98,4.09,3.42,9.88,9.23,15.42,14.66,1.09.79,5.96,3.8,12.41-.43,3.92-2.57,4.7-5.73,4.59-8.1-.27-3.15-2.73-5.4-2.73-5.4l-17.66-17.76s-1.87-1.59-1.21-2.4c.54-.68,1.75.3,2.55.96,5.62,4.71,20.86,18.68,20.86,18.68.22.15,5.48,3.9,11.99-.24,2.33-1.49,3.81-3.73,3.94-6.34.22-4.52-2.96-7.2-2.96-7.2Z" />
|
||||
<path className="cls-2" d="m263.76,243.48c-2.74-.03-5.74,1.6-6.13,1.36-.22-.14.17-1.24.42-1.88.27-.63,3.87-11.48-4.92-15.25-6.73-2.89-10.85.36-12.26,1.83-.37.38-.54.35-.58-.13-.14-1.96-1.01-7.24-6.82-9.02-8.3-2.54-13.64,3.25-14.99,5.35-.61-4.73-4.61-8.4-9.5-8.41-5.32,0-9.64,4.3-9.65,9.63,0,5.32,4.31,9.64,9.64,9.64,2.59,0,4.93-1.03,6.66-2.69.06.05.08.14.05.32-.41,2.39-1.15,11.04,7.92,14.57,3.64,1.41,6.73.36,9.29-1.43.76-.54.89-.31.78.41-.33,2.23.09,6.99,6.77,9.7,5.08,2.07,8.09-.04,10.07-1.87.86-.78,1.09-.65,1.14.56.24,6.44,5.59,11.56,12.09,11.57,6.7,0,12.13-5.41,12.13-12.1,0-6.7-5.42-12.06-12.12-12.13Z" />
|
||||
<path className="cls-1" d="m274.35,113.21c-79.31,0-143.6,42.18-143.6,93.92,0,1.34-.02,5.03-.02,5.5,0,54.9,56.19,99.35,143.6,99.35s143.61-44.45,143.61-99.34v-5.51c0-51.74-64.29-93.92-143.59-93.92Zm137.12,83.51c-31.21,6.94-54.49,17.01-60.32,19.61-13.62-11.89-45.1-39.26-53.63-45.66-4.87-3.67-8.2-5.6-11.12-6.47-1.31-.4-3.12-.85-5.45-.85-2.17,0-4.5.39-6.93,1.17-5.51,1.75-11,6.11-16.31,10.33l-.27.22c-4.95,3.93-10.06,8-13.93,8.86-1.69.38-3.43.58-5.16.58-4.34,0-8.23-1.26-9.69-3.12-.24-.31-.08-.81.48-1.52l.07-.1,11.99-12.91c9.39-9.39,18.25-18.25,38.66-18.72.34-.01.68-.02,1.02-.02,12.7.01,25.4,5.69,26.83,6.36,11.91,5.81,24.21,8.76,36.56,8.77,12.85,0,26.11-3.17,40.05-9.58,14.56,12.24,24.21,26.99,27.15,43.06Zm-137.1-77.97c42.1,0,79.76,12.07,105.09,31.07-12.24,5.3-23.91,7.97-35.17,7.97-11.52-.01-23.03-2.78-34.21-8.23-.59-.28-14.61-6.89-29.2-6.9-.38,0-.77,0-1.15.01-17.14.4-26.8,6.49-33.29,11.82-6.31.16-11.76,1.68-16.61,3.03-4.33,1.2-8.06,2.24-11.7,2.24-1.5,0-4.2-.14-4.44-.15-4.18-.13-25.18-5.28-41.95-11.61,25.27-17.96,61.89-29.26,102.64-29.26Zm-107.61,33.01c17.51,7.16,38.76,12.7,45.48,13.13,1.87.12,3.87.34,5.87.34,4.46,0,8.91-1.25,13.21-2.45,2.54-.71,5.35-1.49,8.3-2.05-.79.77-1.58,1.56-2.37,2.35l-12.17,13.17c-.96.97-3.04,3.55-1.67,6.73.54,1.28,1.65,2.51,3.2,3.55,2.9,1.95,8.1,3.28,12.92,3.28,1.83,0,3.57-.18,5.15-.54,5.11-1.14,10.46-5.41,16.13-9.92,4.52-3.59,10.94-8.15,15.86-9.49,1.38-.37,3.06-.61,4.42-.61.41,0,.79.02,1.14.07,3.24.41,6.38,1.51,11.99,5.72,10,7.51,54.22,46.2,54.65,46.58.03.02,2.85,2.46,2.65,6.5-.11,2.26-1.36,4.26-3.54,5.65-1.89,1.2-3.83,1.81-5.8,1.81-2.96,0-4.99-1.39-5.13-1.48-.16-.13-15.31-14.03-20.89-18.7-.89-.74-1.75-1.4-2.62-1.4-.47,0-.88.2-1.16.55-.88,1.08.1,2.58,1.26,3.56l17.7,17.8s2.21,2.06,2.45,4.79c.14,2.95-1.27,5.42-4.2,7.34-2.09,1.38-4.2,2.07-6.27,2.07-2.72,0-4.63-1.24-5.05-1.53l-2.54-2.5c-4.64-4.57-9.43-9.29-12.94-12.21-.86-.71-1.77-1.37-2.64-1.37-.43,0-.82.16-1.12.48-.4.44-.68,1.24.32,2.57.4.55.89,1,.89,1l12.91,14.51c.1.13,2.66,3.17.29,6.19l-.46.58c-.39.42-.8.82-1.2,1.16-2.2,1.81-5.14,2-6.31,2-.63,0-1.22-.05-1.75-.15-1.27-.23-2.13-.58-2.55-1.07l-.16-.16c-.7-.73-7.21-7.38-12.6-11.87-.71-.6-1.6-1.34-2.51-1.34-.45,0-.85.18-1.17.52-1.06,1.17.54,2.91,1.22,3.55l11.01,12.15c-.01.11-.15.36-.41.74-.4.55-1.73,1.88-5.73,2.38-.48.06-.98.09-1.46.09-4.12,0-8.52-2-10.79-3.2,1.03-2.18,1.57-4.58,1.57-6.98,0-9.07-7.36-16.44-16.43-16.45-.19,0-.4,0-.59.01.29-4.14-.29-11.98-8.34-15.43-2.32-1-4.63-1.52-6.87-1.52-1.76,0-3.45.3-5.04.91-1.67-3.24-4.44-5.6-8.04-6.83-2-.69-3.98-1.04-5.9-1.04-3.35,0-6.44.99-9.19,2.94-2.64-3.28-6.62-5.22-10.81-5.22-3.67,0-7.2,1.47-9.81,4.06-3.43-2.62-17.03-11.26-53.44-19.53-1.74-.39-5.69-1.52-8.17-2.25,3.41-16.34,13.8-31.27,29.2-43.52Zm67.54,94.78l-.39-.35h-.4c-.32,0-.66.13-1.11.45-1.86,1.31-3.63,1.94-5.44,1.94-1,0-2.02-.2-3.04-.59-8.44-3.29-7.78-11.25-7.36-13.65.06-.49-.06-.86-.37-1.12l-.6-.49-.56.53c-1.65,1.59-3.8,2.45-6.06,2.45-4.83,0-8.77-3.93-8.76-8.77,0-4.83,3.94-8.76,8.78-8.75,4.37,0,8.09,3.28,8.64,7.65l.3,2.35,1.29-1.99c.14-.23,3.69-5.59,10.2-5.58,1.24,0,2.52.2,3.81.6,5.19,1.58,6.07,6.29,6.2,8.25.09,1.14.91,1.2,1.06,1.2.45,0,.78-.28,1.01-.53.98-1.02,3.11-2.72,6.45-2.72,1.53,0,3.15.37,4.83,1.09,8.25,3.54,4.51,14.02,4.47,14.13-.71,1.74-.74,2.5-.07,2.95l.32.15h.24c.37,0,.83-.16,1.6-.42,1.12-.39,2.81-.97,4.4-.97h0c6.21.07,11.26,5.13,11.26,11.26,0,6.2-5.06,11.24-11.27,11.24-6.07,0-11.01-4.73-11.23-10.74-.02-.52-.07-1.88-1.23-1.88-.47,0-.89.29-1.36.72-1.34,1.24-3.04,2.49-5.52,2.49-1.13,0-2.35-.26-3.64-.79-6.41-2.6-6.5-7-6.24-8.77.07-.47.09-.96-.23-1.35Zm40.07,48.88c-76.26,0-138.08-39.55-138.08-88.33,0-1.96.14-3.91.33-5.84.61.15,6.67,1.59,7.92,1.88,37.19,8.26,49.48,16.85,51.56,18.48-.7,1.69-1.07,3.51-1.07,5.35,0,7.69,6.25,13.95,13.93,13.95.86,0,1.72-.08,2.56-.24,1.16,5.66,4.86,9.95,10.51,12.15,1.65.63,3.32.96,4.97.96,1.06,0,2.13-.13,3.17-.39,1.05,2.65,3.39,5.96,8.65,8.09,1.84.74,3.68,1.13,5.47,1.13,1.46,0,2.89-.26,4.25-.76,2.52,6.13,8.51,10.2,15.19,10.2,4.43,0,8.68-1.8,11.78-4.99,2.65,1.48,8.25,4.15,13.91,4.16.73,0,1.41-.05,2.11-.13,5.62-.71,8.23-2.91,9.43-4.62.22-.3.41-.62.58-.95,1.32.38,2.78.69,4.46.7,3.07,0,6.01-1.05,8.99-3.21,2.93-2.11,5.01-5.14,5.31-7.72,0-.03,0-.07.01-.11.99.2,2,.3,3.01.3,3.16,0,6.27-.98,9.24-2.93,5.73-3.75,6.72-8.66,6.63-11.87,1.01.21,2.03.32,3.05.32,2.96,0,5.88-.89,8.65-2.66,3.55-2.27,5.69-5.75,6.02-9.79.21-2.75-.47-5.53-1.91-7.91,9.58-4.13,31.48-12.12,57.27-17.93.11,1.46.17,2.93.17,4.41,0,48.78-61.82,88.33-138.07,88.33Z" />
|
||||
<g>
|
||||
<path className="cls-1" d="m910.26,142.12c-5.21-6.54-13.13-9.8-23.75-9.8s-18.53,3.27-23.74,9.8c-5.22,6.53-7.83,14.25-7.83,23.16s2.61,16.81,7.83,23.26c5.21,6.43,13.13,9.65,23.74,9.65s18.54-3.22,23.75-9.65c5.22-6.45,7.82-14.19,7.82-23.26s-2.6-16.63-7.82-23.16Zm-12.92,37.48c-2.53,3.35-6.15,5.04-10.89,5.04s-8.36-1.69-10.91-5.04c-2.55-3.35-3.82-8.13-3.82-14.32s1.27-10.95,3.82-14.29c2.55-3.34,6.19-5.01,10.91-5.01s8.35,1.67,10.89,5.01c2.53,3.34,3.8,8.11,3.8,14.29s-1.27,10.97-3.8,14.32Z" />
|
||||
<path className="cls-1" d="m776.98,136.65c-5.29-2.68-11.34-4.03-18.15-4.03-10.47,0-17.86,2.73-22.17,8.18-2.71,3.49-4.22,7.95-4.58,13.37h15.65c.38-2.4,1.15-4.29,2.31-5.69,1.61-1.89,4.36-2.84,8.23-2.84,3.46,0,6.08.48,7.88,1.45,1.78.96,2.68,2.72,2.68,5.26,0,2.09-1.16,3.61-3.49,4.61-1.3.57-3.46,1.04-6.48,1.42l-5.55.68c-6.3.8-11.08,2.13-14.32,3.99-5.92,3.41-8.88,8.93-8.88,16.55,0,5.87,1.83,10.41,5.52,13.61,3.67,3.21,8.34,4.55,13.98,4.81,35.37,1.59,34.98-18.64,35.3-22.84v-23.27c0-7.47-2.65-12.55-7.93-15.25Zm-8.22,35.32c-.11,5.42-1.66,9.15-4.64,11.2-2.99,2.05-6.24,3.07-9.78,3.07-2.24,0-4.14-.63-5.7-1.85-1.56-1.23-2.34-3.24-2.34-6.01,0-3.1,1.28-5.39,3.83-6.88,1.51-.87,3.99-1.61,7.45-2.2l3.69-.69c1.84-.35,3.28-.73,4.34-1.13,1.07-.38,2.1-.9,3.13-1.55v6.03Z" />
|
||||
<path className="cls-1" d="m696.32,146.48c4.05,0,7.01,1.25,8.94,3.75,1.31,1.84,2.13,3.93,2.45,6.24h17.45c-.95-8.81-4.03-14.95-9.24-18.43-5.22-3.47-11.9-5.21-20.07-5.21-9.61,0-17.15,2.95-22.61,8.84-5.46,5.9-8.2,14.15-8.2,24.75,0,9.38,2.47,17.04,7.42,22.93,4.95,5.89,12.66,8.84,23.14,8.84s18.42-3.53,23.76-10.61c3.35-4.38,5.23-9.03,5.62-13.94h-17.39c-.36,3.25-1.37,5.9-3.06,7.94-1.67,2.03-4.5,3.06-8.5,3.06-5.63,0-9.47-2.57-11.5-7.72-1.12-2.75-1.69-6.38-1.69-10.91s.57-8.54,1.69-11.43c2.12-5.39,6.05-8.1,11.79-8.1Z" />
|
||||
<path className="cls-1" d="m660.36,132.83c-35.85,0-33.72,31.73-33.72,31.73v32.24h16.27v-30.23c0-4.96.63-8.62,1.86-11.01,2.23-4.23,6.6-6.35,13.1-6.35.49,0,1.13.03,1.92.07.79.04,1.69.11,2.73.23v-16.55c-.72-.05-1.19-.07-1.39-.1-.21-.02-.46-.03-.77-.03Z" />
|
||||
<path className="cls-1" d="m613.6,144.85c-2.81-4.16-6.38-7.21-10.68-9.15-4.31-1.92-9.15-2.88-14.52-2.88-9.06,0-16.42,2.85-22.1,8.56-5.67,5.72-8.52,13.92-8.52,24.63,0,11.43,3.15,19.67,9.44,24.74,6.28,5.06,13.54,7.61,21.76,7.61,9.96,0,17.71-3.01,23.24-9.02,2.99-3.16,4.86-6.29,5.65-9.38h-17.26c-.68.98-1.41,1.81-2.22,2.46-2.3,1.89-5.42,2.47-9.09,2.47-3.47,0-6.2-.52-8.66-2.07-4.06-2.5-6.35-6.72-6.59-12.91h45.01c.06-5.34-.11-9.43-.54-12.27-.74-4.84-2.4-9.1-4.92-12.77Zm-39.15,14.38c.58-4.02,2.03-7.2,4.3-9.56,2.29-2.35,5.5-3.53,9.65-3.53,3.81,0,7.01,1.11,9.59,3.34,2.57,2.22,4,5.48,4.3,9.75h-27.83Z" />
|
||||
<path className="cls-1" d="m525.46,132.61c-7.55,0-14.08,3.31-18.47,8.61-4.17-5.3-10.59-8.61-18.48-8.61-15.89,0-26.13,11.67-26.13,27.12v37.06h14.87v-37.41c0-6.83,4.62-11.55,11.27-11.55,9.8,0,10.81,8.13,10.81,11.55v37.41h14.87v-37.41c0-6.83,4.73-11.55,11.26-11.55,9.8,0,10.93,8.13,10.93,11.55v37.41h14.85v-37.06c0-15.93-9.56-27.12-25.79-27.12Z" />
|
||||
<path className="cls-1" d="m833.71,124.7l-.02,17.43c-1.81-2.92-4.17-5.2-7.08-6.83-2.9-1.64-6.23-2.47-9.98-2.47-8.13,0-14.6,3.03-19.46,9.06-4.86,6.05-7.29,14.77-7.29,25.31,0,9.15,2.47,16.65,7.4,22.49,4.93,5.83,14.6,8.39,23.19,8.39,29.95,0,29.6-25.68,29.6-25.68v-59.11s-16.37-1.75-16.37,11.41Zm-3.13,55.04c-2.37,3.4-5.86,5.1-10.43,5.1s-7.98-1.72-10.23-5.13c-2.25-3.43-3.37-8.41-3.37-14.11,0-5.3,1.1-9.72,3.31-13.29,2.21-3.57,5.67-5.36,10.4-5.36,3.1,0,5.82.98,8.17,2.94,3.81,3.25,5.73,9.09,5.73,16.64,0,5.4-1.2,9.81-3.58,13.21Z" />
|
||||
</g>
|
||||
<path className="cls-1" d="m496.75,221.66c-13.4-.63-20.16,2.56-24.57,5.93-6.09,4.65-9.8,11.53-9.8,22.52v56.51h7.88c2.11,0,4.22-.73,5.77-2.16,1.74-1.6,2.61-3.56,2.61-5.86v-21.12c1.92,3.31,4.45,5.74,7.65,7.32,3.03,1.41,6.53,2.12,10.51,2.12,7.49,0,13.64-2.98,18.41-8.97,4.78-6.15,7.17-14.15,7.17-24.06s-2.26-16.97-7.68-23.57c-4.38-5.34-11.04-8.35-17.94-8.66Zm5.55,46.38c-2.39,3.31-5.66,4.96-9.8,4.96-4.46,0-7.89-1.64-10.28-4.96-2.39-2.99-3.59-7.45-3.59-13.45,0-6.43,1.11-11.16,3.34-14.15,2.4-3.29,5.75-4.96,10.05-4.96s7.89,1.66,10.28,4.96c2.4,3.31,3.59,8.02,3.59,14.15,0,5.68-1.19,10.14-3.59,13.45Z" />
|
||||
<path className="cls-1" d="m636.47,227.49c-5.53-4.19-11.18-6.38-20.89-6.12-9.86.27-17.03,3.03-21.49,9.07-4.46,6.05-6.68,13.95-6.68,23.68,0,8.33,1.68,15.04,5.04,20.17,3.37,5.1,7.4,8.6,12.1,10.47,4.68,1.89,9.42,2.28,14.2,1.19,4.77-1.11,8.57-3.84,11.39-8.24v3.99c-.32,5.03-1.53,8.8-3.63,11.32-2.13,2.5-4.47,4.04-7.06,4.59-2.56.54-5.16.24-7.73-.95-2.59-1.17-4.5-2.87-5.75-5.06h-17.14c4.44,13.34,12.41,19.23,26.77,20.27,23.16,1.67,30.54-17.94,30.52-28.52v-33.25c0-10.99-3.58-18.03-9.63-22.63Zm-6.81,32.66c-.63,3.68-1.64,6.4-3.06,8.12-2.97,4.08-7.6,5.53-13.84,4.37-6.27-1.19-9.4-7.2-9.4-18.03,0-5.03.93-9.51,2.82-13.45,1.88-3.91,5.47-5.89,10.79-5.89,3.91,0,6.89,1.42,8.92,4.24,2.04,2.83,3.34,6.05,3.88,9.67.55,3.61.5,7.27-.12,10.96Z" />
|
||||
<path className="cls-1" d="m573.49,225.84c-5.29-2.67-11.34-4.03-18.15-4.03-10.47,0-17.85,2.73-22.15,8.19-2.7,3.48-4.22,7.94-4.58,13.36h15.65c.38-2.39,1.15-4.29,2.3-5.68,1.61-1.89,4.36-2.85,8.23-2.85,3.47,0,6.09.48,7.88,1.45,1.78.96,2.67,2.72,2.67,5.26,0,2.08-1.16,3.62-3.49,4.6-1.3.57-3.46,1.04-6.48,1.42l-5.54.67c-6.3.8-11.09,2.13-14.31,3.99-5.93,3.41-8.88,8.92-8.88,16.54,0,5.87,1.83,10.41,5.52,13.61,3.67,3.21,8.34,4.55,13.99,4.81,35.36,1.58,34.96-18.64,35.28-22.84v-23.27c0-7.46-2.63-12.54-7.92-15.24Zm-8.22,35.31c-.1,5.43-1.66,9.15-4.63,11.2-2.98,2.05-6.24,3.07-9.78,3.07-2.24,0-4.13-.63-5.7-1.85-1.56-1.23-2.34-3.23-2.34-6,0-3.1,1.28-5.39,3.83-6.87,1.52-.87,3.99-1.61,7.45-2.2l3.7-.68c1.84-.35,3.29-.72,4.33-1.12,1.07-.39,2.11-.91,3.14-1.56v6.03Z" />
|
||||
<path className="cls-1" d="m707.61,230.97c-5.22-6.54-13.14-9.81-23.76-9.81s-18.52,3.26-23.73,9.81c-5.22,6.53-7.83,14.24-7.83,23.15s2.61,16.8,7.83,23.25c5.21,6.42,13.13,9.64,23.73,9.64s18.53-3.22,23.76-9.64c5.21-6.45,7.81-14.19,7.81-23.25s-2.6-16.62-7.81-23.15Zm-12.93,37.46c-2.53,3.36-6.15,5.05-10.87,5.05s-8.36-1.69-10.91-5.05c-2.56-3.35-3.83-8.12-3.83-14.31s1.27-10.95,3.83-14.29c2.54-3.34,6.18-5.01,10.91-5.01s8.35,1.67,10.87,5.01c2.53,3.34,3.79,8.1,3.79,14.29s-1.26,10.96-3.79,14.31Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
689
Frontend/src/components/ModerationModal.tsx
Normal file
689
Frontend/src/components/ModerationModal.tsx
Normal file
@@ -0,0 +1,689 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { AdsV2Service } from '../services/ads.v2.service';
|
||||
import { AdminService } from '../services/admin.service';
|
||||
import { ChatService, type ChatMessage } from '../services/chat.service';
|
||||
import { getImageUrl, formatCurrency, parseUTCDate } from '../utils/app.utils';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import {
|
||||
VEHICLE_TYPES,
|
||||
AUTO_SEGMENTS,
|
||||
MOTO_SEGMENTS,
|
||||
AUTO_TRANSMISSIONS,
|
||||
MOTO_TRANSMISSIONS,
|
||||
FUEL_TYPES,
|
||||
VEHICLE_CONDITIONS,
|
||||
STEERING_TYPES
|
||||
} from '../constants/vehicleOptions';
|
||||
|
||||
interface Props {
|
||||
adSummary: any;
|
||||
onClose: () => void;
|
||||
onApprove: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function ModerationModal({ adSummary, onClose, onApprove }: Props) {
|
||||
const [fullAd, setFullAd] = useState<any>(null);
|
||||
const [brandName, setBrandName] = useState('');
|
||||
const [brands, setBrands] = useState<{ id: number, name: string }[]>([]);
|
||||
const [activePhoto, setActivePhoto] = useState(0);
|
||||
|
||||
// Chat State
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// Edit State
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<any>(null);
|
||||
|
||||
// Moderation State
|
||||
const [showRejectReason, setShowRejectReason] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
// Photo Management State
|
||||
const [photosToDelete, setPhotosToDelete] = useState<number[]>([]);
|
||||
const [newPhotos, setNewPhotos] = useState<File[]>([]);
|
||||
|
||||
const adminUser = AuthService.getCurrentUser();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 🟢 HELPER ROBUSTO PARA IDs: Busca en todas las variantes de casing posibles
|
||||
const getAdId = () => {
|
||||
if (!adSummary) return 0;
|
||||
return Number(adSummary.adID || adSummary.AdID || adSummary.adId || fullAd?.adID || 0);
|
||||
};
|
||||
|
||||
const getSellerId = () => {
|
||||
if (!adSummary) return 0;
|
||||
return Number(adSummary.userID || adSummary.UserID || adSummary.userId || fullAd?.userID || 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const currentAdId = getAdId();
|
||||
if (!currentAdId) {
|
||||
console.error("No se pudo determinar el AdID del resumen:", adSummary);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const adDetail = await AdsV2Service.getById(currentAdId);
|
||||
|
||||
// Normalización para asegurar nombres en minúscula (camelCase)
|
||||
const normalizedAd = {
|
||||
...adDetail,
|
||||
fuelType: adDetail.fuelType || adDetail.FuelType || '',
|
||||
transmission: adDetail.transmission || adDetail.Transmission || '',
|
||||
color: adDetail.color || adDetail.Color || '',
|
||||
segment: adDetail.segment || adDetail.Segment || '',
|
||||
condition: adDetail.condition || adDetail.Condition || '',
|
||||
steering: adDetail.steering || adDetail.Steering || '',
|
||||
location: adDetail.location || adDetail.Location || '',
|
||||
doorCount: adDetail.doorCount || adDetail.DoorCount
|
||||
};
|
||||
setFullAd(normalizedAd);
|
||||
setEditData(normalizedAd);
|
||||
|
||||
// ARGAR MARCAS Y SETEAR NOMBRE ACTUAL
|
||||
if (adDetail.vehicleTypeID) {
|
||||
const brandsList = await AdsV2Service.getBrands(adDetail.vehicleTypeID);
|
||||
setBrands(brandsList);
|
||||
|
||||
const currentBrand = brandsList.find((b: any) => b.id === adDetail.brandID);
|
||||
if (currentBrand) setBrandName(currentBrand.name);
|
||||
}
|
||||
|
||||
if (adminUser) loadChat();
|
||||
} catch (err) {
|
||||
console.error("Error cargando datos del aviso:", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(loadChat, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [adSummary, adminUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const loadChat = async () => {
|
||||
if (!adminUser) return;
|
||||
const currentAdId = getAdId();
|
||||
const sellerId = getSellerId();
|
||||
|
||||
if (!currentAdId || !sellerId) return;
|
||||
|
||||
try {
|
||||
const msgs = await ChatService.getConversation(currentAdId, adminUser.id, sellerId);
|
||||
setMessages(msgs);
|
||||
|
||||
// Marcar como leídos los mensajes que el Admin recibió y no ha leído
|
||||
const unreadMessages = msgs.filter(m => m.receiverID === adminUser.id && !m.isRead);
|
||||
|
||||
if (unreadMessages.length > 0) {
|
||||
// Disparamos la actualización para cada mensaje no leído
|
||||
unreadMessages.forEach(m => {
|
||||
if (m.messageID) {
|
||||
ChatService.markAsRead(m.messageID).catch(err => console.error("Error marcando leído:", err));
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim() || !adminUser) return;
|
||||
|
||||
const currentAdId = getAdId();
|
||||
const sellerId = getSellerId();
|
||||
|
||||
// 🟢 VALIDACIÓN PREVIA AL ENVÍO
|
||||
if (!currentAdId || !sellerId) {
|
||||
console.error("Datos faltantes para enviar mensaje:", { currentAdId, sellerId, adSummary });
|
||||
alert("Error: Faltan datos del aviso o vendedor para iniciar el chat.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
|
||||
const payload = {
|
||||
adID: currentAdId,
|
||||
senderID: adminUser.id,
|
||||
receiverID: sellerId,
|
||||
messageText: newMessage
|
||||
};
|
||||
|
||||
// Debug: Ver qué se envía exactamente
|
||||
console.log("Enviando mensaje:", payload);
|
||||
|
||||
try {
|
||||
await ChatService.sendMessage(payload);
|
||||
setNewMessage('');
|
||||
loadChat();
|
||||
} catch (error: any) {
|
||||
console.log("DETALLE DEL ERROR:", error.response?.data?.errors);
|
||||
|
||||
console.error("Error al enviar mensaje:", error.response?.data || error);
|
||||
alert("Error enviando mensaje. Revisa la consola.");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Lógica de Fotos ---
|
||||
const handleNewPhoto = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
const totalPhotos = (fullAd.photos.length - photosToDelete.length) + newPhotos.length + files.length;
|
||||
|
||||
if (totalPhotos > 5) {
|
||||
alert("El límite total es de 5 fotos.");
|
||||
return;
|
||||
}
|
||||
setNewPhotos([...newPhotos, ...files]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (!window.confirm("¿Confirmar todos los cambios (textos y fotos)?")) return;
|
||||
const currentAdId = getAdId();
|
||||
|
||||
try {
|
||||
// 1. Actualizar Textos
|
||||
const payload = {
|
||||
vehicleTypeID: editData.vehicleTypeID,
|
||||
brandID: editData.brandID,
|
||||
modelID: editData.modelID,
|
||||
versionName: editData.versionName,
|
||||
year: Number(editData.year) || 0,
|
||||
km: Number(editData.km) || 0,
|
||||
price: Number(editData.price) || 0,
|
||||
currency: editData.currency,
|
||||
description: editData.description,
|
||||
isFeatured: editData.isFeatured,
|
||||
contactPhone: editData.contactPhone,
|
||||
contactEmail: editData.contactEmail,
|
||||
displayContactInfo: editData.displayContactInfo,
|
||||
|
||||
fuelType: editData.fuelType,
|
||||
color: editData.color,
|
||||
segment: editData.segment,
|
||||
location: editData.location,
|
||||
condition: editData.condition,
|
||||
doorCount: editData.doorCount ? parseInt(editData.doorCount) : undefined,
|
||||
transmission: editData.transmission,
|
||||
steering: editData.steering
|
||||
};
|
||||
await AdsV2Service.update(currentAdId, payload);
|
||||
|
||||
// 2. Borrar Fotos Marcadas
|
||||
for (const photoId of photosToDelete) {
|
||||
await AdsV2Service.deletePhoto(photoId);
|
||||
}
|
||||
|
||||
// 3. Subir Nuevas Fotos
|
||||
if (newPhotos.length > 0) {
|
||||
await AdsV2Service.uploadPhotos(currentAdId, newPhotos);
|
||||
}
|
||||
|
||||
// 4. Refrescar Todo
|
||||
const updatedAd = await AdsV2Service.getById(currentAdId);
|
||||
setFullAd(updatedAd);
|
||||
setEditData(updatedAd);
|
||||
|
||||
// Resetear estados temporales
|
||||
setPhotosToDelete([]);
|
||||
setNewPhotos([]);
|
||||
setIsEditing(false);
|
||||
|
||||
alert("Aviso corregido exitosamente.");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
const serverMsg = error.response?.data?.message || (typeof error.response?.data === 'string' ? error.response.data : '');
|
||||
alert(`Error al guardar cambios: ${serverMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fullAd) return (
|
||||
<div className="fixed inset-0 z-[1000] bg-black/90 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[999999] flex items-start md:items-center justify-center bg-black/95 backdrop-blur-xl p-4 md:p-10 pt-24 md:pt-16 overflow-hidden">
|
||||
|
||||
<style>{`
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #333; border-radius: 10px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #555; }
|
||||
`}</style>
|
||||
|
||||
<div className="w-full max-w-[1440px] max-h-[85vh] md:max-h-[85vh] bg-[#0a0c10] border border-white/10 rounded-[1.5rem] shadow-[0_0_100px_rgba(0,0,0,0.8)] flex flex-col overflow-hidden relative">
|
||||
|
||||
{/* HEADER */}
|
||||
<div className="px-5 py-3 md:px-6 md:py-4 border-b border-white/10 flex justify-between items-center bg-[#12141a] shrink-0">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:gap-4 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-500/10 text-amber-500 border border-amber-500/20 px-2 py-0.5 rounded-lg text-[8px] md:text-[10px] font-black uppercase tracking-widest shrink-0">
|
||||
En Revisión
|
||||
</span>
|
||||
<h2 className="text-[11px] md:text-sm font-bold text-gray-400 shrink-0">ID #{fullAd.adID}</h2>
|
||||
</div>
|
||||
<p className="text-[9px] md:text-[10px] text-gray-500 md:mt-0">
|
||||
Usuario: <span className="text-white font-bold">{adSummary.userName}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
{!isEditing ? (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="bg-white/5 hover:bg-white/10 text-gray-300 px-3 md:px-4 py-1.5 md:py-2 rounded-xl text-[9px] md:text-[10px] font-black uppercase tracking-widest border border-white/10 transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<span className="text-xs">✏️</span> <span className="hidden md:inline">Editar</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { setIsEditing(false); setEditData(fullAd); setPhotosToDelete([]); setNewPhotos([]); }} className="text-red-400 hover:text-white px-3 py-2 text-[9px] md:text-[10px] font-black uppercase tracking-widest">
|
||||
Cancelar
|
||||
</button>
|
||||
<button onClick={handleSaveChanges} className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 md:py-2 rounded-xl text-[9px] md:text-[10px] font-black uppercase tracking-widest shadow-lg shadow-blue-600/20 transition-all">
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={onClose} className="w-8 h-8 md:w-9 md:h-9 rounded-full bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-all text-base md:text-lg font-bold shrink-0">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden min-h-0">
|
||||
|
||||
{/* IZQUIERDA: DETALLES (MÁS GRANDE) */}
|
||||
<div className="flex-[1.5] overflow-y-auto p-6 lg:p-10 border-r border-white/10 custom-scrollbar bg-gradient-to-b from-[#0a0c10] to-[#0f1115]">
|
||||
|
||||
{/* --- SECCIÓN GALERÍA --- */}
|
||||
<div className="mb-10">
|
||||
{!isEditing ? (
|
||||
// MODO LECTURA: Galería Normal
|
||||
<>
|
||||
<div className="aspect-video w-full rounded-2xl overflow-hidden bg-black border border-white/10 mb-4 relative shadow-2xl group flex items-center justify-center">
|
||||
<img src={getImageUrl(fullAd.photos?.[activePhoto]?.filePath)} className="max-h-full max-w-full object-contain transition-transform duration-700 group-hover:scale-105" alt="Review" />
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-4 scrollbar-hide no-scrollbar">
|
||||
{fullAd.photos?.map((p: any, idx: number) => (
|
||||
<button key={p.photoID} onClick={() => setActivePhoto(idx)} className={`w-24 h-16 rounded-xl overflow-hidden border-2 transition-all shrink-0 ${activePhoto === idx ? 'border-blue-500 opacity-100 shadow-lg scale-105' : 'border-white/10 opacity-40 hover:opacity-100'}`}>
|
||||
<img src={getImageUrl(p.filePath)} className="w-full h-full object-cover" alt="" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// MODO EDICIÓN: Gestión de Fotos
|
||||
<div className="bg-white/5 p-5 rounded-2xl border border-blue-500/30">
|
||||
<h4 className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-4">Gestión de Fotos</h4>
|
||||
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
||||
{/* Fotos Existentes */}
|
||||
{fullAd.photos.filter((p: any) => !photosToDelete.includes(p.photoID)).map((p: any) => (
|
||||
<div key={p.photoID} className="relative group aspect-square rounded-xl overflow-hidden border border-white/10">
|
||||
<img src={getImageUrl(p.filePath)} className="w-full h-full object-cover opacity-70 group-hover:opacity-100 transition-opacity" alt="" />
|
||||
<button
|
||||
onClick={() => setPhotosToDelete([...photosToDelete, p.photoID])}
|
||||
className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center text-red-500 font-bold transition-opacity"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Fotos Nuevas */}
|
||||
{newPhotos.map((file: File, idx: number) => (
|
||||
<div key={idx} className="relative group aspect-square rounded-xl overflow-hidden border border-green-500/50">
|
||||
<img src={URL.createObjectURL(file)} className="w-full h-full object-cover" alt="new" />
|
||||
<button
|
||||
onClick={() => setNewPhotos(newPhotos.filter((_: File, i: number) => i !== idx))}
|
||||
className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white font-bold transition-opacity"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<span className="absolute bottom-1 right-1 bg-green-500 text-black text-[8px] font-bold px-1.5 rounded">NUEVA</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Botón Agregar */}
|
||||
<label className="aspect-square rounded-xl border-2 border-dashed border-white/20 hover:border-blue-500 hover:bg-blue-500/10 flex flex-col items-center justify-center cursor-pointer transition-all">
|
||||
<span className="text-2xl mb-1">+</span>
|
||||
<span className="text-[8px] font-bold uppercase text-gray-400">Agregar</span>
|
||||
<input type="file" multiple accept="image/*" className="hidden" onChange={handleNewPhoto} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[9px] text-gray-500 mt-3 text-center italic">Máximo 5 fotos en total.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-10">
|
||||
<div className="flex flex-col sm:flex-row gap-8 sm:items-end border-b border-white/5 pb-10">
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-gray-500 font-black uppercase mb-3 block tracking-[0.2em]">Título / Versión</label>
|
||||
{isEditing ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<select
|
||||
value={editData.brandID}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newId = Number(e.target.value);
|
||||
setEditData({ ...editData, brandID: newId });
|
||||
const b = brands.find((x: any) => x.id === newId);
|
||||
if (b) setBrandName(b.name);
|
||||
}}
|
||||
className="bg-white/5 border border-blue-500/50 rounded-xl px-4 py-3 text-lg font-black text-blue-500 outline-none focus:bg-white/10 appearance-none cursor-pointer text-center min-w-[140px]"
|
||||
>
|
||||
{brands.map((b: any) => (
|
||||
<option key={b.id} value={b.id} className="bg-[#1a1d24] text-white">
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={editData.versionName}
|
||||
onChange={e => setEditData({ ...editData, versionName: e.target.value })}
|
||||
className="flex-1 bg-white/5 border border-blue-500/50 rounded-xl px-4 py-3 text-lg font-black text-white outline-none focus:bg-white/10"
|
||||
placeholder="Modelo/Versión"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-blue-400 font-black uppercase text-[12px] block mb-2 tracking-[0.3em]">{brandName}</span>
|
||||
<h1 className="text-4xl md:text-5xl font-black uppercase tracking-tighter text-white leading-none">{fullAd.versionName}</h1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<label className="text-[10px] text-gray-500 font-black uppercase mb-3 block tracking-[0.2em]">Precio Final</label>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2 bg-white/5 border border-blue-500/50 rounded-xl px-4 py-2">
|
||||
<span className="text-blue-400 font-black text-lg">{editData.currency}</span>
|
||||
<input type="number" value={editData.price} onChange={e => setEditData({ ...editData, price: e.target.value })} className="bg-transparent border-none py-1 text-2xl font-black text-white text-right outline-none w-40" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-4xl md:text-5xl text-white font-black tracking-tighter">{formatCurrency(fullAd.price, fullAd.currency)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<InputOrBadge label="Año" isEditing={isEditing} value={editData.year} onChange={(v: string) => setEditData({ ...editData, year: v })} type="number" />
|
||||
<InputOrBadge label="Kilómetros" isEditing={isEditing} value={editData.km} onChange={(v: string) => setEditData({ ...editData, km: v })} type="number" />
|
||||
|
||||
{/* Nuevos Campos */}
|
||||
{isEditing ? (
|
||||
<div className="bg-white/5 p-3 rounded-xl border border-white/10">
|
||||
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1 block">Combustible</label>
|
||||
<select value={editData.fuelType || ''} onChange={e => setEditData({ ...editData, fuelType: e.target.value })} className="w-full bg-black/20 text-white text-xs p-1 rounded outline-none border border-white/10">
|
||||
<option value="">Seleccionar</option>
|
||||
{FUEL_TYPES.map(o => <option key={o} value={o} className="bg-gray-900">{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<DataBadge label="Combustible" value={editData.fuelType} />
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="bg-white/5 p-3 rounded-xl border border-white/10">
|
||||
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1 block">Transmisión</label>
|
||||
<select value={editData.transmission || ''} onChange={e => setEditData({ ...editData, transmission: e.target.value })} className="w-full bg-black/20 text-white text-xs p-1 rounded outline-none border border-white/10">
|
||||
<option value="">Seleccionar</option>
|
||||
{(fullAd.vehicleTypeID === VEHICLE_TYPES.MOTOS ? MOTO_TRANSMISSIONS : AUTO_TRANSMISSIONS).map(o => (
|
||||
<option key={o} value={o} className="bg-gray-900">{o}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<DataBadge label="Transmisión" value={editData.transmission} />
|
||||
)}
|
||||
|
||||
<InputOrBadge label="Color" isEditing={isEditing} value={editData.color} onChange={(v: string) => setEditData({ ...editData, color: v })} />
|
||||
<InputOrBadge label="Ubicación" isEditing={isEditing} value={editData.location} onChange={(v: string) => setEditData({ ...editData, location: v })} />
|
||||
{isEditing ? (
|
||||
<div className="bg-white/5 p-3 rounded-xl border border-white/10">
|
||||
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1 block">Segmento</label>
|
||||
<select value={editData.segment || ''} onChange={e => setEditData({ ...editData, segment: e.target.value })} className="w-full bg-black/20 text-white text-xs p-1 rounded outline-none border border-white/10">
|
||||
<option value="">Seleccionar</option>
|
||||
{(fullAd.vehicleTypeID === VEHICLE_TYPES.MOTOS ? MOTO_SEGMENTS : AUTO_SEGMENTS).map(o => (
|
||||
<option key={o} value={o} className="bg-gray-900">{o}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<DataBadge label="Segmento" value={editData.segment} />
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="bg-white/5 p-3 rounded-xl border border-white/10">
|
||||
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1 block">Estado</label>
|
||||
<select value={editData.condition || ''} onChange={e => setEditData({ ...editData, condition: e.target.value })} className="w-full bg-black/20 text-white text-xs p-1 rounded outline-none border border-white/10">
|
||||
<option value="">Seleccionar</option>
|
||||
{VEHICLE_CONDITIONS.map(o => <option key={o} value={o} className="bg-gray-900">{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<DataBadge label="Estado" value={editData.condition} />
|
||||
)}
|
||||
|
||||
{(isEditing || editData.doorCount) && fullAd.vehicleTypeID !== VEHICLE_TYPES.MOTOS && (
|
||||
<InputOrBadge label="Puertas" isEditing={isEditing} value={editData.doorCount || ''} onChange={(v: string) => setEditData({ ...editData, doorCount: v })} type="number" />
|
||||
)}
|
||||
|
||||
{fullAd.vehicleTypeID !== VEHICLE_TYPES.MOTOS && (
|
||||
isEditing ? (
|
||||
<div className="bg-white/5 p-3 rounded-xl border border-white/10">
|
||||
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1 block">Dirección</label>
|
||||
<select value={editData.steering || ''} onChange={e => setEditData({ ...editData, steering: e.target.value })} className="w-full bg-black/20 text-white text-xs p-1 rounded outline-none border border-white/10">
|
||||
<option value="">Seleccionar</option>
|
||||
{STEERING_TYPES.map(o => <option key={o} value={o} className="bg-gray-900">{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<DataBadge label="Dirección" value={editData.steering} />
|
||||
)
|
||||
)}
|
||||
<DataBadge label="Categoría" value={fullAd.vehicleTypeID === 1 ? 'Automóvil' : 'Moto'} />
|
||||
<DataBadge label="Destacado" value={fullAd.isFeatured ? 'SÍ ⭐' : 'NO'} highlight={fullAd.isFeatured} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 p-5 rounded-2xl border border-white/5">
|
||||
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-2 block">Descripción</label>
|
||||
{isEditing ? (
|
||||
<textarea value={editData.description} onChange={e => setEditData({ ...editData, description: e.target.value })} className="w-full h-24 bg-black/20 border border-blue-500/50 rounded-xl p-3 text-xs text-white outline-none focus:bg-black/40 resize-none leading-relaxed" />
|
||||
) : (
|
||||
<p className="text-xs text-gray-300 leading-relaxed whitespace-pre-line font-light">{fullAd.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-900/10 p-5 rounded-2xl border border-blue-500/20">
|
||||
<h4 className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
👤 Datos de Contacto
|
||||
<span className={`px-2 py-0.5 rounded text-[8px] border ${fullAd.displayContactInfo ? 'bg-green-500/20 text-green-400 border-green-500/30' : 'bg-red-500/20 text-red-400 border-red-500/30'}`}>
|
||||
{fullAd.displayContactInfo ? 'VISIBLE AL PÚBLICO' : 'OCULTO'}
|
||||
</span>
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-[9px] uppercase text-gray-500 font-bold block mb-1">Email</span>
|
||||
{isEditing ? (
|
||||
<input type="text" value={editData.contactEmail} onChange={e => setEditData({ ...editData, contactEmail: e.target.value })} className="w-full bg-black/20 border border-blue-500/50 rounded-lg p-2 text-xs text-white outline-none focus:bg-black/40" />
|
||||
) : (
|
||||
<span className="text-white text-sm font-medium break-all">{fullAd.contactEmail}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[9px] uppercase text-gray-500 font-bold block mb-1">Teléfono</span>
|
||||
{isEditing ? (
|
||||
<input type="text" value={editData.contactPhone} onChange={e => setEditData({ ...editData, contactPhone: e.target.value })} className="w-full bg-black/20 border border-blue-500/50 rounded-lg p-2 text-xs text-white outline-none focus:bg-black/40" />
|
||||
) : (
|
||||
<span className="text-white text-sm font-medium">{fullAd.contactPhone}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DERECHA: CHAT Y ACCIONES (NUEVO COMPORTAMIENTO VERTICAL EN MÓVIL) */}
|
||||
<div className="w-full lg:w-[420px] h-[350px] lg:h-full bg-[#161a22] flex flex-col border-l border-white/10 shrink-0">
|
||||
<div className="p-5 bg-[#1a1d24] border-b border-white/5 shadow-sm z-10 shrink-0 flex justify-between items-center">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-2">
|
||||
<span className="text-blue-400">💬</span> Chat con Vendedor
|
||||
</h3>
|
||||
{messages.some(m => !m.isRead && m.receiverID === adminUser?.id) && (
|
||||
<span className="bg-blue-500 text-white text-[8px] px-2 py-0.5 rounded-full animate-pulse">NUEVOS</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-5 space-y-4 custom-scrollbar bg-[#161a22] min-h-0">
|
||||
{messages.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center opacity-20 p-10 text-center">
|
||||
<span className="text-4xl mb-3">💬</span>
|
||||
<p className="text-[10px] uppercase font-black tracking-widest">Sin mensajes aún</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m: ChatMessage) => {
|
||||
const isAdminMsg = m.senderID === adminUser?.id;
|
||||
return (
|
||||
<div key={m.messageID} className={`flex flex-col ${isAdminMsg ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`max-w-[85%] p-3 rounded-2xl text-xs leading-relaxed shadow-lg ${isAdminMsg ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-[#252830] text-gray-200 rounded-tl-none border border-white/5'}`}>
|
||||
{m.messageText}
|
||||
</div>
|
||||
<span className="text-[8px] text-gray-600 mt-1.5 font-bold px-1 uppercase tracking-tighter">
|
||||
{isAdminMsg ? 'ADMINISTRADOR' : 'VENDEDOR'} • {parseUTCDate(m.sentAt!).toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* PANEL DE ACCIONES Y MENSAJERÍA */}
|
||||
<div className="shrink-0 bg-[#0a0c10] border-t border-white/10">
|
||||
{/* Formulario de Mensaje */}
|
||||
<div className="p-4 border-b border-white/5">
|
||||
<form onSubmit={handleSendMessage} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={e => setNewMessage(e.target.value)}
|
||||
placeholder="Escribir al vendedor..."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 focus:bg-white/10 transition-all"
|
||||
/>
|
||||
<button
|
||||
disabled={sending}
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white w-11 h-11 rounded-xl transition-all disabled:opacity-50 flex items-center justify-center shadow-lg active:scale-95"
|
||||
>
|
||||
<span className="text-lg">➤</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Botonera de Moderación */}
|
||||
<div className="p-5 space-y-4">
|
||||
{showRejectReason ? (
|
||||
<div className="space-y-3 animate-fade-in">
|
||||
<label className="text-[9px] font-black text-red-400 uppercase tracking-widest block ml-1">Motivo del Rechazo</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={rejectReason}
|
||||
onChange={e => setRejectReason(e.target.value)}
|
||||
placeholder="Ej: Fotos de baja calidad, precio irreal..."
|
||||
className="w-full h-24 bg-red-500/5 border border-red-500/30 rounded-xl p-3 text-xs text-white outline-none focus:border-red-500 focus:bg-red-500/10 transition-all resize-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowRejectReason(false)}
|
||||
className="flex-1 text-gray-500 hover:text-white text-[10px] font-black uppercase tracking-widest py-3"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
disabled={!rejectReason.trim()}
|
||||
onClick={() => {
|
||||
// Usamos AdminService directamente
|
||||
AdminService.rejectAd(getAdId(), rejectReason)
|
||||
.then(() => {
|
||||
alert("Aviso rechazado correctamente.");
|
||||
onClose();
|
||||
})
|
||||
.catch((err: any) => alert("Error al rechazar: " + (err.response?.data || "Error desconocido")));
|
||||
}}
|
||||
className="flex-[2] bg-red-600 hover:bg-red-500 text-white py-3 rounded-xl font-black uppercase tracking-widest text-[10px] shadow-lg shadow-red-600/20 disabled:opacity-30"
|
||||
>
|
||||
Confirmar Rechazo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setShowRejectReason(true)}
|
||||
className="bg-white/5 hover:bg-red-600/20 text-gray-500 hover:text-red-400 border border-white/10 hover:border-red-500/30 py-4 rounded-2xl font-black uppercase tracking-widest text-[10px] transition-all flex flex-col items-center gap-1"
|
||||
>
|
||||
<span className="text-lg">✕</span>
|
||||
Rechazar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm("¿Aprobar este aviso?")) {
|
||||
onApprove(getAdId());
|
||||
}
|
||||
}}
|
||||
className="bg-green-600 hover:bg-green-500 text-white py-4 rounded-2xl font-black uppercase tracking-widest text-[10px] transition-all shadow-lg shadow-green-600/20 flex flex-col items-center gap-1 active:scale-[0.98]"
|
||||
>
|
||||
<span className="text-lg">✓</span>
|
||||
Aprobar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
interface InputBadgeProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
isEditing: boolean;
|
||||
onChange: (val: string) => void;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function InputOrBadge({ label, value, isEditing, onChange, type = "text" }: InputBadgeProps) {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="p-2.5 rounded-xl border border-blue-500/50 bg-white/5 flex flex-col justify-center">
|
||||
<span className="text-[8px] font-black uppercase tracking-widest mb-1 text-blue-400">{label}</span>
|
||||
<input type={type} value={value ?? ''} onChange={e => onChange(e.target.value)} className="bg-transparent text-white text-sm font-bold w-full outline-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <DataBadge label={label} value={value} />;
|
||||
}
|
||||
|
||||
function DataBadge({ label, value, highlight = false }: { label: string, value: string | number, highlight?: boolean }) {
|
||||
return (
|
||||
<div className={`p-2.5 rounded-xl border flex flex-col justify-center ${highlight ? 'bg-blue-600/10 border-blue-500/30' : 'bg-white/5 border-white/5'}`}>
|
||||
<span className={`text-[8px] font-black uppercase tracking-widest mb-1 ${highlight ? 'text-blue-400' : 'text-gray-500'}`}>{label}</span>
|
||||
<span className="text-white text-sm font-bold truncate">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
Frontend/src/components/SearchableSelect.tsx
Normal file
98
Frontend/src/components/SearchableSelect.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface Option {
|
||||
id: number | string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: Option[];
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function SearchableSelect({ options, value, onChange, placeholder = "Seleccionar...", disabled = false }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Cerrar al hacer clic fuera
|
||||
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);
|
||||
}, []);
|
||||
|
||||
// Encontrar la opción seleccionada para mostrar su nombre
|
||||
const selectedOption = options.find(o => String(o.id) === String(value));
|
||||
|
||||
const filteredOptions = options.filter(option =>
|
||||
option.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={wrapperRef}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setIsOpen(!isOpen);
|
||||
setSearch(''); // Limpiar búsqueda al abrir
|
||||
}
|
||||
}}
|
||||
className={`w-full bg-white/5 border rounded-xl px-4 py-3 text-left text-sm flex justify-between items-center transition-all ${isOpen ? 'border-blue-500 ring-1 ring-blue-500' : 'border-white/10 hover:border-white/20'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={selectedOption ? 'text-white' : 'text-gray-500'}>
|
||||
{selectedOption ? selectedOption.name : placeholder}
|
||||
</span>
|
||||
<span className="text-gray-500 text-xs">▼</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-[#1a1d24] border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
{/* Buscador interno */}
|
||||
<div className="p-2 border-b border-white/5 sticky top-0 bg-[#1a1d24]">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar..."
|
||||
className="w-full bg-black/20 border border-white/5 rounded-lg px-3 py-2 text-xs text-white outline-none focus:border-blue-500/50"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul className="max-h-60 overflow-y-auto scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<li
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
onChange(String(option.id));
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`px-4 py-3 text-sm cursor-pointer hover:bg-blue-600 hover:text-white transition-colors border-b border-white/5 last:border-0 ${String(option.id) === String(value) ? 'bg-blue-600/20 text-blue-400 font-bold' : 'text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{option.name}
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="px-4 py-3 text-xs text-gray-500 text-center italic">
|
||||
No hay resultados
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
Frontend/src/components/UserModal.tsx
Normal file
151
Frontend/src/components/UserModal.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AdminService } from '../services/admin.service';
|
||||
|
||||
interface Props {
|
||||
userId: number;
|
||||
onClose: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export default function UserModal({ userId, onClose, onUpdate }: Props) {
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editData, setEditData] = useState({
|
||||
userName: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phoneNumber: '',
|
||||
userType: 1
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [userId]);
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const res = await AdminService.getUserById(userId);
|
||||
setUser(res);
|
||||
setEditData({
|
||||
userName: res.userName || '',
|
||||
firstName: res.firstName || '',
|
||||
lastName: res.lastName || '',
|
||||
phoneNumber: res.phoneNumber || '',
|
||||
userType: res.userType
|
||||
});
|
||||
} catch (err) {
|
||||
alert('Error al cargar datos del usuario');
|
||||
onClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await AdminService.updateUser(userId, editData);
|
||||
alert('Usuario actualizado correctamente');
|
||||
onUpdate();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || 'Error al actualizar usuario');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2000] flex items-start md:items-center justify-center bg-black/80 backdrop-blur-sm p-4 pt-24 md:pt-8 animate-fade-in overflow-hidden">
|
||||
<div className="absolute inset-0" onClick={onClose}></div>
|
||||
|
||||
<div className="relative w-full max-w-2xl max-h-[85vh] md:max-h-[90vh] bg-[#0a0c10] border border-white/10 rounded-[2rem] md:rounded-[2.5rem] shadow-2xl flex flex-col overflow-hidden animate-scale-up">
|
||||
<div className="px-6 md:px-8 py-5 md:py-6 border-b border-white/10 bg-[#12141a] flex justify-between items-center shrink-0">
|
||||
<div>
|
||||
<h2 className="text-xl font-black uppercase tracking-tight text-white mb-1">Editar Usuario</h2>
|
||||
<p className="text-[9px] md:text-[10px] text-gray-500 uppercase tracking-widest font-bold">ID #{userId} • {user.email}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="w-9 h-9 md:w-10 md:h-10 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-all shrink-0">✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 space-y-6 custom-scrollbar">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 md:gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Nombre de Usuario</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.userName}
|
||||
onChange={e => setEditData({ ...editData, userName: 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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Tipo de Usuario</label>
|
||||
<select
|
||||
value={editData.userType}
|
||||
onChange={e => setEditData({ ...editData, userType: parseInt(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 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value={1} className="bg-gray-900">Particular (User)</option>
|
||||
<option value={3} className="bg-gray-900">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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={editData.firstName}
|
||||
onChange={e => setEditData({ ...editData, 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"
|
||||
/>
|
||||
</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={editData.lastName}
|
||||
onChange={e => setEditData({ ...editData, 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"
|
||||
/>
|
||||
</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</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editData.phoneNumber}
|
||||
onChange={e => setEditData({ ...editData, 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-white/5 flex flex-col md:flex-row gap-3 md:gap-4 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="order-2 md:order-1 flex-1 bg-white/5 hover:bg-white/10 text-gray-400 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="order-1 md:order-2 flex-1 md:flex-[2] 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>
|
||||
);
|
||||
}
|
||||
117
Frontend/src/components/VisualCreditCard.tsx
Normal file
117
Frontend/src/components/VisualCreditCard.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
// src/components/VisualCreditCard.tsx
|
||||
|
||||
interface Props {
|
||||
cardNumber: string;
|
||||
cardholderName: string;
|
||||
cardExpirationMonth: string;
|
||||
cardExpirationYear: string;
|
||||
cvc: string;
|
||||
isFlipped: boolean;
|
||||
}
|
||||
|
||||
const getCardBrand = (cardNumber: string) => {
|
||||
const firstDigit = cardNumber.charAt(0);
|
||||
if (firstDigit === '4') return 'VISA';
|
||||
if (firstDigit === '5') return 'MASTER';
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function VisualCreditCard({ cardNumber, cardholderName, cardExpirationMonth, cardExpirationYear, cvc, isFlipped }: Props) {
|
||||
const brand = getCardBrand(cardNumber.replace(/\s/g, ''));
|
||||
// Máscara simple para mostrar solo los últimos 4 si hay datos, o placeholder
|
||||
const formattedNumber = cardNumber || "#### #### #### ####";
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm mx-auto aspect-[1.586] perspective-[1200px]">
|
||||
<div
|
||||
className={`relative w-full h-full transition-all duration-700 [transform-style:preserve-3d] shadow-2xl rounded-2xl ${isFlipped ? '[transform:rotateY(180deg)]' : ''
|
||||
}`}
|
||||
>
|
||||
{/* --- CARA FRONTAL --- */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full [backface-visibility:hidden] rounded-2xl p-6 flex flex-col justify-between bg-[#161a22] border border-white/10 shadow-inner overflow-hidden"
|
||||
style={{ transform: 'translateZ(1px)' }}
|
||||
>
|
||||
{/* Decoración de fondo */}
|
||||
<div className="absolute top-0 right-0 -mr-10 -mt-10 w-40 h-40 bg-white/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-0 left-0 -ml-10 -mb-10 w-40 h-40 bg-blue-500/10 rounded-full blur-3xl"></div>
|
||||
|
||||
<div className="flex justify-between items-start relative z-10">
|
||||
{/* Chip */}
|
||||
<div className="w-12 h-9 bg-yellow-500 rounded-md border border-yellow-600/50 flex items-center justify-center overflow-hidden">
|
||||
<div className="w-full h-px bg-yellow-600/50 absolute top-1/3"></div>
|
||||
<div className="w-full h-px bg-yellow-600/50 absolute bottom-1/3"></div>
|
||||
<div className="h-full w-px bg-yellow-600/50 absolute left-1/3"></div>
|
||||
<div className="h-full w-px bg-yellow-600/50 absolute right-1/3"></div>
|
||||
</div>
|
||||
|
||||
{/* Marca */}
|
||||
{brand ? (
|
||||
<div className="text-2xl font-black text-white italic tracking-tighter opacity-90">
|
||||
{brand === 'VISA' && 'VISA'}
|
||||
{brand === 'MASTER' && (
|
||||
<div className="flex relative">
|
||||
<div className="w-8 h-8 bg-red-500/90 rounded-full"></div>
|
||||
<div className="w-8 h-8 bg-yellow-500/90 rounded-full -ml-3"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-white/20 font-black text-lg tracking-widest uppercase">Brand</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NÚMERO DE TARJETA - Ajustado tamaño para evitar truncate */}
|
||||
<div className="text-white font-mono text-[19px] sm:text-[21px] tracking-[0.12em] text-center drop-shadow-md relative z-10 w-full mt-2 whitespace-nowrap">
|
||||
{formattedNumber}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-end text-gray-300 text-[9px] uppercase font-bold tracking-widest relative z-10">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[7px] text-gray-500 mb-0.5">Titular</span>
|
||||
<span className="text-xs sm:text-sm text-white tracking-wider truncate max-w-[150px] sm:max-w-[180px]">
|
||||
{cardholderName || 'NOMBRE APELLIDO'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[7px] text-gray-500 mb-0.5">Vence</span>
|
||||
<span className="text-xs sm:text-sm text-white">
|
||||
{cardExpirationMonth || 'MM'}/{cardExpirationYear || 'AA'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- CARA TRASERA --- */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full [backface-visibility:hidden] [transform:rotateY(180deg)] rounded-2xl overflow-hidden bg-[#161a22] border border-white/10"
|
||||
style={{ transform: 'rotateY(180deg) translateZ(1px)' }}
|
||||
>
|
||||
{/* Banda Magnética */}
|
||||
<div className="w-full h-12 bg-black mt-6 relative">
|
||||
<div className="w-full h-full bg-gray-900 opacity-90"></div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 mt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 h-10 bg-gray-300/20 rounded opacity-50"></div>
|
||||
|
||||
<div className="w-16 h-10 bg-white text-black font-mono font-bold text-lg flex items-center justify-center italic rounded transform -skew-x-6 border-2 border-gray-300 tracking-widest">
|
||||
{cvc || '***'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-between items-center">
|
||||
<div className="h-2 w-24 bg-white/20 rounded-full"></div>
|
||||
<div className="text-[8px] text-white uppercase tracking-widest">Código de Seguridad</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 right-6 opacity-30">
|
||||
{brand === 'VISA' && <span className="text-white font-black italic text-xl [transform:scale(-1,1)]">VISA</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
Frontend/src/constants/adStatuses.ts
Normal file
86
Frontend/src/constants/adStatuses.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export const AD_STATUSES = {
|
||||
DRAFT: 1,
|
||||
PAYMENT_PENDING: 2,
|
||||
MODERATION_PENDING: 3,
|
||||
ACTIVE: 4,
|
||||
REJECTED: 5,
|
||||
PAUSED: 6,
|
||||
SOLD: 7,
|
||||
EXPIRED: 8,
|
||||
DELETED: 9,
|
||||
RESERVED: 10
|
||||
};
|
||||
|
||||
// Estilos de alto contraste para sobreponer en fotos
|
||||
export const STATUS_CONFIG: Record<number, { label: string; color: string; bg: string; border: string; icon: string }> = {
|
||||
[AD_STATUSES.ACTIVE]: {
|
||||
label: 'Activo',
|
||||
color: 'text-white',
|
||||
bg: 'bg-green-600/90',
|
||||
border: 'border-green-400/50',
|
||||
icon: '✅'
|
||||
},
|
||||
[AD_STATUSES.PAUSED]: {
|
||||
label: 'Pausado',
|
||||
color: 'text-white',
|
||||
bg: 'bg-amber-600/90',
|
||||
border: 'border-amber-400/50',
|
||||
icon: '⏸️'
|
||||
},
|
||||
[AD_STATUSES.RESERVED]: {
|
||||
label: 'Reservado',
|
||||
color: 'text-white',
|
||||
bg: 'bg-blue-600/90',
|
||||
border: 'border-blue-400/50',
|
||||
icon: '🔐'
|
||||
},
|
||||
[AD_STATUSES.SOLD]: {
|
||||
label: 'Vendido',
|
||||
color: 'text-white',
|
||||
bg: 'bg-gray-800/90',
|
||||
border: 'border-gray-500/50',
|
||||
icon: '🤝'
|
||||
},
|
||||
[AD_STATUSES.MODERATION_PENDING]: {
|
||||
label: 'En Revisión',
|
||||
color: 'text-white',
|
||||
bg: 'bg-indigo-600/90',
|
||||
border: 'border-indigo-400/50',
|
||||
icon: '⏳'
|
||||
},
|
||||
[AD_STATUSES.REJECTED]: {
|
||||
label: 'Rechazado',
|
||||
color: 'text-white',
|
||||
bg: 'bg-red-600/90',
|
||||
border: 'border-red-400/50',
|
||||
icon: '✕'
|
||||
},
|
||||
[AD_STATUSES.DRAFT]: {
|
||||
label: 'Borrador',
|
||||
color: 'text-white',
|
||||
bg: 'bg-gray-600/90',
|
||||
border: 'border-gray-400/50',
|
||||
icon: '📝'
|
||||
},
|
||||
[AD_STATUSES.DELETED]: {
|
||||
label: 'Eliminar',
|
||||
color: 'text-white',
|
||||
bg: 'bg-red-700/90',
|
||||
border: 'border-red-500/50',
|
||||
icon: '🗑️'
|
||||
},
|
||||
[AD_STATUSES.EXPIRED]: {
|
||||
label: 'Vencido',
|
||||
color: 'text-white',
|
||||
bg: 'bg-stone-700/90',
|
||||
border: 'border-stone-500/50',
|
||||
icon: '⏰'
|
||||
},
|
||||
[AD_STATUSES.PAYMENT_PENDING]: {
|
||||
label: 'Pago Pendiente',
|
||||
color: 'text-white',
|
||||
bg: 'bg-yellow-600/90',
|
||||
border: 'border-yellow-400/50',
|
||||
icon: '💳'
|
||||
}
|
||||
};
|
||||
76
Frontend/src/constants/vehicleOptions.ts
Normal file
76
Frontend/src/constants/vehicleOptions.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export const VEHICLE_TYPES = {
|
||||
AUTOS: 1,
|
||||
MOTOS: 2
|
||||
};
|
||||
|
||||
export const AUTO_SEGMENTS = [
|
||||
'Hatchback',
|
||||
'Sedán',
|
||||
'SUV',
|
||||
'Crossover',
|
||||
'Pick-up',
|
||||
'Coupé',
|
||||
'Convertible',
|
||||
'Minivan',
|
||||
'Familiar / Rural',
|
||||
'Todoterreno',
|
||||
'Van (Comercial)'
|
||||
];
|
||||
|
||||
export const MOTO_SEGMENTS = [
|
||||
'Calle / Naked',
|
||||
'Clásica / Colección',
|
||||
'Triciclo / Cuatriciclo',
|
||||
'Custom / Chopper',
|
||||
'Enduro (Off-Road-Motocross) / Cross / Trial',
|
||||
'Pista / Carenadas / Sport',
|
||||
'Ciclomotor / Scooter',
|
||||
'Touring / Trail',
|
||||
'Kartings'
|
||||
];
|
||||
|
||||
export const AUTO_TRANSMISSIONS = [
|
||||
'Manual',
|
||||
'Automática',
|
||||
'Continuamente Variable (CVT)',
|
||||
'Doble Embrague (DSG o DGT)',
|
||||
'Manual Automatizada (AMT)',
|
||||
'Manual Secuencial',
|
||||
'Electrónica Variable (EVT)',
|
||||
'Hidráulica',
|
||||
'Sin Especificar'
|
||||
];
|
||||
|
||||
export const MOTO_TRANSMISSIONS = [
|
||||
'Manual',
|
||||
'Automática',
|
||||
'Sin Transmisión (Eléctrica)',
|
||||
'Sin Especificar'
|
||||
];
|
||||
|
||||
export const FUEL_TYPES = [
|
||||
'Nafta',
|
||||
'Diesel',
|
||||
'GNC',
|
||||
'Nafta/GNC',
|
||||
'Eléctrico',
|
||||
'Hidrógeno',
|
||||
'Sin Especificar'
|
||||
];
|
||||
|
||||
export const VEHICLE_CONDITIONS = [
|
||||
'0 Kilómetro',
|
||||
'Excelente',
|
||||
'Muy Bueno',
|
||||
'Bueno',
|
||||
'Regular',
|
||||
'Para Repuestos'
|
||||
];
|
||||
|
||||
export const STEERING_TYPES = [
|
||||
'Asistida',
|
||||
'Hidráulica',
|
||||
'Eléctrica',
|
||||
'Mecánica',
|
||||
'Sin Especificar'
|
||||
];
|
||||
92
Frontend/src/context/AuthContext.tsx
Normal file
92
Frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { AuthService, type UserSession } from '../services/auth.service';
|
||||
import { ChatService } from '../services/chat.service';
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserSession | null;
|
||||
loading: boolean;
|
||||
unreadCount: number;
|
||||
login: (user: UserSession) => void;
|
||||
logout: () => void;
|
||||
refreshSession: () => Promise<void>;
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<UserSession | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
const currentUser = AuthService.getCurrentUser();
|
||||
if (currentUser) {
|
||||
try {
|
||||
const count = await ChatService.getUnreadCount(currentUser.id);
|
||||
setUnreadCount(count);
|
||||
} catch {
|
||||
setUnreadCount(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Verificar sesión al cargar la app (Solo una vez)
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const sessionUser = await AuthService.checkSession();
|
||||
if (sessionUser) {
|
||||
setUser(sessionUser);
|
||||
await fetchUnreadCount(); // <--- 5. LLAMAR AL CARGAR LA APP
|
||||
} else {
|
||||
setUser(null);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
} catch (error) {
|
||||
setUser(null);
|
||||
setUnreadCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const login = (userData: UserSession) => {
|
||||
setUser(userData);
|
||||
localStorage.setItem('userProfile', JSON.stringify(userData));
|
||||
fetchUnreadCount();
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
AuthService.logout();
|
||||
setUser(null);
|
||||
setUnreadCount(0);
|
||||
localStorage.removeItem('userProfile');
|
||||
};
|
||||
|
||||
const refreshSession = async () => {
|
||||
const sessionUser = await AuthService.checkSession();
|
||||
setUser(sessionUser);
|
||||
if (sessionUser) {
|
||||
await fetchUnreadCount();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, unreadCount, login, logout, refreshSession, fetchUnreadCount }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook personalizado para usar el contexto fácilmente
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
127
Frontend/src/index.css
Normal file
127
Frontend/src/index.css
Normal file
@@ -0,0 +1,127 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-brand-primary: #00e5ff;
|
||||
--color-brand-secondary: #0051ff;
|
||||
--color-dark-bg: #0a0c10;
|
||||
--color-dark-card: #161a22;
|
||||
|
||||
--animate-fade-in-up: fade-in-up 0.5s ease-out;
|
||||
--animate-glow: glow 2s infinite alternate;
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
from {
|
||||
box-shadow: 0 0 5px rgba(0, 229, 255, 0.2);
|
||||
}
|
||||
|
||||
to {
|
||||
box-shadow: 0 0 20px rgba(0, 229, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-scale-up {
|
||||
animation: scale-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
background-color: var(--color-dark-bg);
|
||||
color: white;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Glassmorphism utility */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: var(--color-brand-primary);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(to right, #00e5ff, #0051ff);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* FIX PARA AUTOCOMPLETE EN TEMA OSCURO */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
/* Pintamos el fondo usando una sombra interior sólida del color de tu input (#0a0c10) */
|
||||
-webkit-box-shadow: 0 0 0 30px #0a0c10 inset !important;
|
||||
/* Forzamos el color del texto a blanco */
|
||||
-webkit-text-fill-color: white !important;
|
||||
/* Mantenemos el color del cursor */
|
||||
caret-color: white !important;
|
||||
/* Forzamos que el borde se mantenga (opcional si usas border en tailwind) */
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Ocultar scrollbar manteniendo funcionalidad */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
10
Frontend/src/main.tsx
Normal file
10
Frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
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>
|
||||
);
|
||||
}
|
||||
73
Frontend/src/services/admin.service.ts
Normal file
73
Frontend/src/services/admin.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import apiClient from './axios.client';
|
||||
|
||||
export const AdminService = {
|
||||
getStats: async () => {
|
||||
const response = await apiClient.get('/Admin/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPendingAds: async () => {
|
||||
const response = await apiClient.get('/Admin/ads/pending');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
approveAd: async (id: number) => {
|
||||
const response = await apiClient.post(`/Admin/ads/${id}/approve`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
rejectAd: async (id: number, reason: string) => {
|
||||
// apiClient serializa el body automáticamente a JSON
|
||||
const response = await apiClient.post(`/Admin/ads/${id}/reject`, JSON.stringify(reason), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTransactions: async (params?: { status?: string, userSearch?: string, fromDate?: string, toDate?: string, page?: number }) => {
|
||||
const response = await apiClient.get('/Admin/transactions', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getUsers: async (q?: string, page: number = 1) => {
|
||||
const response = await apiClient.get('/Admin/users', { params: { q, page } });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getUserById: async (id: number) => {
|
||||
const response = await apiClient.get(`/Admin/users/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateUser: async (id: number, data: any) => {
|
||||
const response = await apiClient.put(`/Admin/users/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAuditLogs: async (params?: { actionType?: string, entity?: string, userId?: number, fromDate?: string, toDate?: string, page?: number }) => {
|
||||
const response = await apiClient.get('/Admin/audit', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Búsqueda de usuarios (Centralizado aquí para usar en FormularioAviso)
|
||||
searchUsers: async (query: string) => {
|
||||
const response = await apiClient.get(`/Admin/users/search?q=${query}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
toggleBlockUser: async (id: number) => {
|
||||
const response = await apiClient.post(`/Admin/users/${id}/toggle-block`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Obtener todos los avisos para gestión
|
||||
getAllAds: async (params?: { q?: string, statusId?: number, page?: number }) => {
|
||||
const response = await apiClient.get('/Admin/ads', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
republishAd: async (id: number) => {
|
||||
const response = await apiClient.post(`/Admin/ads/${id}/republish`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
135
Frontend/src/services/ads.v2.service.ts
Normal file
135
Frontend/src/services/ads.v2.service.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import apiClient from './axios.client';
|
||||
|
||||
// Interfaces
|
||||
export interface AdListingDto {
|
||||
id: number;
|
||||
brandName?: string;
|
||||
versionName: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
year: number;
|
||||
km: number;
|
||||
image: string;
|
||||
isFeatured: boolean;
|
||||
statusId?: number;
|
||||
viewsCounter?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
q?: string;
|
||||
c?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
currency?: string;
|
||||
minYear?: number;
|
||||
maxYear?: number;
|
||||
userId?: number;
|
||||
brandId?: number;
|
||||
modelId?: number;
|
||||
fuel?: string;
|
||||
transmission?: string;
|
||||
color?: string;
|
||||
segment?: string;
|
||||
location?: string;
|
||||
condition?: string;
|
||||
isFeatured?: boolean;
|
||||
doorCount?: number;
|
||||
steering?: string;
|
||||
}
|
||||
|
||||
export const AdsV2Service = {
|
||||
async getAll(params: SearchParams = {}) {
|
||||
const response = await apiClient.get<AdListingDto[]>('/AdsV2', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getById(id: number) {
|
||||
const response = await apiClient.get(`/AdsV2/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getInitPaymentData(adId: number, category: string, isFeatured: boolean) {
|
||||
// Refactorizado: Usa apiClient y ruta relativa
|
||||
const response = await apiClient.get(`/Payments/init/${adId}/${category}/${isFeatured}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getBrands(vehicleTypeId: number) {
|
||||
const response = await apiClient.get(`/AdsV2/brands/${vehicleTypeId}`);
|
||||
return response.data as { id: number, name: string }[];
|
||||
},
|
||||
|
||||
async getModels(brandId: number) {
|
||||
const response = await apiClient.get(`/AdsV2/models/${brandId}`);
|
||||
return response.data as { id: number, name: string }[];
|
||||
},
|
||||
|
||||
async createDraft(ad: any) {
|
||||
const response = await apiClient.post('/AdsV2', ad);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async uploadPhotos(id: number, files: File[]) {
|
||||
const formData = new FormData();
|
||||
files.forEach(file => formData.append('files', file));
|
||||
// apiClient maneja el token, solo especificamos el content-type para form-data
|
||||
await apiClient.post(`/AdsV2/${id}/upload-photos`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
},
|
||||
|
||||
async deletePhoto(photoId: number) {
|
||||
await apiClient.delete(`/AdsV2/photos/${photoId}`);
|
||||
},
|
||||
|
||||
async update(id: number, ad: any) {
|
||||
const response = await apiClient.put(`/AdsV2/${id}`, ad);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async delete(id: number) {
|
||||
await apiClient.delete(`/AdsV2/${id}`);
|
||||
},
|
||||
|
||||
async addFavorite(userId: number, adId: number) {
|
||||
await apiClient.post('/AdsV2/favorites', { userId, adId });
|
||||
},
|
||||
|
||||
async removeFavorite(userId: number, adId: number) {
|
||||
await apiClient.delete(`/AdsV2/favorites?userId=${userId}&adId=${adId}`);
|
||||
},
|
||||
|
||||
async getFavorites(userId: number) {
|
||||
const response = await apiClient.get(`/AdsV2/favorites/${userId}`);
|
||||
return response.data as AdListingDto[];
|
||||
},
|
||||
|
||||
async searchModels(brandId: number, query: string) {
|
||||
const response = await apiClient.get('/AdsV2/models/search', {
|
||||
params: { brandId, query }
|
||||
});
|
||||
return response.data as { id: number, name: string }[];
|
||||
},
|
||||
|
||||
async changeStatus(id: number, statusId: number) {
|
||||
await apiClient.patch(`/AdsV2/${id}/status`, statusId);
|
||||
},
|
||||
|
||||
// Método para procesar pago con Mercado Pago
|
||||
async processPayment(paymentData: any) {
|
||||
// Usamos el apiClient que ya tiene la URL base y cookies
|
||||
const response = await apiClient.post('/Payments/process', paymentData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async checkPaymentStatus(adId: number) {
|
||||
const response = await apiClient.post(`/Payments/check-status/${adId}`);
|
||||
return response.data; // Retorna { status: 'approved' | 'in_process' | 'rejected', ... }
|
||||
},
|
||||
|
||||
async getSearchSuggestions(term: string) {
|
||||
const response = await apiClient.get<string[]>('/AdsV2/search-suggestions', { params: { term } });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
143
Frontend/src/services/auth.service.ts
Normal file
143
Frontend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import apiClient from './axios.client';
|
||||
|
||||
export interface UserSession {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
userType: number;
|
||||
phoneNumber?: string;
|
||||
isMFAEnabled: boolean;
|
||||
}
|
||||
|
||||
export const AuthService = {
|
||||
async login(username: string, password: string) {
|
||||
const response = await apiClient.post('/auth/login', { username, password });
|
||||
|
||||
if (response.data.status === 'MIGRATION_REQUIRED') {
|
||||
return { status: 'MIGRATION_REQUIRED', username: response.data.username };
|
||||
}
|
||||
|
||||
if (response.data.status === 'MFA_SETUP_REQUIRED') {
|
||||
return {
|
||||
status: 'MFA_SETUP_REQUIRED',
|
||||
username: response.data.username,
|
||||
qrUri: response.data.qrUri,
|
||||
secret: response.data.secret
|
||||
};
|
||||
}
|
||||
|
||||
if (response.data.status === 'TOTP_REQUIRED') {
|
||||
return {
|
||||
status: 'TOTP_REQUIRED',
|
||||
username: response.data.username,
|
||||
recommendMfa: response.data.recommendMfa
|
||||
};
|
||||
}
|
||||
|
||||
if (response.data.status === 'SUCCESS') {
|
||||
localStorage.setItem('userProfile', JSON.stringify(response.data.user));
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
user: response.data.user,
|
||||
recommendMfa: response.data.recommendMfa
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Respuesta inesperada del servidor');
|
||||
},
|
||||
|
||||
async logout() {
|
||||
await apiClient.post('/auth/logout');
|
||||
localStorage.removeItem('userProfile');
|
||||
},
|
||||
|
||||
async register(data: any) {
|
||||
const response = await apiClient.post('/auth/register', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async verifyEmail(token: string) {
|
||||
const response = await apiClient.post('/auth/verify-email', { token });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async verifyMFA(username: string, code: string) {
|
||||
const response = await apiClient.post('/auth/verify-mfa', { username, code });
|
||||
if (response.data.status === 'SUCCESS') {
|
||||
this.setSession(response.data.user, response.data.token);
|
||||
return response.data.user;
|
||||
}
|
||||
},
|
||||
|
||||
setSession(user: UserSession, token: string) {
|
||||
localStorage.setItem('session', JSON.stringify(user));
|
||||
localStorage.setItem('token', token);
|
||||
},
|
||||
|
||||
async migratePassword(username: string, newPassword: string) {
|
||||
await apiClient.post('/auth/migrate-password', { username, newPassword });
|
||||
},
|
||||
|
||||
async checkSession() {
|
||||
try {
|
||||
const response = await apiClient.get('/auth/me');
|
||||
|
||||
// Si el backend devuelve 200 pero el body es null, no hay sesión.
|
||||
if (!response.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sincronizar localStorage con la sesión real del servidor
|
||||
// Esto asegura que 'MisAvisosPage' siempre tenga el ID correcto para consultar.
|
||||
localStorage.setItem('userProfile', JSON.stringify(response.data));
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Si falla (token expirado), limpiamos
|
||||
localStorage.removeItem('userProfile');
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getCurrentUser() {
|
||||
// Solo para visualización rápida, la verdad está en el backend
|
||||
const session = localStorage.getItem('userProfile');
|
||||
return session ? JSON.parse(session) : null;
|
||||
},
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
},
|
||||
|
||||
async resendVerification(email: string) {
|
||||
const response = await apiClient.post('/auth/resend-verification', { email });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async forgotPassword(email: string) {
|
||||
const response = await apiClient.post('/auth/forgot-password', { email });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
const response = await apiClient.post('/auth/reset-password', { token, newPassword });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async initMFA() {
|
||||
const response = await apiClient.post('/auth/init-mfa');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async changePassword(currentPassword: string, newPassword: string) {
|
||||
const response = await apiClient.post('/auth/change-password', { currentPassword, newPassword });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async disableMFA() {
|
||||
const response = await apiClient.post('/auth/disable-mfa');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
35
Frontend/src/services/avisos.service.ts
Normal file
35
Frontend/src/services/avisos.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import apiClient from './axios.client';
|
||||
import type { DatosAvisoDto, InsertarAvisoDto, AvisoWebDto } from '../types/aviso.types';
|
||||
|
||||
export const AvisosService = {
|
||||
/**
|
||||
* Obtiene la configuración y tarifas de avisos desde el SP legacy
|
||||
* @param tarea Tipo de tarea (EMOTORES, EAUTOS, etc.)
|
||||
* @param paquete ID del paquete (opcional, default 0)
|
||||
*/
|
||||
obtenerConfiguracion: async (tarea: string, paquete: number = 0): Promise<DatosAvisoDto[]> => {
|
||||
const response = await apiClient.get<DatosAvisoDto[]>('/AvisosLegacy/configuracion', {
|
||||
params: { tarea, paquete }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Crea un nuevo aviso en el sistema
|
||||
* @param infoAviso Datos completos del aviso
|
||||
*/
|
||||
crearAviso: async (aviso: InsertarAvisoDto): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiClient.post<boolean>('/AvisosLegacy', aviso);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error al crear aviso:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async obtenerAvisosUsuario(nroDoc: string): Promise<AvisoWebDto[]> {
|
||||
const response = await apiClient.get<AvisoWebDto[]>(`/AvisosLegacy/cliente/${nroDoc}`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
59
Frontend/src/services/axios.client.ts
Normal file
59
Frontend/src/services/axios.client.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
withCredentials: true, // Importante para enviar Cookies
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor de Respuesta (Manejo de Errores y Refresh)
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Condición para intentar refresh:
|
||||
// 1. Es error 401 (Unauthorized)
|
||||
// 2. No hemos reintentado ya esta petición (_retry no es true)
|
||||
// 3. 🛑 IMPORTANTE: La URL que falló NO es la de refresh-token (evita bucle)
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.url.includes('/auth/refresh-token')
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Intentamos renovar el token
|
||||
await apiClient.post('/auth/refresh-token');
|
||||
|
||||
// Si el refresh funciona, reintentamos la petición original
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Si el refresh falla (401 o cualquier otro), no hay nada que hacer.
|
||||
// Forzamos logout en el cliente para limpiar basura
|
||||
localStorage.removeItem('session');
|
||||
localStorage.removeItem('userProfile');
|
||||
|
||||
// Opcional: Redirigir a login o recargar para limpiar estado
|
||||
// window.location.href = '/';
|
||||
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Interceptor de Request (Para inyectar token si usáramos Headers,
|
||||
// pero como usamos Cookies httpOnly para el AccessToken,
|
||||
// este interceptor solo es necesario si tu backend espera algún header custom extra,
|
||||
// de lo contrario las cookies viajan solas gracias a withCredentials: true).
|
||||
// Lo dejamos limpio por ahora.
|
||||
|
||||
export default apiClient;
|
||||
37
Frontend/src/services/chat.service.ts
Normal file
37
Frontend/src/services/chat.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import apiClient from './axios.client';
|
||||
|
||||
export interface ChatMessage {
|
||||
messageID?: number;
|
||||
adID: number;
|
||||
senderID: number;
|
||||
receiverID: number;
|
||||
messageText: string;
|
||||
sentAt?: string;
|
||||
isRead?: boolean;
|
||||
}
|
||||
|
||||
export const ChatService = {
|
||||
async sendMessage(msg: ChatMessage) {
|
||||
const response = await apiClient.post('/Chat/send', msg);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getInbox(userId: number) {
|
||||
const response = await apiClient.get(`/Chat/inbox/${userId}`);
|
||||
return response.data as ChatMessage[];
|
||||
},
|
||||
|
||||
async getConversation(adId: number, user1Id: number, user2Id: number) {
|
||||
const response = await apiClient.get(`/Chat/conversation/${adId}/${user1Id}/${user2Id}`);
|
||||
return response.data as ChatMessage[];
|
||||
},
|
||||
|
||||
async markAsRead(messageId: number) {
|
||||
await apiClient.post(`/Chat/mark-read/${messageId}`);
|
||||
},
|
||||
|
||||
async getUnreadCount(userId: number): Promise<number> {
|
||||
const response = await apiClient.get(`/Chat/unread-count/${userId}`);
|
||||
return response.data.count;
|
||||
},
|
||||
};
|
||||
13
Frontend/src/services/operaciones.service.ts
Normal file
13
Frontend/src/services/operaciones.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import apiClient from './axios.client';
|
||||
|
||||
export interface MedioDePago {
|
||||
id: number;
|
||||
mediodepago: string;
|
||||
}
|
||||
|
||||
export const OperacionesService = {
|
||||
obtenerMediosDePago: async (): Promise<MedioDePago[]> => {
|
||||
const response = await apiClient.get<MedioDePago[]>('/AdsV2/payment-methods');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
13
Frontend/src/services/profile.service.ts
Normal file
13
Frontend/src/services/profile.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import apiClient from './axios.client';
|
||||
|
||||
export const ProfileService = {
|
||||
getProfile: async () => {
|
||||
const response = await apiClient.get('/Profile');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProfile: async (data: { firstName?: string; lastName?: string; phoneNumber?: string }) => {
|
||||
const response = await apiClient.put('/Profile', data);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
22
Frontend/src/services/usuarios.service.ts
Normal file
22
Frontend/src/services/usuarios.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import apiClient from './axios.client';
|
||||
import type { UsuarioLegacyDto, AgenciaLegacyDto } from '../types/usuario.types';
|
||||
|
||||
export const UsuariosService = {
|
||||
obtenerParticular: async (usuario: string): Promise<UsuarioLegacyDto | null> => {
|
||||
try {
|
||||
const response = await apiClient.get<UsuarioLegacyDto>(`/UsuariosLegacy/particular/${usuario}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
obtenerAgencia: async (usuario: string): Promise<AgenciaLegacyDto | null> => {
|
||||
try {
|
||||
const response = await apiClient.get<AgenciaLegacyDto>(`/UsuariosLegacy/agencia/${usuario}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
73
Frontend/src/types/aviso.types.ts
Normal file
73
Frontend/src/types/aviso.types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface DatosAvisoDto {
|
||||
idTipoavi: number;
|
||||
nomavi: string;
|
||||
descripcion: string;
|
||||
importeSiniva: number;
|
||||
importeTotsiniva: number;
|
||||
cantidadDias: number;
|
||||
diasCorridos: boolean;
|
||||
palabras: number;
|
||||
centimetros: number;
|
||||
columnas: number;
|
||||
idRubro: number;
|
||||
idSubrubro: number;
|
||||
idCombinado: number;
|
||||
porcentajeCombinado: number;
|
||||
totalAvisos: number;
|
||||
paquete: number;
|
||||
destacado: boolean;
|
||||
}
|
||||
|
||||
export interface InsertarAvisoDto {
|
||||
tipo: string;
|
||||
nroOperacion: number;
|
||||
idCliente: number;
|
||||
tipodoc: number;
|
||||
nroDoc: string;
|
||||
razon: string;
|
||||
calle: string;
|
||||
numero: string;
|
||||
localidad: string;
|
||||
codigoPostal: string;
|
||||
telefono: string;
|
||||
email: string;
|
||||
idTipoiva: number;
|
||||
porcentajeIva1: number;
|
||||
porcentajeIva2: number;
|
||||
porcentajePercepcion: number;
|
||||
idTipoaviso: number;
|
||||
nombreaviso: string;
|
||||
idRubro: number;
|
||||
idSubrubro: number;
|
||||
idCombinado: number;
|
||||
porcentajeCombinado: number;
|
||||
fechaInicio: string;
|
||||
cantDias: number;
|
||||
diasCorridos: boolean;
|
||||
palabras: number;
|
||||
centimetros: number;
|
||||
columnas: number;
|
||||
idTarjeta: number;
|
||||
nroTarjeta: string;
|
||||
cvcTarjeta: number;
|
||||
vencimiento: string;
|
||||
calleEnvio: string;
|
||||
numeroEnvio: string;
|
||||
localidadEnvio: string;
|
||||
tarifa: number;
|
||||
importeAviso: number;
|
||||
importeIva1: number;
|
||||
importeIva2: number;
|
||||
importePercepcion: number;
|
||||
cantavi: number;
|
||||
paquete: number;
|
||||
destacado: boolean;
|
||||
}
|
||||
|
||||
export interface AvisoWebDto {
|
||||
nombreAviso: string;
|
||||
fechaInicio?: string;
|
||||
importeAviso: number;
|
||||
estado?: string;
|
||||
nroOperacion?: number;
|
||||
}
|
||||
27
Frontend/src/types/usuario.types.ts
Normal file
27
Frontend/src/types/usuario.types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface UsuarioLegacyDto {
|
||||
part_Id: number;
|
||||
part_Usu_Nombre: string;
|
||||
part_Nombre?: string;
|
||||
part_Apellido?: string;
|
||||
part_Email?: string;
|
||||
part_Telefono?: string;
|
||||
part_Calle?: string;
|
||||
part_Nro?: string;
|
||||
part_Localidad?: string;
|
||||
part_CP?: string;
|
||||
part_TipoDoc?: number;
|
||||
part_NroDoc?: string;
|
||||
part_IdIVA?: number;
|
||||
}
|
||||
|
||||
export interface AgenciaLegacyDto {
|
||||
agen_Id: number;
|
||||
agen_usuario: string;
|
||||
agen_Nombre?: string;
|
||||
agen_Email?: string;
|
||||
agen_Telefono?: string;
|
||||
agen_Domicilio?: string;
|
||||
agen_Localidad?: string;
|
||||
agen_Cuit?: string;
|
||||
agen_IdIVA?: number;
|
||||
}
|
||||
51
Frontend/src/utils/app.utils.ts
Normal file
51
Frontend/src/utils/app.utils.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// src/utils/app.utils.ts
|
||||
|
||||
// Leemos la variable de entorno para la base de las imágenes.
|
||||
// Si no está definida, se usará un string vacío (lo que funcionará para placeholders locales).
|
||||
const IMAGE_BASE_URL = import.meta.env.VITE_STATIC_BASE_URL || '';
|
||||
|
||||
/**
|
||||
* Construye la URL completa de una imagen.
|
||||
* @param path - La ruta relativa de la imagen guardada en la base de datos (ej: /uploads/ads/12/image.jpg).
|
||||
* @returns La URL completa y accesible desde el navegador.
|
||||
*/
|
||||
export const getImageUrl = (path?: string): string => {
|
||||
// Caso 1: El backend envía 'null' o 'undefined' porque no hay foto.
|
||||
if (!path) {
|
||||
return "/placeholder-car.png";
|
||||
}
|
||||
|
||||
// Caso 2: La ruta ya es una URL completa (de un seeder, por ejemplo).
|
||||
if (path.startsWith('http')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Caso 3: Es una ruta relativa de nuestro backend. La construye correctamente.
|
||||
const baseUrl = IMAGE_BASE_URL.endsWith('/') ? IMAGE_BASE_URL.slice(0, -1) : IMAGE_BASE_URL;
|
||||
const imagePath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
return `${baseUrl}${imagePath}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatea un número como moneda ARS o USD.
|
||||
*/
|
||||
export const formatCurrency = (amount: number, currency: string = 'ARS') => {
|
||||
return new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parsea una fecha string asumiendo que es UTC si no se especifica.
|
||||
*/
|
||||
export const parseUTCDate = (dateStr?: string | Date): Date => {
|
||||
if (!dateStr) return new Date();
|
||||
if (dateStr instanceof Date) return dateStr;
|
||||
|
||||
const normalized = dateStr.includes('T') ? dateStr : dateStr.replace(' ', 'T');
|
||||
const finalStr = (normalized.includes('Z') || normalized.includes('+')) ? normalized : normalized + 'Z';
|
||||
return new Date(finalStr);
|
||||
};
|
||||
31
Frontend/tailwind.config.js
Normal file
31
Frontend/tailwind.config.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// tailwind.config.js
|
||||
const plugin = require('tailwindcss/plugin')
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
plugin(function ({ addUtilities }) {
|
||||
addUtilities({
|
||||
'.rotate-y-180': {
|
||||
transform: 'rotateY(180deg)',
|
||||
},
|
||||
'.transform-style-3d': {
|
||||
transformStyle: 'preserve-3d',
|
||||
},
|
||||
'.perspective-1000': {
|
||||
perspective: '1000px',
|
||||
},
|
||||
'.backface-hidden': {
|
||||
backfaceVisibility: 'hidden',
|
||||
},
|
||||
})
|
||||
})
|
||||
],
|
||||
}
|
||||
28
Frontend/tsconfig.app.json
Normal file
28
Frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
Frontend/tsconfig.json
Normal file
7
Frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
Frontend/tsconfig.node.json
Normal file
26
Frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
11
Frontend/vite.config.ts
Normal file
11
Frontend/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user