Compare commits
2 Commits
32cf2ba74a
...
a5f501e88e
| Author | SHA1 | Date | |
|---|---|---|---|
| a5f501e88e | |||
| 8f6f8d4500 |
@@ -11,7 +11,7 @@ namespace MotoresArgentinosV2.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
// CORRECCIÓN: Se quitó [EnableRateLimiting("AuthPolicy")] de aquí para no bloquear /me ni /logout
|
[EnableRateLimiting("AuthPolicy")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IIdentityService _identityService;
|
private readonly IIdentityService _identityService;
|
||||||
@@ -33,7 +33,7 @@ public class AuthController : ControllerBase
|
|||||||
var cookieOptions = new CookieOptions
|
var cookieOptions = new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true, // Seguridad: JS no puede leer esto
|
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)
|
Secure = true, // Solo HTTPS (localhost con https cuenta)
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
IsEssential = true
|
IsEssential = true
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,25 +78,28 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
|
|
||||||
options.AddPolicy("AuthPolicy", context =>
|
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;
|
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.GetNoLimiter("loopback_auth");
|
||||||
}
|
}
|
||||||
|
|
||||||
return RateLimitPartition.GetFixedWindowLimiter(
|
return RateLimitPartition.GetFixedWindowLimiter("auth_limit", _ => new FixedWindowRateLimiterOptions
|
||||||
partitionKey: remoteIp?.ToString() ?? "unknown",
|
{
|
||||||
factory: _ => new FixedWindowRateLimiterOptions
|
PermitLimit = 5,
|
||||||
{
|
Window = TimeSpan.FromMinutes(1),
|
||||||
AutoReplenishment = true,
|
QueueLimit = 0
|
||||||
PermitLimit = 5, // 5 intentos por minuto para IPs externas
|
});
|
||||||
QueueLimit = 0,
|
|
||||||
Window = TimeSpan.FromMinutes(1)
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🛡️ 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)
|
// DB CONTEXTS (Legacy unificado en eldia)
|
||||||
builder.Services.AddDbContext<EldiaDbContext>(options =>
|
builder.Services.AddDbContext<EldiaDbContext>(options =>
|
||||||
options.UseSqlServer(builder.Configuration.GetConnectionString("eldia")));
|
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<IEmailService, SmtpEmailService>();
|
||||||
builder.Services.AddScoped<IImageStorageService, ImageStorageService>();
|
builder.Services.AddScoped<IImageStorageService, ImageStorageService>();
|
||||||
builder.Services.AddHostedService<AdExpirationService>();
|
builder.Services.AddHostedService<AdExpirationService>();
|
||||||
|
builder.Services.AddHostedService<TokenCleanupService>();
|
||||||
|
|
||||||
// 🔒 JWT AUTH
|
// 🔒 JWT AUTH
|
||||||
var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key Missing");
|
var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key Missing");
|
||||||
@@ -158,8 +162,10 @@ builder.Services.AddSwaggerGen();
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// USAR EL MIDDLEWARE AL PRINCIPIO
|
// Middleware de Manejo Global de Excepciones (Debe ser el primero para atrapar todo)
|
||||||
// Debe ser lo primero para que el RateLimiter y los Logs vean la IP real
|
app.UseMiddleware<MotoresArgentinosV2.API.Middleware.ExceptionHandlingMiddleware>();
|
||||||
|
|
||||||
|
// USAR EL MIDDLEWARE DE HEADERS
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
|
|
||||||
// 🔒 HEADERS DE SEGURIDAD MIDDLEWARE
|
// 🔒 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("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
|
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
|
// CSP adaptada para permitir pagos en Payway y WebSockets de Vite
|
||||||
string csp = "default-src 'self'; " +
|
string csp = "default-src 'self'; " +
|
||||||
"img-src 'self' data: https: blob:; " +
|
"img-src 'self' data: https: blob:; " +
|
||||||
|
|||||||
@@ -43,14 +43,19 @@ public class AdExpirationService : BackgroundService
|
|||||||
await ProcessWeeklyStatsAsync();
|
await ProcessWeeklyStatsAsync();
|
||||||
await ProcessPaymentRemindersAsync();
|
await ProcessPaymentRemindersAsync();
|
||||||
await ProcessUnreadMessagesRemindersAsync();
|
await ProcessUnreadMessagesRemindersAsync();
|
||||||
|
|
||||||
|
// Ejecutar cada 1 hora
|
||||||
|
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error CRÍTICO en ciclo de mantenimiento.");
|
_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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ public class TokenService : ITokenService
|
|||||||
new Claim(ClaimTypes.Email, user.Email),
|
new Claim(ClaimTypes.Email, user.Email),
|
||||||
new Claim(ClaimTypes.Role, user.UserType == 3 ? "Admin" : "User")
|
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),
|
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
|
||||||
Issuer = _config["Jwt:Issuer"],
|
Issuer = _config["Jwt:Issuer"],
|
||||||
Audience = _config["Jwt:Audience"]
|
Audience = _config["Jwt:Audience"]
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
|
# Seguridad: Limitar tamaño de subida para prevenir DoS
|
||||||
|
client_max_body_size 20M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
198
Frontend/src/components/PremiumGallery.tsx
Normal file
198
Frontend/src/components/PremiumGallery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
--animate-fade-in-up: fade-in-up 0.5s ease-out;
|
--animate-fade-in-up: fade-in-up 0.5s ease-out;
|
||||||
--animate-glow: glow 2s infinite alternate;
|
--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 {
|
@keyframes fade-in-up {
|
||||||
from {
|
from {
|
||||||
@@ -52,16 +54,22 @@
|
|||||||
transform: scale(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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
:root {
|
||||||
background-color: var(--color-dark-bg);
|
background-color: var(--color-dark-bg);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import ChatModal from '../components/ChatModal';
|
|||||||
import { FaWhatsapp, FaMapMarkerAlt, FaInfoCircle, FaShareAlt } from 'react-icons/fa';
|
import { FaWhatsapp, FaMapMarkerAlt, FaInfoCircle, FaShareAlt } from 'react-icons/fa';
|
||||||
import { AD_STATUSES } from '../constants/adStatuses';
|
import { AD_STATUSES } from '../constants/adStatuses';
|
||||||
import AdStatusBadge from '../components/AdStatusBadge';
|
import AdStatusBadge from '../components/AdStatusBadge';
|
||||||
|
import PremiumGallery from '../components/PremiumGallery';
|
||||||
|
|
||||||
export default function VehiculoDetailPage() {
|
export default function VehiculoDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [vehicle, setVehicle] = useState<any>(null);
|
const [vehicle, setVehicle] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activePhoto, setActivePhoto] = useState(0);
|
|
||||||
const [isFavorite, setIsFavorite] = useState(false);
|
const [isFavorite, setIsFavorite] = useState(false);
|
||||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||||
const user = AuthService.getCurrentUser();
|
const user = AuthService.getCurrentUser();
|
||||||
@@ -88,11 +88,6 @@ export default function VehiculoDetailPage() {
|
|||||||
const isAdActive = vehicle.statusID === AD_STATUSES.ACTIVE;
|
const isAdActive = vehicle.statusID === AD_STATUSES.ACTIVE;
|
||||||
const isContactable = isAdActive && !isOwnerAdmin;
|
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 (
|
return (
|
||||||
<div className="container mx-auto px-4 md:px-6 py-6 md:py-12 animate-fade-in-up">
|
<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">
|
<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) */}
|
{/* 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">
|
<div className="lg:col-span-2 space-y-8 md:space-y-12 order-1 lg:order-1">
|
||||||
{/* BLOQUE 1: Galería y Fotos */}
|
<PremiumGallery
|
||||||
<div className="space-y-3 md:space-y-4">
|
photos={vehicle.photos}
|
||||||
<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">
|
isAdActive={isAdActive}
|
||||||
<img
|
isFavorite={isFavorite}
|
||||||
src={getImageUrl(vehicle.photos?.[activePhoto]?.filePath)}
|
onFavoriteToggle={handleFavoriteToggle}
|
||||||
className={`max-h-full max-w-full object-contain transition-all duration-1000 group-hover:scale-105 ${!isAdActive ? 'grayscale' : ''}`}
|
statusBadge={<AdStatusBadge statusId={vehicle.statusID} />}
|
||||||
alt={vehicle.versionName}
|
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>}
|
||||||
<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) */}
|
{/* 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">
|
<div className="glass p-6 md:p-12 rounded-2xl md:rounded-[3rem] border border-white/5 relative overflow-hidden group shadow-2xl">
|
||||||
|
|||||||
@@ -8,4 +8,7 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
|
build: {
|
||||||
|
sourcemap: false, // Seguridad: Ocultar código fuente en producción
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user