Compare commits

..

3 Commits

Author SHA1 Message Date
8f0b9546d4 Fix: Seguridad de Imágenes 2026-01-30 15:23:50 -03:00
a5f501e88e Feat: Mejora de galeria de imagenes 2026-01-30 11:39:14 -03:00
8f6f8d4500 Feat: Ajustes de seguridad 2026-01-30 11:18:56 -03:00
13 changed files with 408 additions and 250 deletions

View File

@@ -11,7 +11,7 @@ namespace MotoresArgentinosV2.API.Controllers;
[ApiController]
[Route("api/[controller]")]
// CORRECCIÓN: Se quitó [EnableRateLimiting("AuthPolicy")] de aquí para no bloquear /me ni /logout
[EnableRateLimiting("AuthPolicy")]
public class AuthController : ControllerBase
{
private readonly IIdentityService _identityService;
@@ -33,7 +33,7 @@ public class AuthController : ControllerBase
var cookieOptions = new CookieOptions
{
HttpOnly = true, // Seguridad: JS no puede leer esto
Expires = DateTime.UtcNow.AddDays(7),
Expires = DateTime.UtcNow.AddMinutes(15),
Secure = true, // Solo HTTPS (localhost con https cuenta)
SameSite = SameSiteMode.Strict,
IsEssential = true

View File

@@ -1,180 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Infrastructure.Data;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
namespace MotoresArgentinosV2.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SeedController : ControllerBase
{
private readonly MotoresV2DbContext _context;
private readonly IPasswordService _passwordService;
public SeedController(MotoresV2DbContext context, IPasswordService passwordService)
{
_context = context;
_passwordService = passwordService;
}
[HttpPost("database")]
public async Task<IActionResult> SeedDatabase()
{
// 1. Asegurar Marcas y Modelos
if (!await _context.Brands.AnyAsync())
{
var toyota = new Brand { VehicleTypeID = 1, Name = "Toyota" };
var ford = new Brand { VehicleTypeID = 1, Name = "Ford" };
var vw = new Brand { VehicleTypeID = 1, Name = "Volkswagen" };
var honda = new Brand { VehicleTypeID = 2, Name = "Honda" };
var yamaha = new Brand { VehicleTypeID = 2, Name = "Yamaha" };
_context.Brands.AddRange(toyota, ford, vw, honda, yamaha);
await _context.SaveChangesAsync();
_context.Models.AddRange(
new Model { BrandID = toyota.BrandID, Name = "Corolla" },
new Model { BrandID = toyota.BrandID, Name = "Hilux" },
new Model { BrandID = ford.BrandID, Name = "Ranger" },
new Model { BrandID = vw.BrandID, Name = "Amarok" },
new Model { BrandID = honda.BrandID, Name = "Wave 110" },
new Model { BrandID = yamaha.BrandID, Name = "FZ FI" }
);
await _context.SaveChangesAsync();
}
// 2. Crear Usuarios de Prueba
var testUser = await _context.Users.FirstOrDefaultAsync(u => u.UserName == "testuser");
if (testUser == null)
{
testUser = new User
{
UserName = "testuser",
Email = "test@motores.com.ar",
PasswordHash = _passwordService.HashPassword("test123"),
FirstName = "Usuario",
LastName = "Prueba",
MigrationStatus = 1,
UserType = 1
};
_context.Users.Add(testUser);
}
var adminUser = await _context.Users.FirstOrDefaultAsync(u => u.UserName == "admin");
if (adminUser == null)
{
adminUser = new User
{
UserName = "admin",
Email = "admin@motoresargentinos.com.ar",
PasswordHash = _passwordService.HashPassword("admin123"),
FirstName = "Admin",
LastName = "Motores",
MigrationStatus = 1,
UserType = 3 // ADMIN
};
_context.Users.Add(adminUser);
}
await _context.SaveChangesAsync();
// 3. Crear Avisos de Prueba
if (!await _context.Ads.AnyAsync())
{
var brands = await _context.Brands.ToListAsync();
var models = await _context.Models.ToListAsync();
var ad1 = new Ad
{
UserID = testUser.UserID,
VehicleTypeID = 1,
BrandID = brands.First(b => b.Name == "Toyota").BrandID,
ModelID = models.First(m => m.Name == "Corolla").ModelID,
VersionName = "Toyota Corolla 1.8 XLI",
Year = 2022,
KM = 15000,
Price = 25000,
Currency = "USD",
Description = "Excelente estado, único dueño. Service al día.",
StatusID = 4, // Activo
IsFeatured = true,
CreatedAt = DateTime.UtcNow,
PublishedAt = DateTime.UtcNow
};
var ad2 = new Ad
{
UserID = testUser.UserID,
VehicleTypeID = 2,
BrandID = brands.First(b => b.Name == "Honda").BrandID,
ModelID = models.First(m => m.Name == "Wave 110").ModelID,
VersionName = "Honda Wave 110 S",
Year = 2023,
KM = 2500,
Price = 1800,
Currency = "USD",
Description = "Impecable, como nueva. Muy económica.",
StatusID = 4, // Activo
CreatedAt = DateTime.UtcNow,
PublishedAt = DateTime.UtcNow
};
var ad3 = new Ad
{
UserID = testUser.UserID,
VehicleTypeID = 1,
BrandID = brands.First(b => b.Name == "Ford").BrandID,
ModelID = models.First(m => m.Name == "Ranger").ModelID,
VersionName = "Ford Ranger Limited 4x4",
Year = 2021,
KM = 35000,
Price = 42000,
Currency = "USD",
Description = "Camioneta impecable, lista para transferir.",
StatusID = 3, // Moderacion Pendiente
CreatedAt = DateTime.UtcNow
};
_context.Ads.AddRange(ad1, ad2, ad3);
await _context.SaveChangesAsync();
// Agregar fotos
_context.AdPhotos.AddRange(
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?auto=format&fit=crop&q=80&w=1200", IsCover = true },
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1590362891991-f776e933a68e?auto=format&fit=crop&q=80&w=1200" },
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1549317661-bd32c8ce0db2?auto=format&fit=crop&q=80&w=1200" },
new AdPhoto { AdID = ad2.AdID, FilePath = "https://images.unsplash.com/photo-1558981403-c5f91cbba527?auto=format&fit=crop&q=80&w=800", IsCover = true },
new AdPhoto { AdID = ad3.AdID, FilePath = "https://images.unsplash.com/photo-1533473359331-0135ef1b58bf?auto=format&fit=crop&q=80&w=1200", IsCover = true }
);
// Agregar Características Técnicas
_context.AdFeatures.AddRange(
new AdFeature { AdID = ad1.AdID, FeatureKey = "Combustible", FeatureValue = "Nafta" },
new AdFeature { AdID = ad1.AdID, FeatureKey = "Transmision", FeatureValue = "Automática" },
new AdFeature { AdID = ad1.AdID, FeatureKey = "Color", FeatureValue = "Blanco" },
new AdFeature { AdID = ad2.AdID, FeatureKey = "Combustible", FeatureValue = "Nafta" },
new AdFeature { AdID = ad2.AdID, FeatureKey = "Color", FeatureValue = "Rojo" }
);
await _context.SaveChangesAsync();
}
return Ok("Database seeded successfully with Features and Multiple Photos");
}
[HttpPost("reset")]
public async Task<IActionResult> ResetDatabase()
{
_context.ChangeTracker.Clear();
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Transactions");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM AdPhotos");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM AdFeatures");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Ads");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Models");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Brands");
return await SeedDatabase();
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text.Json;
namespace MotoresArgentinosV2.API.Middleware;
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger, IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
// Loguear el error real con stack trace completo
_logger.LogError(exception, "Error no controlado procesando la solicitud: {Method} {Path}", context.Request.Method, context.Request.Path);
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var response = new
{
status = context.Response.StatusCode,
message = "Ocurrió un error interno en el servidor. Por favor, intente nuevamente más tarde.",
// En desarrollo mostramos el detalle, en producción ocultamos todo
detail = _env.IsDevelopment() ? exception.Message : null
};
var json = JsonSerializer.Serialize(response);
await context.Response.WriteAsync(json);
}
}

View File

@@ -78,25 +78,28 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("AuthPolicy", context =>
{
// Si es localhost, SIN LÍMITES
// Si es localhost, SIN LÍMITES (Evita auto-bloqueo en desarrollo)
var remoteIp = context.Connection.RemoteIpAddress;
if (System.Net.IPAddress.IsLoopback(remoteIp!))
if (remoteIp != null && System.Net.IPAddress.IsLoopback(remoteIp))
{
return RateLimitPartition.GetNoLimiter("loopback_auth");
}
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: remoteIp?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 5, // 5 intentos por minuto para IPs externas
QueueLimit = 0,
Window = TimeSpan.FromMinutes(1)
});
return RateLimitPartition.GetFixedWindowLimiter("auth_limit", _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
});
});
// 🛡️ SEGURIDAD: Evitar que el host se caiga si un servicio de fondo falla
builder.Services.Configure<HostOptions>(options =>
{
options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
});
// DB CONTEXTS (Legacy unificado en eldia)
builder.Services.AddDbContext<EldiaDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("eldia")));
@@ -119,6 +122,7 @@ builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("SmtpS
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<IImageStorageService, ImageStorageService>();
builder.Services.AddHostedService<AdExpirationService>();
builder.Services.AddHostedService<TokenCleanupService>();
// 🔒 JWT AUTH
var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key Missing");
@@ -158,8 +162,10 @@ builder.Services.AddSwaggerGen();
var app = builder.Build();
// USAR EL MIDDLEWARE AL PRINCIPIO
// Debe ser lo primero para que el RateLimiter y los Logs vean la IP real
// Middleware de Manejo Global de Excepciones (Debe ser el primero para atrapar todo)
app.UseMiddleware<MotoresArgentinosV2.API.Middleware.ExceptionHandlingMiddleware>();
// USAR EL MIDDLEWARE DE HEADERS
app.UseForwardedHeaders();
// 🔒 HEADERS DE SEGURIDAD MIDDLEWARE
@@ -170,6 +176,9 @@ app.Use(async (context, next) =>
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
// Permissions-Policy: Bloquear funcionalidades sensibles del navegador no usadas
context.Response.Headers.Append("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()");
// CSP adaptada para permitir pagos en Payway y WebSockets de Vite
string csp = "default-src 'self'; " +
"img-src 'self' data: https: blob:; " +

View File

@@ -43,14 +43,19 @@ public class AdExpirationService : BackgroundService
await ProcessWeeklyStatsAsync();
await ProcessPaymentRemindersAsync();
await ProcessUnreadMessagesRemindersAsync();
// Ejecutar cada 1 hora
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error CRÍTICO en ciclo de mantenimiento.");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
// Ejecutar cada 1 hora
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}

View File

@@ -78,11 +78,27 @@ public class ImageStorageService : IImageStorageService
{
string hex = BitConverter.ToString(headerBytes.Take(8).ToArray());
_logger.LogWarning("Firma de archivo inválida para {Extension}: {HexBytes}", ext, hex);
throw new Exception($"El archivo parece corrupto o tiene una firma inválida ({hex}). El sistema acepta JPG, PNG y WEBP reales.");
throw new Exception($"El archivo parece corrupto o tiene una firma inválida ({hex}).");
}
}
try
{
// 4. PREVENCIÓN DoS: Validar dimensiones sin cargar la imagen completa en memoria
// Esto evita que una imagen de 1KB que dice ser de 50000x50000px cuelgue el servidor
using (var stream = file.OpenReadStream())
{
var info = await Image.IdentifyAsync(stream);
if (info == null) throw new Exception("No se pudo identificar el formato de la imagen.");
const int MaxDimension = 5000; // 5000px es más que suficiente para un aviso
if (info.Width > MaxDimension || info.Height > MaxDimension)
{
_logger.LogWarning("Intento de subir imagen con dimensiones excesivas: {W}x{H}", info.Width, info.Height);
throw new Exception($"Las dimensiones de la imagen ({info.Width}x{info.Height}) exceden el límite de seguridad de {MaxDimension}px.");
}
}
// 1. Definir rutas
var uploadFolder = Path.Combine(_env.WebRootPath, "uploads", "ads", adId.ToString());
if (!Directory.Exists(uploadFolder)) Directory.CreateDirectory(uploadFolder);
@@ -94,7 +110,7 @@ public class ImageStorageService : IImageStorageService
var filePath = Path.Combine(uploadFolder, fileName);
var thumbPath = Path.Combine(uploadFolder, thumbName);
// 2. Cargar y Procesar con ImageSharp
// 2. Cargar y Procesar con ImageSharp (Aquí ya es seguro porque validamos dimensiones e identidad)
using (var image = await Image.LoadAsync(file.OpenReadStream()))
{
// A. Guardar imagen principal (Optimized: Max width 1280px)
@@ -102,22 +118,21 @@ public class ImageStorageService : IImageStorageService
{
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(1280, 0), // 0 mantiene aspect ratio
Size = new Size(1280, 0),
Mode = ResizeMode.Max
}));
}
await image.SaveAsJpegAsync(filePath);
// B. Generar Thumbnail (Max width 400px para grillas)
// B. Generar Thumbnail
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(400, 300),
Mode = ResizeMode.Crop // Recorte inteligente para que queden parejitas
Mode = ResizeMode.Crop
}));
await image.SaveAsJpegAsync(thumbPath);
}
// Retornar ruta relativa web de la imagen principal
return $"/uploads/ads/{adId}/{fileName}";
}
catch (Exception ex)

View File

@@ -0,0 +1,72 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Infrastructure.Data;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class TokenCleanupService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TokenCleanupService> _logger;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(24); // Ejecutar cada 24hs
public TokenCleanupService(IServiceProvider serviceProvider, ILogger<TokenCleanupService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Servicio de Limpieza de Tokens iniciado.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await CleanExpiredTokensAsync(stoppingToken);
// Esperar hasta la próxima ejecución
await Task.Delay(_cleanupInterval, stoppingToken);
}
catch (OperationCanceledException)
{
// El servicio se está deteniendo, es normal
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error durante la limpieza de tokens.");
// Si hay un error, esperamos un poco antes de reintentar (evitar bucle infinito de errores)
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
private async Task CleanExpiredTokensAsync(CancellationToken stoppingToken)
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
// Definir criterios de limpieza
var cutoffDate = DateTime.UtcNow; // Tokens ya expirados
var revokedCutoff = DateTime.UtcNow.AddDays(-30); // Tokens revocados hace más de 30 días (auditoría)
_logger.LogInformation("Ejecutando limpieza de RefreshTokens expirados o antiguos...");
// Opción 1: Borrado con SQL Raw para eficiencia en lotes grandes
// Asumimos que la tabla se llama 'RefreshTokens' en la DB
var rowsAffected = await context.Database.ExecuteSqlRawAsync(
"DELETE FROM [RefreshTokens] WHERE [Expires] < @cutoffDate OR ([Revoked] IS NOT NULL AND [Revoked] < @revokedCutoff)",
new[] {
new Microsoft.Data.SqlClient.SqlParameter("@cutoffDate", cutoffDate),
new Microsoft.Data.SqlClient.SqlParameter("@revokedCutoff", revokedCutoff)
},
stoppingToken);
_logger.LogInformation("Limpieza completada. {Count} tokens eliminados.", rowsAffected);
}
}
}

View File

@@ -33,7 +33,7 @@ public class TokenService : ITokenService
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, user.UserType == 3 ? "Admin" : "User")
}),
Expires = DateTime.UtcNow.AddHours(8),
Expires = DateTime.UtcNow.AddMinutes(15),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
Issuer = _config["Jwt:Issuer"],
Audience = _config["Jwt:Audience"]

View File

@@ -2,6 +2,9 @@ server {
listen 80;
server_name localhost;
# Seguridad: Limitar tamaño de subida para prevenir DoS
client_max_body_size 20M;
location / {
root /usr/share/nginx/html;
index index.html index.htm;

View File

@@ -0,0 +1,198 @@
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { FaChevronLeft, FaChevronRight, FaExpand, FaTimes, FaArrowLeft } from 'react-icons/fa';
interface Photo {
filePath: string;
}
interface PremiumGalleryProps {
photos: Photo[];
isAdActive: boolean;
onFavoriteToggle: () => void;
isFavorite: boolean;
statusBadge: React.ReactNode;
featuredBadge: React.ReactNode;
locationBadge: React.ReactNode;
}
export default function PremiumGallery({
photos,
isAdActive,
onFavoriteToggle,
isFavorite,
statusBadge,
featuredBadge,
locationBadge
}: PremiumGalleryProps) {
const [activePhoto, setActivePhoto] = useState(0);
const [isFullscreen, setIsFullscreen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef<number | null>(null);
const getImageUrl = (path: string) => {
if (!path) return "/placeholder-car.png";
return path.startsWith('http') ? path : `${import.meta.env.VITE_STATIC_BASE_URL}${path}`;
};
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartX.current === null) return;
const touchEndX = e.changedTouches[0].clientX;
const diff = touchStartX.current - touchEndX;
if (Math.abs(diff) > 50) {
if (diff > 0) nextPhoto();
else prevPhoto();
}
touchStartX.current = null;
};
const nextPhoto = () => setActivePhoto((prev) => (prev + 1) % photos.length);
const prevPhoto = () => setActivePhoto((prev) => (prev - 1 + photos.length) % photos.length);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isFullscreen) {
if (e.key === 'ArrowRight') nextPhoto();
if (e.key === 'ArrowLeft') prevPhoto();
if (e.key === 'Escape') setIsFullscreen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isFullscreen]);
if (!photos || photos.length === 0) return null;
return (
<div className="space-y-4 select-none">
{/* CONTENEDOR PRINCIPAL */}
<div
ref={containerRef}
className="glass rounded-2xl md:rounded-[3rem] overflow-hidden border border-white/5 relative h-[350px] md:h-[550px] shadow-2xl group bg-black/40 cursor-zoom-in"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => setIsFullscreen(true)}
>
<div className="relative w-full h-full overflow-hidden flex items-center justify-center">
<img
src={getImageUrl(photos[activePhoto].filePath)}
className={`max-h-full max-w-full object-contain transition-transform duration-500 ${!isAdActive ? 'grayscale' : ''}`}
alt="Vehículo"
/>
</div>
{/* HUD DE INFO */}
<div className="absolute top-4 md:top-8 left-4 md:left-8 flex flex-col items-start gap-2 z-10">
{statusBadge}
{featuredBadge}
{locationBadge}
</div>
<button
onClick={(e) => { e.stopPropagation(); onFavoriteToggle(); }}
className={`absolute top-4 md:top-8 right-4 md:right-8 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 z-10 ${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>
{photos.length > 1 && (
<>
<button
onClick={(e) => { e.stopPropagation(); prevPhoto(); }}
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 md:w-12 h-10 md:h-12 bg-black/40 hover:bg-white hover:text-black backdrop-blur-md rounded-full border border-white/10 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100 translate-x-[-10px] group-hover:translate-x-0 z-10"
>
<FaChevronLeft />
</button>
<button
onClick={(e) => { e.stopPropagation(); nextPhoto(); }}
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 md:w-12 h-10 md:h-12 bg-black/40 hover:bg-white hover:text-black backdrop-blur-md rounded-full border border-white/10 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100 translate-x-[10px] group-hover:translate-x-0 z-10"
>
<FaChevronRight />
</button>
</>
)}
<div className="absolute bottom-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity z-10 hidden md:block">
<div className="bg-black/60 backdrop-blur-md p-3 rounded-xl border border-white/10 text-white/70">
<FaExpand size={18} />
</div>
</div>
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/40 backdrop-blur-md px-4 py-1.5 rounded-full border border-white/10 text-[10px] font-black tracking-[0.2em] text-white/80 z-10">
{activePhoto + 1} / {photos.length}
</div>
</div>
{/* MINIATURAS (THUMBNAILS) */}
{photos.length > 1 && (
<div className="flex gap-3 md:gap-4 overflow-x-auto pt-4 pb-4 px-1 scrollbar-hide no-scrollbar items-center justify-center">
{photos.map((p, idx) => (
<button
key={idx}
onClick={() => setActivePhoto(idx)}
className={`relative w-24 md:w-32 h-16 md:h-20 rounded-xl md:rounded-2xl overflow-hidden flex-shrink-0 border-2 transition-all duration-300 ${activePhoto === idx ? 'border-blue-500 scale-105 shadow-lg ring-4 ring-blue-500/20' : 'border-white/5 opacity-40 hover:opacity-100 hover:border-white/20'}`}
>
<img src={getImageUrl(p.filePath)} className="w-full h-full object-cover" alt="" />
{activePhoto === idx && <div className="absolute inset-0 bg-blue-500/10 pointer-events-none" />}
</button>
))}
</div>
)}
{/* LIGHTBOX CON PORTAL */}
{isFullscreen && createPortal(
<div className="fixed inset-0 z-[99999] bg-black/98 backdrop-blur-3xl flex flex-col items-center justify-center animate-fade-in" onClick={() => setIsFullscreen(false)}>
{/* Botón Cerrar Desktop (X Minimalista) */}
<button
className="absolute top-8 right-8 text-white/40 hover:text-white transition-all hidden md:flex items-center gap-2 group z-[100000]"
onClick={() => setIsFullscreen(false)}
>
<span className="text-[10px] font-black tracking-widest opacity-0 group-hover:opacity-100 transition-opacity">CERRAR</span>
<FaTimes size={24} />
</button>
<div
className="relative w-full h-full flex flex-col items-center justify-center p-4 md:p-12"
onClick={(e) => e.stopPropagation()}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<img
src={getImageUrl(photos[activePhoto].filePath)}
className="max-h-[80vh] max-w-full object-contain animate-scale-up shadow-2xl"
alt="Vista amplia"
/>
{photos.length > 1 && (
<>
<button onClick={(e) => { e.stopPropagation(); prevPhoto(); }} className="absolute left-4 md:left-12 top-1/2 -translate-y-1/2 w-16 h-16 bg-white/5 hover:bg-white hover:text-black rounded-full border border-white/5 flex items-center justify-center transition-all text-2xl hidden md:flex"><FaChevronLeft /></button>
<button onClick={(e) => { e.stopPropagation(); nextPhoto(); }} className="absolute right-4 md:right-12 top-1/2 -translate-y-1/2 w-16 h-16 bg-white/5 hover:bg-white hover:text-black rounded-full border border-white/5 flex items-center justify-center transition-all text-2xl hidden md:flex"><FaChevronRight /></button>
</>
)}
{/* Info inferior (Contador) */}
<div className="mt-8 text-white/30 font-black tracking-[0.4em] text-[10px] uppercase">
Foto {activePhoto + 1} de {photos.length}
</div>
{/* BOTÓN CERRAR MÓVIL (Píldora ergonómica) */}
<button
className="mt-12 md:hidden bg-white/10 border border-white/20 text-white px-8 py-4 rounded-full font-black text-xs tracking-widest flex items-center gap-2 hover:bg-white/20 active:scale-95 transition-all shadow-xl"
onClick={() => setIsFullscreen(false)}
>
<FaArrowLeft size={10} /> VOLVER AL AVISO
</button>
</div>
</div>,
document.body
)}
</div>
);
}

View File

@@ -8,6 +8,8 @@
--animate-fade-in-up: fade-in-up 0.5s ease-out;
--animate-glow: glow 2s infinite alternate;
--animate-fade-in: fade-in 0.3s ease-out forwards;
--animate-scale-up: scale-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
@keyframes fade-in-up {
from {
@@ -52,16 +54,22 @@
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;
}
}
/* Clases de utilidad personalizadas (fuera de @theme) */
.animate-fade-in {
animation: var(--animate-fade-in);
}
.animate-scale-up {
animation: var(--animate-scale-up);
}
.animate-fade-in-up {
animation: var(--animate-fade-in-up);
}
:root {
background-color: var(--color-dark-bg);
color: white;

View File

@@ -6,13 +6,13 @@ 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';
import PremiumGallery from '../components/PremiumGallery';
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();
@@ -88,11 +88,6 @@ export default function VehiculoDetailPage() {
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">
@@ -105,37 +100,15 @@ export default function VehiculoDetailPage() {
{/* 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>
<PremiumGallery
photos={vehicle.photos}
isAdActive={isAdActive}
isFavorite={isFavorite}
onFavoriteToggle={handleFavoriteToggle}
statusBadge={<AdStatusBadge statusId={vehicle.statusID} />}
featuredBadge={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>}
locationBadge={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>}
/>
{/* 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">

View File

@@ -8,4 +8,7 @@ export default defineConfig({
react(),
tailwindcss(),
],
build: {
sourcemap: false, // Seguridad: Ocultar código fuente en producción
},
})