Compare commits

...

26 Commits

Author SHA1 Message Date
d8d7e5c2eb Fix: UpdatedAt del Sitemap 2026-03-21 20:17:27 -03:00
3135241aaa Feat: Sitemap y Robots 2026-03-21 20:11:50 -03:00
f837f446b9 Fix: Filtros ExplorarPage 2026-03-20 13:39:11 -03:00
8bd8384715 Fix: Orden de Fotos y Asignación de Portada 2026-03-18 15:52:24 -03:00
dd4b32dd7e Fix: Inserta Pagos SP Id_Cliente (0) y Id_TipoIva (5) - Feat: Google Analytics Tag 2026-03-18 14:55:26 -03:00
37869fa8b4 feat: mejorar área privada, invitaciones de registro y contraste de modales
- Se sincronizó el estado de autenticación en las páginas de publicación y mis avisos usando AuthContext.
- Se actualizó el diseño de las invitaciones a registro en áreas privadas.
- Se añadió soporte para abrir el modal de login directamente en la pestaña de registro.
- Se mejoró la legibilidad de todos los modales aumentando el desenfoque de fondo y la opacidad.
2026-03-16 13:09:11 -03:00
96fca4d9c7 Sistema de Notificaciones y Baja One-Click 2026-03-12 13:52:33 -03:00
f1a9bb9099 Fix: IdTipoiva debe ser 5 (Consumidor final) en lugar de 1 (Responsable Inscripto) 2026-03-12 10:04:12 -03:00
88b558afd4 Fix: Fecha de Vencimiento y Aviso Aplica a Admins (Tipo 3). 2026-03-11 10:17:56 -03:00
c60d7be293 Fix: Descripción (Saltos de líneas habilitados). 2026-03-02 10:54:28 -03:00
1bc93972ef Fix: Limpieza Registro "DeletedAd" de Tabla "Ads" y Registro de Estado (Palabra). 2026-02-26 20:28:01 -03:00
0802dae400 Feat: Papelera de Avisos
- Se añade la sección de Papelera de Avisos para los avisos eliminados que serán removidos de los registros a los 60 días del cambio de estado. Es esta sección se permite restaurar un aviso eliminado al estado "Borrador".
2026-02-26 20:17:52 -03:00
df777400ab Fix: Reemplazo y Limpieza de Archivos Img de Avisos. 2026-02-24 19:38:47 -03:00
47d47d42fb Fix: Ajuste tamaño de imagen y peso. 2026-02-24 19:17:21 -03:00
18a142e070 Feat: Cambio de Estados en Menu Admin - Avisos 2026-02-21 18:21:37 -03:00
2c3b7b2336 Fix: Valor Cero Input Precio 2026-02-20 20:48:04 -03:00
9e57eb7f54 Fix: CheckBox y Estados 2026-02-19 21:10:48 -03:00
8569f57a62 Fix: Usuarios Admin No Pueden Tener Avisos Propios 2026-02-19 19:59:23 -03:00
042cd8c6f1 Feat: Selector de Contacto Independiente y Formato de Precio
- Se divide la selección de medios de contacto entre los datos del usuario, permitiendo mostras el tipo de contacto que prefiera.
- Cuando el precio es igual a 0, se muestra la palabra "Consultar" en lugar de $0 o ARS 0.
2026-02-19 19:47:13 -03:00
2dfd5f1fb8 Fix: Axios Login 2026-02-19 18:52:07 -03:00
84bbb676f8 Fix: Texto de Contacto (No Disponibles) 2026-02-18 21:07:40 -03:00
ba9b0b3547 Fix: Galeria Movil, Contactos, Estado de Verificación de Mail al Cambiar Clave y Otros. 2026-02-18 21:00:35 -03:00
5a7c3f62f1 Fix: Sesion 1 Hora y Refresh con Redirección 2026-02-16 20:33:38 -03:00
bd45e89bd2 Fix: Estado Rechazado y Logica de Edición 2026-02-16 18:21:10 -03:00
46a41dc29d Fix: Estado Rechazado Y Acciones del Lado del Usuario 2026-02-13 15:52:33 -03:00
0ebb2b15e5 Fix: Recorte de URL Front Para Notificaciones. 2026-02-13 15:07:16 -03:00
50 changed files with 4193 additions and 986 deletions

27
.dockerignore Normal file
View File

@@ -0,0 +1,27 @@
# Git
.git
.gitignore
# Docker (No queremos que el Dockerfile se copie a sí mismo)
Dockerfile
docker-compose.yml
.dockerignore
# .NET (Backend)
**/bin/
**/obj/
**/.vs/
**/.vscode/
*.user
*.suo
# Node.js / React (Frontend)
**/node_modules/
**/dist/
**/build/
npm-debug.log*
# Varios
.DS_Store
.env
.history/

1
.gitignore vendored
View File

@@ -88,7 +88,6 @@ coverage/
#Documentación #Documentación
*.pdf *.pdf
*.txt
#Directorio de Imagenes #Directorio de Imagenes
Backend/MotoresArgentinosV2.API/wwwroot Backend/MotoresArgentinosV2.API/wwwroot

View File

@@ -18,12 +18,21 @@ public class AdminController : ControllerBase
private readonly MotoresV2DbContext _context; private readonly MotoresV2DbContext _context;
private readonly IAdSyncService _syncService; private readonly IAdSyncService _syncService;
private readonly INotificationService _notificationService; private readonly INotificationService _notificationService;
private readonly INotificationPreferenceService _prefService;
private readonly string _frontendUrl;
public AdminController(MotoresV2DbContext context, IAdSyncService syncService, INotificationService notificationService) public AdminController(
MotoresV2DbContext context,
IAdSyncService syncService,
INotificationService notificationService,
INotificationPreferenceService prefService,
IConfiguration config)
{ {
_context = context; _context = context;
_syncService = syncService; _syncService = syncService;
_notificationService = notificationService; _notificationService = notificationService;
_prefService = prefService;
_frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
} }
// --- MODERACIÓN --- // --- MODERACIÓN ---
@@ -43,6 +52,12 @@ public class AdminController : ControllerBase
.AsNoTracking() // Optimización de lectura .AsNoTracking() // Optimización de lectura
.AsQueryable(); .AsQueryable();
// Por defecto, ocultar eliminados a menos que se pida explícitamente
if (statusId != (int)AdStatusEnum.Deleted)
{
query = query.Where(a => a.StatusID != (int)AdStatusEnum.Deleted);
}
// Filtro por Texto (Marca, Modelo, Email Usuario, Nombre Usuario) // Filtro por Texto (Marca, Modelo, Email Usuario, Nombre Usuario)
if (!string.IsNullOrEmpty(q)) if (!string.IsNullOrEmpty(q))
{ {
@@ -154,12 +169,22 @@ public class AdminController : ControllerBase
}); });
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// Sincronizar a Legacy // Sincronizar a Legacy y notificar aprobación (categoría: sistema)
try try
{ {
await _syncService.SyncAdToLegacyAsync(id); await _syncService.SyncAdToLegacyAsync(id);
var adTitle = $"{ad.Brand?.Name} {ad.VersionName}"; var adTitle = $"{ad.Brand?.Name} {ad.VersionName}";
await _notificationService.SendAdStatusChangedEmailAsync(ad.User?.Email ?? string.Empty, adTitle, "APROBADO");
// Generamos el token de baja para la categoría "sistema"
string? unsubscribeUrl = null;
if (ad.User != null)
{
var rawToken = await _prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Sistema);
unsubscribeUrl = $"{_frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
}
await _notificationService.SendAdStatusChangedEmailAsync(
ad.User?.Email ?? string.Empty, adTitle, "APROBADO", null, unsubscribeUrl);
} }
catch (Exception) catch (Exception)
{ {
@@ -191,9 +216,19 @@ public class AdminController : ControllerBase
}); });
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// Notificar rechazo // Notificar rechazo (categoría: sistema)
var adTitle = $"{ad.Brand?.Name} {ad.VersionName}"; var adTitle = $"{ad.Brand?.Name} {ad.VersionName}";
await _notificationService.SendAdStatusChangedEmailAsync(ad.User?.Email ?? string.Empty, adTitle, "RECHAZADO", reason);
// Generamos el token de baja para la categoría "sistema"
string? unsubscribeUrl = null;
if (ad.User != null)
{
var rawToken = await _prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Sistema);
unsubscribeUrl = $"{_frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
}
await _notificationService.SendAdStatusChangedEmailAsync(
ad.User?.Email ?? string.Empty, adTitle, "RECHAZADO", reason, unsubscribeUrl);
return Ok(new { message = "Aviso rechazado." }); return Ok(new { message = "Aviso rechazado." });
} }
@@ -452,7 +487,11 @@ public class AdminController : ControllerBase
if (string.IsNullOrEmpty(q)) return Ok(new List<object>()); if (string.IsNullOrEmpty(q)) return Ok(new List<object>());
var users = await _context.Users var users = await _context.Users
.Where(u => (u.Email ?? "").Contains(q) || (u.UserName ?? "").Contains(q) || (u.FirstName ?? "").Contains(q) || (u.LastName ?? "").Contains(q)) .Where(u =>
((u.Email ?? "").Contains(q) || (u.UserName ?? "").Contains(q) || (u.FirstName ?? "").Contains(q) || (u.LastName ?? "").Contains(q))
&&
u.UserType != 3 // Excluir usuarios tipo 3 de la búsqueda para asignar avisos, ya que no pueden ser asignados a nuevos avisos en V2.
)
.Take(10) .Take(10)
.Select(u => new { u.UserID, u.UserName, u.Email, u.FirstName, u.LastName, u.PhoneNumber, u.IsBlocked }) .Select(u => new { u.UserID, u.UserName, u.Email, u.FirstName, u.LastName, u.PhoneNumber, u.IsBlocked })
.ToListAsync(); .ToListAsync();

View File

@@ -79,7 +79,12 @@ public class AdsV2Controller : ControllerBase
query = query.Where(a => a.Transmission == transmission || a.Features.Any(f => f.FeatureKey == "Transmision" && f.FeatureValue == transmission)); query = query.Where(a => a.Transmission == transmission || a.Features.Any(f => f.FeatureKey == "Transmision" && f.FeatureValue == transmission));
if (!string.IsNullOrEmpty(color)) if (!string.IsNullOrEmpty(color))
query = query.Where(a => a.Color == color || a.Features.Any(f => f.FeatureKey == "Color" && f.FeatureValue == color)); {
var lowerColor = color.ToLower();
query = query.Where(a =>
(a.Color != null && a.Color.ToLower().Contains(lowerColor)) ||
a.Features.Any(f => f.FeatureKey == "Color" && f.FeatureValue != null && f.FeatureValue.ToLower().Contains(lowerColor)));
}
if (!string.IsNullOrEmpty(Request.Query["segment"])) if (!string.IsNullOrEmpty(Request.Query["segment"]))
{ {
@@ -88,8 +93,8 @@ public class AdsV2Controller : ControllerBase
} }
if (!string.IsNullOrEmpty(Request.Query["location"])) if (!string.IsNullOrEmpty(Request.Query["location"]))
{ {
var loc = Request.Query["location"].ToString(); var loc = Request.Query["location"].ToString().ToLower();
query = query.Where(a => a.Location != null && a.Location.Contains(loc)); query = query.Where(a => a.Location != null && a.Location.ToLower().Contains(loc));
} }
if (!string.IsNullOrEmpty(Request.Query["condition"])) if (!string.IsNullOrEmpty(Request.Query["condition"]))
{ {
@@ -119,7 +124,7 @@ public class AdsV2Controller : ControllerBase
} }
else else
{ {
query = query.Where(a => a.UserID == userId.Value); query = query.Where(a => a.UserID == userId.Value && a.StatusID != (int)AdStatusEnum.Deleted);
} }
// --- LÓGICA DE BÚSQUEDA POR PALABRAS --- // --- LÓGICA DE BÚSQUEDA POR PALABRAS ---
@@ -326,6 +331,9 @@ public class AdsV2Controller : ControllerBase
contactPhone = ad.ContactPhone, contactPhone = ad.ContactPhone,
contactEmail = ad.ContactEmail, contactEmail = ad.ContactEmail,
displayContactInfo = ad.DisplayContactInfo, displayContactInfo = ad.DisplayContactInfo,
showPhone = ad.ShowPhone,
allowWhatsApp = ad.AllowWhatsApp,
showEmail = ad.ShowEmail,
photos = ad.Photos.Select(p => new { p.PhotoID, p.FilePath, p.IsCover, p.SortOrder }), photos = ad.Photos.Select(p => new { p.PhotoID, p.FilePath, p.IsCover, p.SortOrder }),
features = ad.Features.Select(f => new { f.FeatureKey, f.FeatureValue }), features = ad.Features.Select(f => new { f.FeatureKey, f.FeatureValue }),
brand = ad.Brand != null ? new { id = ad.Brand.BrandID, name = ad.Brand.Name } : null, brand = ad.Brand != null ? new { id = ad.Brand.BrandID, name = ad.Brand.Name } : null,
@@ -346,10 +354,25 @@ public class AdsV2Controller : ControllerBase
{ {
if (request.TargetUserID.HasValue) if (request.TargetUserID.HasValue)
{ {
// Validación adicional: Asegurar que el ID seleccionado no sea Admin (por seguridad extra)
var targetUser = await _context.Users.FindAsync(request.TargetUserID.Value);
if (targetUser != null && targetUser.UserType == 3)
{
return BadRequest("No se puede asignar un aviso a una cuenta de Administrador.");
}
finalUserId = request.TargetUserID.Value; finalUserId = request.TargetUserID.Value;
} }
else if (!string.IsNullOrEmpty(request.GhostUserEmail)) else if (!string.IsNullOrEmpty(request.GhostUserEmail))
{ {
// Verificamos si el email escrito manualmente pertenece a un Admin existente
var existingAdmin = await _context.Users
.AnyAsync(u => u.Email == request.GhostUserEmail && u.UserType == 3);
if (existingAdmin)
{
return BadRequest($"El correo '{request.GhostUserEmail}' pertenece a un Administrador. No puedes asignar avisos a administradores.");
}
// Pasamos nombre y apellido por separado // Pasamos nombre y apellido por separado
var ghost = await _identityService.CreateGhostUserAsync( var ghost = await _identityService.CreateGhostUserAsync(
request.GhostUserEmail, request.GhostUserEmail,
@@ -432,6 +455,9 @@ public class AdsV2Controller : ControllerBase
ContactPhone = request.ContactPhone, ContactPhone = request.ContactPhone,
ContactEmail = request.ContactEmail, ContactEmail = request.ContactEmail,
DisplayContactInfo = request.DisplayContactInfo, DisplayContactInfo = request.DisplayContactInfo,
ShowPhone = request.ShowPhone,
AllowWhatsApp = request.AllowWhatsApp,
ShowEmail = request.ShowEmail,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
@@ -500,6 +526,34 @@ public class AdsV2Controller : ControllerBase
return Ok(models); return Ok(models);
} }
private async Task NormalizeAdPhotosAsync(int adId)
{
var photos = await _context.AdPhotos
.Where(p => p.AdID == adId)
.OrderBy(p => p.SortOrder)
.ThenBy(p => p.PhotoID)
.ToListAsync();
bool coverAssigned = false;
for (int i = 0; i < photos.Count; i++)
{
photos[i].SortOrder = i;
if (photos[i].IsCover && !coverAssigned) {
coverAssigned = true;
} else {
photos[i].IsCover = false;
}
}
if (!coverAssigned && photos.Count > 0)
{
photos[0].IsCover = true;
}
await _context.SaveChangesAsync();
}
[HttpPost("{id}/upload-photos")] [HttpPost("{id}/upload-photos")]
public async Task<IActionResult> UploadPhotos(int id, [FromForm] IFormFileCollection files) public async Task<IActionResult> UploadPhotos(int id, [FromForm] IFormFileCollection files)
{ {
@@ -552,6 +606,7 @@ public class AdsV2Controller : ControllerBase
if (uploadedCount > 0) if (uploadedCount > 0)
{ {
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
await NormalizeAdPhotosAsync(id);
return Ok(new return Ok(new
{ {
message = $"{uploadedCount} fotos procesadas.", message = $"{uploadedCount} fotos procesadas.",
@@ -583,6 +638,8 @@ public class AdsV2Controller : ControllerBase
_context.AdPhotos.Remove(photo); _context.AdPhotos.Remove(photo);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
await NormalizeAdPhotosAsync(photo.AdID);
return NoContent(); return NoContent();
} }
@@ -655,8 +712,18 @@ public class AdsV2Controller : ControllerBase
ad.ContactPhone = updatedAdDto.ContactPhone; ad.ContactPhone = updatedAdDto.ContactPhone;
ad.ContactEmail = updatedAdDto.ContactEmail; ad.ContactEmail = updatedAdDto.ContactEmail;
ad.DisplayContactInfo = updatedAdDto.DisplayContactInfo; ad.DisplayContactInfo = updatedAdDto.DisplayContactInfo;
ad.ShowPhone = updatedAdDto.ShowPhone;
ad.AllowWhatsApp = updatedAdDto.AllowWhatsApp;
ad.ShowEmail = updatedAdDto.ShowEmail;
// Nota: IsFeatured y otros campos sensibles se manejan por separado (pago/admin) // Nota: IsFeatured y otros campos sensibles se manejan por separado (pago/admin)
// LÓGICA DE ESTADO TRAS RECHAZO
if (!IsUserAdmin() && ad.StatusID == (int)AdStatusEnum.Rejected)
{
// Si estaba rechazado y el dueño lo edita, vuelve a revisión.
ad.StatusID = (int)AdStatusEnum.ModerationPending;
}
// 📝 AUDITORÍA // 📝 AUDITORÍA
var adBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? ""; var adBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
_context.AuditLogs.Add(new AuditLog _context.AuditLogs.Add(new AuditLog
@@ -732,6 +799,7 @@ public class AdsV2Controller : ControllerBase
var ads = await _context.Favorites var ads = await _context.Favorites
.Where(f => f.UserID == userId) .Where(f => f.UserID == userId)
.Join(_context.Ads, f => f.AdID, a => a.AdID, (f, a) => a) .Join(_context.Ads, f => f.AdID, a => a.AdID, (f, a) => a)
.Where(a => a.StatusID != (int)AdStatusEnum.Deleted)
.Include(a => a.Photos) .Include(a => a.Photos)
.Select(a => new .Select(a => new
{ {
@@ -770,11 +838,17 @@ public class AdsV2Controller : ControllerBase
return BadRequest("Debes completar el pago para activar este aviso."); return BadRequest("Debes completar el pago para activar este aviso.");
} }
// 2. NUEVO: No tocar si está en moderación // 2. No tocar si está en moderación
if (ad.StatusID == (int)AdStatusEnum.ModerationPending) if (ad.StatusID == (int)AdStatusEnum.ModerationPending)
{ {
return BadRequest("El aviso está en revisión. Espera la aprobación del administrador."); return BadRequest("El aviso está en revisión. Espera la aprobación del administrador.");
} }
// 3. Bloquear si está RECHAZADO
if (ad.StatusID == (int)AdStatusEnum.Rejected)
{
return BadRequest("Este aviso fue rechazado. Debes editarlo y corregirlo para que sea revisado nuevamente.");
}
} }
// Validar estados destino permitidos para el usuario // Validar estados destino permitidos para el usuario
@@ -787,6 +861,15 @@ public class AdsV2Controller : ControllerBase
int oldStatus = ad.StatusID; int oldStatus = ad.StatusID;
ad.StatusID = newStatus; ad.StatusID = newStatus;
if (newStatus == (int)AdStatusEnum.Deleted)
{
ad.DeletedAt = DateTime.UtcNow;
}
else
{
ad.DeletedAt = null;
}
// 📝 AUDITORÍA // 📝 AUDITORÍA
var statusBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? ""; var statusBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
_context.AuditLogs.Add(new AuditLog _context.AuditLogs.Add(new AuditLog
@@ -795,7 +878,7 @@ public class AdsV2Controller : ControllerBase
Entity = "Ad", Entity = "Ad",
EntityID = ad.AdID, EntityID = ad.AdID,
UserID = userId, UserID = userId,
Details = $"Estado de aviso ({statusBrandName} {ad.VersionName}) cambiado de {oldStatus} a {newStatus}." Details = $"Estado de aviso ({statusBrandName} {ad.VersionName}) cambiado de {AdStatusHelper.GetStatusDisplayName(oldStatus)} a {AdStatusHelper.GetStatusDisplayName(newStatus)}."
}); });
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();

View File

@@ -11,7 +11,6 @@ namespace MotoresArgentinosV2.API.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[EnableRateLimiting("AuthPolicy")]
public class AuthController : ControllerBase public class AuthController : ControllerBase
{ {
private readonly IIdentityService _identityService; private readonly IIdentityService _identityService;
@@ -28,21 +27,21 @@ public class AuthController : ControllerBase
} }
// Helper privado para cookies // Helper privado para cookies
private void SetTokenCookie(string token, string cookieName) private void SetTokenCookie(string token, string cookieName, DateTime expires)
{ {
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.AddMinutes(15), Expires = expires,
Secure = true, // Solo HTTPS (Para tests locales 'Secure = false' temporalmente) Secure = true, // Solo HTTPS (Para tests locales 'Secure = false' temporalmente)
SameSite = SameSiteMode.Strict, // Protección CSRF (Strict para máxima seguridad, pero puede ser Lax si hay problemas con redirecciones y testeos locales) SameSite = SameSiteMode.Strict,//SameSiteMode.Strict, Protección CSRF (Strict para máxima seguridad) - (Para tests locales SameSiteMode.Lax temporalmente)
IsEssential = true IsEssential = true
}; };
Response.Cookies.Append(cookieName, token, cookieOptions); Response.Cookies.Append(cookieName, token, cookieOptions);
} }
[HttpPost("login")] [HttpPost("login")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO (5 intentos/min) [EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> Login([FromBody] LoginRequest request) public async Task<IActionResult> Login([FromBody] LoginRequest request)
{ {
var (user, message) = await _identityService.AuthenticateAsync(request.Username, request.Password); var (user, message) = await _identityService.AuthenticateAsync(request.Username, request.Password);
@@ -89,8 +88,10 @@ public class AuthController : ControllerBase
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// 3. Setear Cookies // 3. Setear Cookies
SetTokenCookie(jwtToken, "accessToken"); // El AccessToken dura 60 min (coincide con JWT)
SetTokenCookie(refreshToken.Token, "refreshToken"); SetTokenCookie(jwtToken, "accessToken", DateTime.UtcNow.AddMinutes(60));
// El RefreshToken dura 7 días (coincide con DB)
SetTokenCookie(refreshToken.Token, "refreshToken", DateTime.UtcNow.AddDays(7));
// 4. Audit Log // 4. Audit Log
_context.AuditLogs.Add(new AuditLog _context.AuditLogs.Add(new AuditLog
@@ -122,7 +123,6 @@ public class AuthController : ControllerBase
} }
[HttpPost("refresh-token")] [HttpPost("refresh-token")]
// NO PROTEGIDO ESTRICTAMENTE (Usa límite global)
public async Task<IActionResult> RefreshToken() public async Task<IActionResult> RefreshToken()
{ {
var refreshToken = Request.Cookies["refreshToken"]; var refreshToken = Request.Cookies["refreshToken"];
@@ -154,14 +154,14 @@ public class AuthController : ControllerBase
var newJwtToken = _tokenService.GenerateJwtToken(user); var newJwtToken = _tokenService.GenerateJwtToken(user);
// Actualizar Cookies // Actualizar Cookies
SetTokenCookie(newJwtToken, "accessToken"); SetTokenCookie(newJwtToken, "accessToken", DateTime.UtcNow.AddMinutes(60));
SetTokenCookie(newRefreshToken.Token, "refreshToken"); // El refresh token DEBE durar 7 días para mantener la sesión viva
SetTokenCookie(newRefreshToken.Token, "refreshToken", DateTime.UtcNow.AddDays(7));
return Ok(new { message = "Token renovado" }); return Ok(new { message = "Token renovado" });
} }
[HttpPost("logout")] [HttpPost("logout")]
// NO PROTEGIDO ESTRICTAMENTE
public IActionResult Logout() public IActionResult Logout()
{ {
Response.Cookies.Delete("accessToken"); Response.Cookies.Delete("accessToken");
@@ -287,8 +287,8 @@ public class AuthController : ControllerBase
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// Setear Cookies Seguras // Setear Cookies Seguras
SetTokenCookie(token, "accessToken"); SetTokenCookie(token, "accessToken", DateTime.UtcNow.AddMinutes(60));
SetTokenCookie(refreshToken.Token, "refreshToken"); SetTokenCookie(refreshToken.Token, "refreshToken", DateTime.UtcNow.AddDays(7));
_context.AuditLogs.Add(new AuditLog _context.AuditLogs.Add(new AuditLog
{ {
@@ -386,7 +386,7 @@ public class AuthController : ControllerBase
} }
[HttpPost("register")] [HttpPost("register")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO [EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request) public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{ {
var (success, message) = await _identityService.RegisterUserAsync(request); var (success, message) = await _identityService.RegisterUserAsync(request);
@@ -407,7 +407,7 @@ public class AuthController : ControllerBase
} }
[HttpPost("verify-email")] [HttpPost("verify-email")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO [EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest request) public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest request)
{ {
var (success, message) = await _identityService.VerifyEmailAsync(request.Token); var (success, message) = await _identityService.VerifyEmailAsync(request.Token);
@@ -428,7 +428,7 @@ public class AuthController : ControllerBase
} }
[HttpPost("resend-verification")] [HttpPost("resend-verification")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO [EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> ResendVerification([FromBody] ResendVerificationRequest request) public async Task<IActionResult> ResendVerification([FromBody] ResendVerificationRequest request)
{ {
var (success, message) = await _identityService.ResendVerificationEmailAsync(request.Email); var (success, message) = await _identityService.ResendVerificationEmailAsync(request.Email);
@@ -437,7 +437,7 @@ public class AuthController : ControllerBase
} }
[HttpPost("forgot-password")] [HttpPost("forgot-password")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO [EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request) public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
{ {
var (success, message) = await _identityService.ForgotPasswordAsync(request.Email); var (success, message) = await _identityService.ForgotPasswordAsync(request.Email);
@@ -452,7 +452,7 @@ public class AuthController : ControllerBase
} }
[HttpPost("reset-password")] [HttpPost("reset-password")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO [EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request) public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
{ {
var (success, message) = await _identityService.ResetPasswordAsync(request.Token, request.NewPassword); var (success, message) = await _identityService.ResetPasswordAsync(request.Token, request.NewPassword);
@@ -474,7 +474,7 @@ public class AuthController : ControllerBase
[Authorize] [Authorize]
[HttpPost("change-password")] [HttpPost("change-password")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO [EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request) public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{ {
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0"); var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");

View File

@@ -14,11 +14,19 @@ public class ChatController : ControllerBase
{ {
private readonly MotoresV2DbContext _context; private readonly MotoresV2DbContext _context;
private readonly INotificationService _notificationService; private readonly INotificationService _notificationService;
private readonly INotificationPreferenceService _prefService;
private readonly string _frontendUrl;
public ChatController(MotoresV2DbContext context, INotificationService notificationService) public ChatController(
MotoresV2DbContext context,
INotificationService notificationService,
INotificationPreferenceService prefService,
IConfiguration config)
{ {
_context = context; _context = context;
_notificationService = notificationService; _notificationService = notificationService;
_prefService = prefService;
_frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
} }
[HttpPost("send")] [HttpPost("send")]
@@ -38,6 +46,9 @@ public class ChatController : ControllerBase
var sender = await _context.Users.FindAsync(msg.SenderID); var sender = await _context.Users.FindAsync(msg.SenderID);
if (receiver != null && !string.IsNullOrEmpty(receiver.Email)) if (receiver != null && !string.IsNullOrEmpty(receiver.Email))
{
// Solo enviar correo si la preferencia "mensajes" está habilitada
if (await _prefService.IsEnabledAsync(receiver.UserID, NotificationCategory.Mensajes))
{ {
// LÓGICA DE NOMBRE DE REMITENTE // LÓGICA DE NOMBRE DE REMITENTE
string senderDisplayName; string senderDisplayName;
@@ -54,11 +65,17 @@ public class ChatController : ControllerBase
senderDisplayName = $"El usuario {name}"; senderDisplayName = $"El usuario {name}";
} }
// Generamos el token de baja para la categoría "mensajes"
var rawToken = await _prefService.GetOrCreateUnsubscribeTokenAsync(receiver.UserID, NotificationCategory.Mensajes);
var unsubscribeUrl = $"{_frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await _notificationService.SendChatNotificationEmailAsync( await _notificationService.SendChatNotificationEmailAsync(
receiver.Email, receiver.Email,
senderDisplayName, // Pasamos el nombre formateado senderDisplayName, // Pasamos el nombre formateado
msg.MessageText, msg.MessageText,
msg.AdID); msg.AdID,
unsubscribeUrl); // Se incluye URL de baja en el footer
}
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -74,8 +91,12 @@ public class ChatController : ControllerBase
public async Task<IActionResult> GetInbox(int userId) public async Task<IActionResult> GetInbox(int userId)
{ {
// Obtener todas las conversaciones donde el usuario es remitente o destinatario // Obtener todas las conversaciones donde el usuario es remitente o destinatario
// Pero filtramos los que pertenecen a avisos eliminados (StatusID != 9)
var messages = await _context.ChatMessages var messages = await _context.ChatMessages
.Where(m => m.SenderID == userId || m.ReceiverID == userId) .Where(m => m.SenderID == userId || m.ReceiverID == userId)
.Join(_context.Ads, m => m.AdID, a => a.AdID, (m, a) => new { m, a })
.Where(x => x.a.StatusID != (int)AdStatusEnum.Deleted)
.Select(x => x.m)
.OrderByDescending(m => m.SentAt) .OrderByDescending(m => m.SentAt)
.ToListAsync(); .ToListAsync();
@@ -119,7 +140,8 @@ public class ChatController : ControllerBase
} }
var count = await _context.ChatMessages var count = await _context.ChatMessages
.CountAsync(m => m.ReceiverID == userId && !m.IsRead); .Join(_context.Ads, m => m.AdID, a => a.AdID, (m, a) => new { m, a })
.CountAsync(x => x.m.ReceiverID == userId && !x.m.IsRead && x.a.StatusID != (int)AdStatusEnum.Deleted);
return Ok(new { count }); return Ok(new { count });
} }

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Core.DTOs; using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Entities; using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Infrastructure.Data; using MotoresArgentinosV2.Infrastructure.Data;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using System.Security.Claims; using System.Security.Claims;
@@ -14,10 +15,14 @@ namespace MotoresArgentinosV2.API.Controllers;
public class ProfileController : ControllerBase public class ProfileController : ControllerBase
{ {
private readonly MotoresV2DbContext _context; private readonly MotoresV2DbContext _context;
private readonly INotificationPreferenceService _notifPrefService;
public ProfileController(MotoresV2DbContext context) public ProfileController(
MotoresV2DbContext context,
INotificationPreferenceService notifPrefService)
{ {
_context = context; _context = context;
_notifPrefService = notifPrefService;
} }
[HttpGet] [HttpGet]
@@ -71,4 +76,43 @@ public class ProfileController : ControllerBase
return Ok(new { message = "Perfil actualizado con éxito." }); return Ok(new { message = "Perfil actualizado con éxito." });
} }
// ─── Preferencias de Notificación ────────────────────────────────────────
/// <summary>
/// Obtiene las preferencias de notificación del usuario autenticado.
/// GET api/profile/notification-preferences
/// </summary>
[HttpGet("notification-preferences")]
public async Task<IActionResult> GetNotificationPreferences()
{
var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "0");
var prefs = await _notifPrefService.GetPreferencesAsync(userId);
return Ok(prefs);
}
/// <summary>
/// Actualiza las preferencias de notificación del usuario autenticado.
/// PUT api/profile/notification-preferences
/// </summary>
[HttpPut("notification-preferences")]
public async Task<IActionResult> UpdateNotificationPreferences(
[FromBody] UpdateNotificationPreferencesDto dto)
{
var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "0");
await _notifPrefService.UpdatePreferencesAsync(userId, dto);
// Registramos en auditoría
_context.AuditLogs.Add(new AuditLog
{
Action = "NOTIFICATION_PREFS_UPDATED",
Entity = "User",
EntityID = userId,
UserID = userId,
Details = "Usuario actualizó sus preferencias de notificación."
});
await _context.SaveChangesAsync();
return Ok(new { message = "Preferencias actualizadas con éxito." });
}
} }

View File

@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Mvc;
using MotoresArgentinosV2.Core.Interfaces;
namespace MotoresArgentinosV2.API.Controllers;
/// <summary>
/// Controlador PÚBLICO (sin autenticación) para gestionar la baja de correos.
/// El token del enlace garantiza que no se puede dar de baja a otro usuario.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class UnsubscribeController : ControllerBase
{
private readonly INotificationPreferenceService _prefService;
private readonly IConfiguration _config;
public UnsubscribeController(
INotificationPreferenceService prefService,
IConfiguration config)
{
_prefService = prefService;
_config = config;
}
/// <summary>
/// Procesa la baja one-click desde el enlace del correo.
/// GET api/unsubscribe?token=xxxxx
/// Redirige al frontend con el resultado para mostrar una página amigable.
/// </summary>
[HttpGet]
public async Task<IActionResult> Unsubscribe([FromQuery] string token)
{
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { success = false, message = "Token inválido o faltante." });
var (success, categoryLabel) = await _prefService.UnsubscribeAsync(token);
if (success)
{
return Ok(new { success = true, category = categoryLabel });
}
return BadRequest(new { success = false, message = "El enlace de baja ha expirado o no es válido." });
}
}

View File

@@ -28,7 +28,8 @@ builder.Logging.AddConsole();
builder.Logging.AddDebug(); builder.Logging.AddDebug();
// 🔒 CORS POLICY // 🔒 CORS POLICY
var frontendUrls = (builder.Configuration["AppSettings:FrontendUrl"] ?? "http://localhost:5173").Split(','); var frontendUrlConfig = builder.Configuration["AppSettings:FrontendUrl"] ?? "http://localhost:5173,https://clasificados.eldia.com";
var frontendUrls = frontendUrlConfig.Split(',');
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("AllowSpecificOrigin", options.AddPolicy("AllowSpecificOrigin",
@@ -109,14 +110,16 @@ builder.Services.AddScoped<IPasswordService, PasswordService>();
builder.Services.AddScoped<IIdentityService, IdentityService>(); builder.Services.AddScoped<IIdentityService, IdentityService>();
builder.Services.AddScoped<ILegacyPaymentService, LegacyPaymentService>(); builder.Services.AddScoped<ILegacyPaymentService, LegacyPaymentService>();
builder.Services.AddScoped<IPaymentService, MercadoPagoService>(); builder.Services.AddScoped<IPaymentService, MercadoPagoService>();
builder.Services.AddScoped<IAdSyncService, AdSyncService>(); builder.Services.AddScoped<IAdSyncService, AdSyncService>();
builder.Services.AddScoped<INotificationService, NotificationService>(); builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddScoped<ITokenService, TokenService>(); builder.Services.AddScoped<INotificationPreferenceService, NotificationPreferenceService>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("SmtpSettings")); builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("SmtpSettings"));
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>(); builder.Services.AddHostedService<TokenCleanupService>();
builder.Services.AddHostedService<SitemapGeneratorService>();
// 🔒 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");
@@ -161,14 +164,16 @@ app.UseMiddleware<MotoresArgentinosV2.API.Middleware.ExceptionHandlingMiddleware
// USAR EL MIDDLEWARE DE HEADERS // USAR EL MIDDLEWARE DE HEADERS
app.UseForwardedHeaders(); app.UseForwardedHeaders();
// 🔒 HEADERS DE SEGURIDAD MIDDLEWARE // 🔒 HEADERS DE SEGURIDAD & PNA FIX MIDDLEWARE
app.Use(async (context, next) => app.Use(async (context, next) =>
{ {
// --- 1. SEGURIDAD EXISTENTE (HARDENING) ---
context.Response.Headers.Append("X-Frame-Options", "DENY"); context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
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");
context.Response.Headers.Append("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"); context.Response.Headers.Append("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()");
string csp = "default-src 'self'; " + string csp = "default-src 'self'; " +
"img-src 'self' data: https: blob:; " + "img-src 'self' data: https: blob:; " +
"script-src 'self' 'unsafe-inline'; " + "script-src 'self' 'unsafe-inline'; " +
@@ -176,11 +181,35 @@ app.Use(async (context, next) =>
"connect-src 'self' https: ws: wss:; " + "connect-src 'self' https: ws: wss:; " +
"object-src 'none'; " + "object-src 'none'; " +
"base-uri 'self'; " + "base-uri 'self'; " +
"form-action 'self' https://developers-ventasonline.payway.com.ar; " + "form-action 'self'; " +
"frame-ancestors 'none';"; "frame-ancestors 'none';";
context.Response.Headers.Append("Content-Security-Policy", csp); context.Response.Headers.Append("Content-Security-Policy", csp);
context.Response.Headers.Remove("Server"); context.Response.Headers.Remove("Server");
context.Response.Headers.Remove("X-Powered-By"); context.Response.Headers.Remove("X-Powered-By");
// Esto permite que el sitio público (eldia.com) pida recursos a tu IP local/privada.
// Si el navegador pregunta explícitamente "Puedo acceder a la red privada?"
if (context.Request.Headers.ContainsKey("Access-Control-Request-Private-Network"))
{
context.Response.Headers.Append("Access-Control-Allow-Private-Network", "true");
}
// O si estamos sirviendo imágenes/API (Backup por si el navegador no manda el header de request)
else if (context.Request.Path.StartsWithSegments("/uploads") || context.Request.Path.StartsWithSegments("/api"))
{
context.Response.Headers.Append("Access-Control-Allow-Private-Network", "true");
}
// Asegurar que el header esté presente en las peticiones OPTIONS (Preflight)
if (context.Request.Method == "OPTIONS")
{
// A veces es necesario forzarlo aquí también para que el preflight pase
if (!context.Response.Headers.ContainsKey("Access-Control-Allow-Private-Network"))
{
context.Response.Headers.Append("Access-Control-Allow-Private-Network", "true");
}
}
await next(); await next();
}); });
@@ -199,6 +228,16 @@ app.UseHttpsRedirection();
app.UseStaticFiles(); app.UseStaticFiles();
// 🔒 APLICAR CORS & RATE LIMIT // 🔒 APLICAR CORS & RATE LIMIT
app.Use(async (context, next) =>
{
// Para las peticiones de imágenes, agregamos el header PNA
if (context.Request.Path.StartsWithSegments("/uploads"))
{
context.Response.Headers.Append("Access-Control-Allow-Private-Network", "true");
}
await next();
});
app.UseCors("AllowSpecificOrigin"); app.UseCors("AllowSpecificOrigin");
app.UseRateLimiter(); app.UseRateLimiter();

View File

@@ -42,7 +42,7 @@ public class CreateAdRequestDto
public string Currency { get; set; } = "ARS"; public string Currency { get; set; } = "ARS";
[StringLength(1000, ErrorMessage = "La descripción no puede superar los 1000 caracteres.")] [StringLength(1000, ErrorMessage = "La descripción no puede superar los 1000 caracteres.")]
[RegularExpression(@"^(?!.*<script>).*$", ErrorMessage = "Contenido no permitido.")] [RegularExpression(@"^(?s)(?!.*<script\b).*$", ErrorMessage = "Contenido no permitido.")]
[JsonPropertyName("description")] [JsonPropertyName("description")]
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
@@ -95,6 +95,15 @@ public class CreateAdRequestDto
[JsonPropertyName("displayContactInfo")] [JsonPropertyName("displayContactInfo")]
public bool DisplayContactInfo { get; set; } = true; public bool DisplayContactInfo { get; set; } = true;
[JsonPropertyName("showPhone")]
public bool ShowPhone { get; set; } = true;
[JsonPropertyName("allowWhatsApp")]
public bool AllowWhatsApp { get; set; } = true;
[JsonPropertyName("showEmail")]
public bool ShowEmail { get; set; } = true;
// --- Admin Only --- // --- Admin Only ---
[JsonPropertyName("targetUserID")] [JsonPropertyName("targetUserID")]

View File

@@ -0,0 +1,24 @@
namespace MotoresArgentinosV2.Core.DTOs;
/// <summary>
/// DTO para mostrar el estado de todas las categorías de notificación de un usuario.
/// </summary>
public class NotificationPreferencesDto
{
// true = el usuario SÍ quiere recibir este tipo de correo
public bool Sistema { get; set; } = true;
public bool Marketing { get; set; } = true;
public bool Rendimiento { get; set; } = true;
public bool Mensajes { get; set; } = true;
}
/// <summary>
/// DTO para actualizar las preferencias desde el perfil del usuario.
/// </summary>
public class UpdateNotificationPreferencesDto
{
public bool Sistema { get; set; }
public bool Marketing { get; set; }
public bool Rendimiento { get; set; }
public bool Mensajes { get; set; }
}

View File

@@ -35,6 +35,27 @@ public enum AdStatusEnum
Reserved = 10 Reserved = 10
} }
public static class AdStatusHelper
{
public static string GetStatusDisplayName(int statusId)
{
return statusId switch
{
1 => "Borrador",
2 => "Pago Pendiente",
3 => "En Revisión",
4 => "Activo",
5 => "Rechazado",
6 => "Pausado",
7 => "Vendido",
8 => "Vencido",
9 => "Eliminado",
10 => "Reservado",
_ => "Desconocido"
};
}
}
public class User public class User
{ {
public int UserID { get; set; } public int UserID { get; set; }
@@ -125,6 +146,11 @@ public class Ad
public string? ContactPhone { get; set; } public string? ContactPhone { get; set; }
public string? ContactEmail { get; set; } public string? ContactEmail { get; set; }
public bool DisplayContactInfo { get; set; } = true; public bool DisplayContactInfo { get; set; } = true;
public bool ShowPhone { get; set; } = true;
public bool AllowWhatsApp { get; set; } = true;
public bool ShowEmail { get; set; } = true;
public bool IsFeatured { get; set; } public bool IsFeatured { get; set; }
public int StatusID { get; set; } public int StatusID { get; set; }
@@ -216,3 +242,60 @@ public class AdViewLog
public string IPAddress { get; set; } = string.Empty; public string IPAddress { get; set; } = string.Empty;
public DateTime ViewDate { get; set; } = DateTime.UtcNow; public DateTime ViewDate { get; set; } = DateTime.UtcNow;
} }
/// <summary>
/// Categorías de correo disponibles para preferencias de notificación.
/// </summary>
public static class NotificationCategory
{
public const string Sistema = "sistema"; // Avisos del sistema (vencimientos, pagos)
public const string Marketing = "marketing"; // Boletines y promociones
public const string Rendimiento = "rendimiento"; // Resumen semanal de visitas/favoritos
public const string Mensajes = "mensajes"; // Recordatorio de mensajes no leídos
public static readonly string[] Todos = [Sistema, Marketing, Rendimiento, Mensajes];
}
/// <summary>
/// Preferencia de notificación por email de un usuario para una categoría específica.
/// Por defecto el usuario recibe todos los correos; el registro solo se crea al DESACTIVAR.
/// </summary>
public class UserNotificationPreference
{
public int PreferenceID { get; set; }
public int UserID { get; set; }
public User User { get; set; } = null!;
// Categoría: "sistema", "marketing", "rendimiento", "mensajes"
public string Category { get; set; } = string.Empty;
// false = usuario optó por NO recibir esta categoría
public bool IsEnabled { get; set; } = true;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Token de baja firmado con HMAC-SHA256 para darse de baja sin login.
/// El token incluye UserID + Category y es válido hasta ExpiresAt.
/// </summary>
public class UnsubscribeToken
{
public int TokenID { get; set; }
public int UserID { get; set; }
public User User { get; set; } = null!;
// Categoría a la que aplica el token
public string Category { get; set; } = string.Empty;
// Token opaco (GUID + HMAC) que va en el enlace del correo
public string Token { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Tokens de baja expiran a los 365 días (la URL del correo puede ser vieja)
public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddDays(365);
// true cuando ya fue utilizado para darse de baja
public bool IsUsed { get; set; } = false;
}

View File

@@ -0,0 +1,39 @@
using MotoresArgentinosV2.Core.DTOs;
namespace MotoresArgentinosV2.Core.Interfaces;
/// <summary>
/// Servicio para gestionar preferencias de notificación por email y tokens de baja.
/// </summary>
public interface INotificationPreferenceService
{
/// <summary>
/// Retorna las preferencias actuales del usuario (todas las categorías).
/// </summary>
Task<NotificationPreferencesDto> GetPreferencesAsync(int userId);
/// <summary>
/// Guarda las preferencias enviadas desde el perfil del usuario.
/// </summary>
Task UpdatePreferencesAsync(int userId, UpdateNotificationPreferencesDto dto);
/// <summary>
/// Verifica si un usuario tiene habilitada una categoría de correo.
/// Usa para chequear ANTES de enviar cada notificación del sistema.
/// </summary>
Task<bool> IsEnabledAsync(int userId, string category);
/// <summary>
/// Genera (o reutiliza) un token de baja firmado para incluir en el footer de un correo.
/// </summary>
Task<string> GetOrCreateUnsubscribeTokenAsync(int userId, string category);
/// <summary>
/// Procesa la baja del usuario desde el enlace one-click (sin login).
/// Valida el token y actualiza la preferencia correspondiente.
/// </summary>
/// <returns>
/// (Success, CategoryLabel) indicando si se procesó OK y el nombre legible de la categoría.
/// </returns>
Task<(bool Success, string CategoryLabel)> UnsubscribeAsync(string token);
}

View File

@@ -2,13 +2,30 @@ namespace MotoresArgentinosV2.Core.Interfaces;
public interface INotificationService public interface INotificationService
{ {
Task SendChatNotificationEmailAsync(string toEmail, string fromUser, string message, int adId); // Categoría: "mensajes"
Task SendAdStatusChangedEmailAsync(string toEmail, string adTitle, string status, string? reason = null); Task SendChatNotificationEmailAsync(string toEmail, string fromUser, string message, int adId, string? unsubscribeUrl = null);
// Categoría: "sistema"
Task SendAdStatusChangedEmailAsync(string toEmail, string adTitle, string status, string? reason = null, string? unsubscribeUrl = null);
// SIN baja — correo crítico de seguridad, siempre se envía
Task SendSecurityAlertEmailAsync(string toEmail, string actionDescription); Task SendSecurityAlertEmailAsync(string toEmail, string actionDescription);
Task SendExpirationWarningEmailAsync(string toEmail, string userName, string adTitle, DateTime expirationDate);
Task SendAdExpiredEmailAsync(string toEmail, string userName, string adTitle); // Categoría: "sistema"
Task SendWeeklyPerformanceEmailAsync(string toEmail, string userName, string adTitle, int views, int favorites); Task SendExpirationWarningEmailAsync(string toEmail, string userName, string adTitle, DateTime expirationDate, string? unsubscribeUrl = null);
Task SendPaymentReminderEmailAsync(string toEmail, string userName, string adTitle, string link);
// Categoría: "sistema"
Task SendAdExpiredEmailAsync(string toEmail, string userName, string adTitle, string? unsubscribeUrl = null);
// Categoría: "rendimiento"
Task SendWeeklyPerformanceEmailAsync(string toEmail, string userName, string adTitle, int views, int favorites, string? unsubscribeUrl = null);
// Categoría: "marketing"
Task SendPaymentReminderEmailAsync(string toEmail, string userName, string adTitle, string link, string? unsubscribeUrl = null);
// SIN baja — recibo de pago transaccional, siempre se envía
Task SendPaymentReceiptEmailAsync(string toEmail, string userName, string adTitle, decimal amount, string operationCode); Task SendPaymentReceiptEmailAsync(string toEmail, string userName, string adTitle, decimal amount, string operationCode);
Task SendUnreadMessagesReminderEmailAsync(string toEmail, string userName, int unreadCount);
// Categoría: "mensajes"
Task SendUnreadMessagesReminderEmailAsync(string toEmail, string userName, int unreadCount, string? unsubscribeUrl = null);
} }

View File

@@ -23,6 +23,8 @@ public class MotoresV2DbContext : DbContext
public DbSet<PaymentMethod> PaymentMethods { get; set; } public DbSet<PaymentMethod> PaymentMethods { get; set; }
public DbSet<RefreshToken> RefreshTokens { get; set; } public DbSet<RefreshToken> RefreshTokens { get; set; }
public DbSet<AdViewLog> AdViewLogs { get; set; } public DbSet<AdViewLog> AdViewLogs { get; set; }
public DbSet<UserNotificationPreference> NotificationPreferences { get; set; }
public DbSet<UnsubscribeToken> UnsubscribeTokens { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -67,8 +69,31 @@ public class MotoresV2DbContext : DbContext
modelBuilder.Entity<AdFeature>().ToTable("AdFeatures"); modelBuilder.Entity<AdFeature>().ToTable("AdFeatures");
modelBuilder.Entity<TransactionRecord>().ToTable("Transactions"); modelBuilder.Entity<TransactionRecord>().ToTable("Transactions");
// Configuración de AdViewLog
modelBuilder.Entity<AdViewLog>().ToTable("AdViewLogs"); modelBuilder.Entity<AdViewLog>().ToTable("AdViewLogs");
modelBuilder.Entity<AdViewLog>().HasIndex(l => new { l.AdID, l.IPAddress, l.ViewDate }); modelBuilder.Entity<AdViewLog>().HasIndex(l => new { l.AdID, l.IPAddress, l.ViewDate });
// Configuración de UserNotificationPreference
modelBuilder.Entity<UserNotificationPreference>().ToTable("UserNotificationPreferences");
modelBuilder.Entity<UserNotificationPreference>().HasKey(p => p.PreferenceID);
// Índice único: un usuario no puede tener dos registros para la misma categoría
modelBuilder.Entity<UserNotificationPreference>()
.HasIndex(p => new { p.UserID, p.Category })
.IsUnique();
modelBuilder.Entity<UserNotificationPreference>()
.HasOne(p => p.User)
.WithMany()
.HasForeignKey(p => p.UserID)
.OnDelete(DeleteBehavior.Cascade);
// Configuración de UnsubscribeToken
modelBuilder.Entity<UnsubscribeToken>().ToTable("UnsubscribeTokens");
modelBuilder.Entity<UnsubscribeToken>().HasKey(t => t.TokenID);
// Índice para búsqueda rápida por valor del token
modelBuilder.Entity<UnsubscribeToken>().HasIndex(t => t.Token).IsUnique();
modelBuilder.Entity<UnsubscribeToken>()
.HasOne(t => t.User)
.WithMany()
.HasForeignKey(t => t.UserID)
.OnDelete(DeleteBehavior.Cascade);
} }
} }

View File

@@ -38,6 +38,7 @@ public class AdExpirationService : BackgroundService
await PermanentDeleteOldDeletedAdsAsync(); await PermanentDeleteOldDeletedAdsAsync();
await CleanupOldRefreshTokensAsync(); await CleanupOldRefreshTokensAsync();
await CleanupAdViewLogsAsync(); await CleanupAdViewLogsAsync();
await CleanupUnsubscribeTokensAsync();
// 3. Marketing y Retención // 3. Marketing y Retención
await ProcessWeeklyStatsAsync(); await ProcessWeeklyStatsAsync();
@@ -80,12 +81,36 @@ public class AdExpirationService : BackgroundService
} }
} }
private async Task CleanupUnsubscribeTokensAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
// Borramos tokens que ya expiraron o que ya fueron usados
var now = DateTime.UtcNow;
var deletedCount = await context.UnsubscribeTokens
.Where(t => t.ExpiresAt <= now || t.IsUsed)
.ExecuteDeleteAsync();
if (deletedCount > 0)
{
logger.LogInformation("Mantenimiento: Se eliminaron {Count} tokens de baja expirados o usados.", deletedCount);
}
}
}
private async Task CheckExpiredAdsAsync() private async Task CheckExpiredAdsAsync()
{ {
using (var scope = _serviceProvider.CreateScope()) using (var scope = _serviceProvider.CreateScope())
{ {
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>(); var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>(); var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var cutoffDate = DateTime.UtcNow.AddDays(-30); var cutoffDate = DateTime.UtcNow.AddDays(-30);
@@ -97,8 +122,8 @@ public class AdExpirationService : BackgroundService
a.StatusID == (int)AdStatusEnum.Active && a.StatusID == (int)AdStatusEnum.Active &&
// Regla 2: Publicado hace más de 30 días // Regla 2: Publicado hace más de 30 días
a.PublishedAt.HasValue && a.PublishedAt.Value < cutoffDate && a.PublishedAt.HasValue && a.PublishedAt.Value < cutoffDate &&
// --- CAMBIO AQUÍ: Excluimos avisos de administradores --- // Aplica a todos los usuarios, incluyendo administradores
a.User != null && a.User.UserType != 3 a.User != null
) )
.ToListAsync(); .ToListAsync();
@@ -106,10 +131,17 @@ public class AdExpirationService : BackgroundService
{ {
ad.StatusID = (int)AdStatusEnum.Expired; ad.StatusID = (int)AdStatusEnum.Expired;
if (ad.User != null && !string.IsNullOrEmpty(ad.User.Email)) // Solo enviamos el correo si el usuario tiene habilitada la categoría "sistema"
if (ad.User != null && !string.IsNullOrEmpty(ad.User.Email)
&& await prefService.IsEnabledAsync(ad.User.UserID, NotificationCategory.Sistema))
{ {
var title = $"{ad.Brand?.Name} {ad.VersionName}"; var title = $"{ad.Brand?.Name} {ad.VersionName}";
await notifService.SendAdExpiredEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title); // Generamos el token de baja para la categoría "sistema"
var rawToken = await prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Sistema);
var unsubscribeUrl = $"{frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await notifService.SendAdExpiredEmailAsync(
ad.User.Email, ad.User.FirstName ?? "Usuario", title, unsubscribeUrl);
} }
context.AuditLogs.Add(new AuditLog context.AuditLogs.Add(new AuditLog
@@ -118,7 +150,7 @@ public class AdExpirationService : BackgroundService
Entity = "Ad", Entity = "Ad",
EntityID = ad.AdID, EntityID = ad.AdID,
UserID = 0, UserID = 0,
Details = $"Aviso ID {ad.AdID} vencido. Email enviado a usuario no-admin." Details = $"Aviso ID {ad.AdID} vencido automáticamente por el sistema."
}); });
} }
if (expiredAds.Any()) await context.SaveChangesAsync(); if (expiredAds.Any()) await context.SaveChangesAsync();
@@ -131,6 +163,9 @@ public class AdExpirationService : BackgroundService
{ {
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>(); var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>(); var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var warningThreshold = DateTime.UtcNow.AddDays(-25); var warningThreshold = DateTime.UtcNow.AddDays(-25);
@@ -141,8 +176,8 @@ public class AdExpirationService : BackgroundService
a.StatusID == (int)AdStatusEnum.Active && a.StatusID == (int)AdStatusEnum.Active &&
a.PublishedAt.HasValue && a.PublishedAt.Value <= warningThreshold && a.PublishedAt.HasValue && a.PublishedAt.Value <= warningThreshold &&
!a.ExpirationWarningSent && !a.ExpirationWarningSent &&
// --- CAMBIO AQUÍ: Excluimos avisos de administradores --- // Aplica a todos los usuarios, incluyendo administradores
a.User != null && a.User.UserType != 3 a.User != null
) )
.ToListAsync(); .ToListAsync();
@@ -155,7 +190,17 @@ public class AdExpirationService : BackgroundService
try try
{ {
await notifService.SendExpirationWarningEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title, expDate); // Respetamos la preferencia de la categoría "sistema"
if (await prefService.IsEnabledAsync(ad.User.UserID, NotificationCategory.Sistema))
{
// Generamos el token de baja para la categoría "sistema"
var rawToken = await prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Sistema);
var unsubscribeUrl = $"{frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await notifService.SendExpirationWarningEmailAsync(
ad.User.Email, ad.User.FirstName ?? "Usuario", title, expDate, unsubscribeUrl);
}
// La bandera se marca igual para no reintentar aunque el usuario no quiera el email
ad.ExpirationWarningSent = true; ad.ExpirationWarningSent = true;
} }
catch { /* Log error pero continuar */ } catch { /* Log error pero continuar */ }
@@ -171,6 +216,9 @@ public class AdExpirationService : BackgroundService
{ {
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>(); var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>(); var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7); var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
@@ -190,15 +238,28 @@ public class AdExpirationService : BackgroundService
{ {
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue; if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
// Respetamos la preferencia de la categoría "rendimiento"
if (!await prefService.IsEnabledAsync(ad.User.UserID, NotificationCategory.Rendimiento))
{
// Actualizamos la fecha para no consultar este aviso en el próximo ciclo
ad.LastPerformanceEmailSentAt = DateTime.UtcNow;
continue;
}
var favCount = await context.Favorites.CountAsync(f => f.AdID == ad.AdID); var favCount = await context.Favorites.CountAsync(f => f.AdID == ad.AdID);
var title = $"{ad.Brand?.Name} {ad.VersionName}"; var title = $"{ad.Brand?.Name} {ad.VersionName}";
// Generamos el token de baja para la categoría "rendimiento"
var rawToken = await prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Rendimiento);
var unsubscribeUrl = $"{frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await notifService.SendWeeklyPerformanceEmailAsync( await notifService.SendWeeklyPerformanceEmailAsync(
ad.User.Email, ad.User.Email,
ad.User.FirstName ?? "Usuario", ad.User.FirstName ?? "Usuario",
title, title,
ad.ViewsCounter, ad.ViewsCounter,
favCount favCount,
unsubscribeUrl
); );
ad.LastPerformanceEmailSentAt = DateTime.UtcNow; ad.LastPerformanceEmailSentAt = DateTime.UtcNow;
@@ -214,9 +275,9 @@ public class AdExpirationService : BackgroundService
{ {
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>(); var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>(); var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>(); var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"] ?? "http://localhost:5173"; var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var cutoff = DateTime.UtcNow.AddHours(-24); var cutoff = DateTime.UtcNow.AddHours(-24);
@@ -236,10 +297,19 @@ public class AdExpirationService : BackgroundService
{ {
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue; if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
// Respetamos la preferencia de la categoría "marketing" (carrito abandonado)
if (await prefService.IsEnabledAsync(ad.User.UserID, NotificationCategory.Marketing))
{
var title = $"{ad.Brand?.Name} {ad.VersionName}"; var title = $"{ad.Brand?.Name} {ad.VersionName}";
var link = $"{frontendUrl}/publicar?edit={ad.AdID}"; var link = $"{frontendUrl}/publicar?edit={ad.AdID}";
await notifService.SendPaymentReminderEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title, link); // Generamos el token de baja para la categoría "marketing"
var rawToken = await prefService.GetOrCreateUnsubscribeTokenAsync(ad.User.UserID, NotificationCategory.Marketing);
var unsubscribeUrl = $"{frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await notifService.SendPaymentReminderEmailAsync(
ad.User.Email, ad.User.FirstName ?? "Usuario", title, link, unsubscribeUrl);
}
ad.PaymentReminderSentAt = DateTime.UtcNow; ad.PaymentReminderSentAt = DateTime.UtcNow;
} }
@@ -254,6 +324,9 @@ public class AdExpirationService : BackgroundService
{ {
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>(); var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>(); var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
// Buscar usuarios que tengan mensajes no leídos viejos (> 4 horas) // Buscar usuarios que tengan mensajes no leídos viejos (> 4 horas)
// y que no hayan sido notificados en las últimas 24 horas. // y que no hayan sido notificados en las últimas 24 horas.
@@ -282,7 +355,16 @@ public class AdExpirationService : BackgroundService
// Contar total no leídos // Contar total no leídos
var totalUnread = await context.ChatMessages.CountAsync(m => m.ReceiverID == userId && !m.IsRead); var totalUnread = await context.ChatMessages.CountAsync(m => m.ReceiverID == userId && !m.IsRead);
await notifService.SendUnreadMessagesReminderEmailAsync(user.Email, user.FirstName ?? "Usuario", totalUnread); // Respetamos la preferencia de la categoría "mensajes"
if (await prefService.IsEnabledAsync(userId, NotificationCategory.Mensajes))
{
// Generamos el token de baja para la categoría "mensajes"
var rawToken = await prefService.GetOrCreateUnsubscribeTokenAsync(userId, NotificationCategory.Mensajes);
var unsubscribeUrl = $"{frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
await notifService.SendUnreadMessagesReminderEmailAsync(
user.Email, user.FirstName ?? "Usuario", totalUnread, unsubscribeUrl);
}
user.LastUnreadMessageReminderSentAt = DateTime.UtcNow; user.LastUnreadMessageReminderSentAt = DateTime.UtcNow;
} }

View File

@@ -156,7 +156,7 @@ public class AvisosLegacyService : IAvisosLegacyService
{ {
new SqlParameter("@tipo", dto.Tipo), new SqlParameter("@tipo", dto.Tipo),
new SqlParameter("@nro_operacion", dto.NroOperacion), new SqlParameter("@nro_operacion", dto.NroOperacion),
new SqlParameter("@id_cliente", dto.IdCliente), new SqlParameter("@id_cliente", 0), // Siempre debe ser 0 para inserciones web
new SqlParameter("@tipodoc", dto.Tipodoc), new SqlParameter("@tipodoc", dto.Tipodoc),
new SqlParameter("@nro_doc", dto.NroDoc), new SqlParameter("@nro_doc", dto.NroDoc),
new SqlParameter("@razon", dto.Razon), new SqlParameter("@razon", dto.Razon),
@@ -166,7 +166,7 @@ public class AvisosLegacyService : IAvisosLegacyService
new SqlParameter("@codigopostal", dto.CodigoPostal), new SqlParameter("@codigopostal", dto.CodigoPostal),
new SqlParameter("@telefono", dto.Telefono), new SqlParameter("@telefono", dto.Telefono),
new SqlParameter("@email", dto.Email), new SqlParameter("@email", dto.Email),
new SqlParameter("@id_tipoiva", dto.IdTipoiva), new SqlParameter("@id_tipoiva", 5), // Siempre debe ser 5 para inserciones web
new SqlParameter("@porcentaje_iva1", dto.PorcentajeIva1), new SqlParameter("@porcentaje_iva1", dto.PorcentajeIva1),
new SqlParameter("@porcentaje_iva2", dto.PorcentajeIva2), new SqlParameter("@porcentaje_iva2", dto.PorcentajeIva2),
new SqlParameter("@porcentaje_percepcion", dto.PorcentajePercepcion), new SqlParameter("@porcentaje_percepcion", dto.PorcentajePercepcion),

View File

@@ -80,7 +80,7 @@ public class IdentityService : IIdentityService
await _v2Context.SaveChangesAsync(); await _v2Context.SaveChangesAsync();
// 4. Enviar Email REAL // 4. Enviar Email REAL
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173"; var frontendUrl = _config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var verifyLink = $"{frontendUrl}/verificar-email?token={token}"; var verifyLink = $"{frontendUrl}/verificar-email?token={token}";
var emailBody = $@" var emailBody = $@"
@@ -187,7 +187,7 @@ public class IdentityService : IIdentityService
await _v2Context.SaveChangesAsync(); await _v2Context.SaveChangesAsync();
// Email // Email
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173"; var frontendUrl = _config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var verifyLink = $"{frontendUrl}/verificar-email?token={token}"; var verifyLink = $"{frontendUrl}/verificar-email?token={token}";
var emailBody = $@" var emailBody = $@"
@@ -241,7 +241,7 @@ public class IdentityService : IIdentityService
await _v2Context.SaveChangesAsync(); await _v2Context.SaveChangesAsync();
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173"; var frontendUrl = _config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var resetLink = $"{frontendUrl}/restablecer-clave?token={token}"; var resetLink = $"{frontendUrl}/restablecer-clave?token={token}";
var emailBody = $@" var emailBody = $@"
@@ -279,6 +279,8 @@ public class IdentityService : IIdentityService
user.PasswordResetTokenExpiresAt = null; user.PasswordResetTokenExpiresAt = null;
user.PasswordSalt = null; user.PasswordSalt = null;
user.MigrationStatus = 1; user.MigrationStatus = 1;
user.IsEmailVerified = true;
user.VerificationToken = null;
await _v2Context.SaveChangesAsync(); await _v2Context.SaveChangesAsync();
return (true, "Tu contraseña ha sido actualizada correctamente."); return (true, "Tu contraseña ha sido actualizada correctamente.");
@@ -371,7 +373,7 @@ public class IdentityService : IIdentityService
await _v2Context.SaveChangesAsync(); await _v2Context.SaveChangesAsync();
// Enviar Email al NUEVO correo // Enviar Email al NUEVO correo
var frontendUrl = _config["AppSettings:FrontendUrl"]?.Split(',')[0] ?? "http://localhost:5173"; var frontendUrl = _config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
var link = $"{frontendUrl}/confirmar-cambio-email?token={token}"; var link = $"{frontendUrl}/confirmar-cambio-email?token={token}";
var body = $@" var body = $@"

View File

@@ -42,10 +42,10 @@ public class ImageStorageService : IImageStorageService
throw new Exception("Formato de archivo no permitido. Solo JPG, PNG y WEBP."); throw new Exception("Formato de archivo no permitido. Solo JPG, PNG y WEBP.");
} }
// 2. Validación de Tamaño (Max 3MB) // 2. Validación de Tamaño (Max 10MB)
if (file.Length > 3 * 1024 * 1024) if (file.Length > 10 * 1024 * 1024)
{ {
throw new Exception("El archivo excede los 3MB permitidos."); throw new Exception("El archivo excede los 10MB permitidos.");
} }
// 3. Validación de Magic Numbers (Leer cabecera real) // 3. Validación de Magic Numbers (Leer cabecera real)

View File

@@ -98,7 +98,7 @@ public class LegacyPaymentService : ILegacyPaymentService
var p = new DynamicParameters(); var p = new DynamicParameters();
p.Add("@tipo", ad.VehicleTypeID == 1 ? "A" : "M"); p.Add("@tipo", ad.VehicleTypeID == 1 ? "A" : "M");
p.Add("@nro_operacion", tx.TransactionID); p.Add("@nro_operacion", tx.TransactionID);
p.Add("@id_cliente", ad.UserID); p.Add("@id_cliente", 0);
p.Add("@tipodoc", 96); p.Add("@tipodoc", 96);
p.Add("@nro_doc", "0"); p.Add("@nro_doc", "0");
p.Add("@razon", $"{ad.User.FirstName} {ad.User.LastName}".Trim().ToUpper()); p.Add("@razon", $"{ad.User.FirstName} {ad.User.LastName}".Trim().ToUpper());
@@ -108,7 +108,7 @@ public class LegacyPaymentService : ILegacyPaymentService
p.Add("@codigopostal", "1900"); p.Add("@codigopostal", "1900");
p.Add("@telefono", ad.ContactPhone ?? ""); p.Add("@telefono", ad.ContactPhone ?? "");
p.Add("@email", ad.User.Email); p.Add("@email", ad.User.Email);
p.Add("@id_tipoiva", 1); p.Add("@id_tipoiva", 5);
p.Add("@porcentaje_iva1", 10.5m); p.Add("@porcentaje_iva1", 10.5m);
p.Add("@porcentaje_iva2", 0); p.Add("@porcentaje_iva2", 0);
p.Add("@porcentaje_percepcion", 0); p.Add("@porcentaje_percepcion", 0);

View File

@@ -0,0 +1,278 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Infrastructure.Data;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class NotificationPreferenceService : INotificationPreferenceService
{
private readonly MotoresV2DbContext _context;
private readonly IConfiguration _config;
private readonly ILogger<NotificationPreferenceService> _logger;
// Clave secreta para firmar los tokens de baja (HMAC-SHA256)
private readonly string _hmacSecret;
public NotificationPreferenceService(
MotoresV2DbContext context,
IConfiguration config,
ILogger<NotificationPreferenceService> logger)
{
_context = context;
_config = config;
_logger = logger;
// La clave se lee de la configuración; si no existe, fallback a la clave JWT (nunca null en producción)
_hmacSecret = config["Unsubscribe:HmacSecret"]
?? config["Jwt:Key"]
?? throw new InvalidOperationException("Falta clave HMAC para tokens de baja.");
}
/// <inheritdoc/>
public async Task<NotificationPreferencesDto> GetPreferencesAsync(int userId)
{
// Cargamos todas las preferencias guardadas del usuario
var prefs = await _context.NotificationPreferences
.Where(p => p.UserID == userId)
.ToListAsync();
// Si no hay registro, la preferencia es TRUE (habilitada) por defecto
return new NotificationPreferencesDto
{
Sistema = GetIsEnabled(prefs, NotificationCategory.Sistema),
Marketing = GetIsEnabled(prefs, NotificationCategory.Marketing),
Rendimiento = GetIsEnabled(prefs, NotificationCategory.Rendimiento),
Mensajes = GetIsEnabled(prefs, NotificationCategory.Mensajes),
};
}
private static bool GetIsEnabled(List<UserNotificationPreference> prefs, string category)
{
var pref = prefs.FirstOrDefault(p => p.Category == category);
// Sin registro = habilitado por defecto
return pref?.IsEnabled ?? true;
}
/// <inheritdoc/>
public async Task UpdatePreferencesAsync(int userId, UpdateNotificationPreferencesDto dto)
{
// Mapa categoría → valor del DTO
var updates = new Dictionary<string, bool>
{
[NotificationCategory.Sistema] = dto.Sistema,
[NotificationCategory.Marketing] = dto.Marketing,
[NotificationCategory.Rendimiento] = dto.Rendimiento,
[NotificationCategory.Mensajes] = dto.Mensajes,
};
foreach (var (category, isEnabled) in updates)
{
var existing = await _context.NotificationPreferences
.FirstOrDefaultAsync(p => p.UserID == userId && p.Category == category);
if (existing == null)
{
// Solo creamos el registro si el usuario está DESACTIVANDO (optimización)
// Si es true y no hay registro, es el valor por defecto → no necesitamos guardar nada
if (!isEnabled)
{
_context.NotificationPreferences.Add(new UserNotificationPreference
{
UserID = userId,
Category = category,
IsEnabled = false,
UpdatedAt = DateTime.UtcNow
});
}
}
else
{
existing.IsEnabled = isEnabled;
existing.UpdatedAt = DateTime.UtcNow;
}
}
await _context.SaveChangesAsync();
_logger.LogInformation("Preferencias de notificación actualizadas para UserID {UserId}", userId);
}
/// <inheritdoc/>
public async Task<bool> IsEnabledAsync(int userId, string category)
{
var pref = await _context.NotificationPreferences
.FirstOrDefaultAsync(p => p.UserID == userId && p.Category == category);
// Sin registro = habilitado por defecto
return pref?.IsEnabled ?? true;
}
/// <inheritdoc/>
public async Task<string> GetOrCreateUnsubscribeTokenAsync(int userId, string category)
{
// Reutilizamos un token existente y vigente si lo hay
var existing = await _context.UnsubscribeTokens
.FirstOrDefaultAsync(t =>
t.UserID == userId &&
t.Category == category &&
!t.IsUsed &&
t.ExpiresAt > DateTime.UtcNow);
if (existing != null)
return existing.Token;
// Generamos un nuevo token seguro: GUID aleatorio + firma HMAC
var rawToken = GenerateSignedToken(userId, category);
_context.UnsubscribeTokens.Add(new UnsubscribeToken
{
UserID = userId,
Category = category,
Token = rawToken,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(365),
IsUsed = false
});
await _context.SaveChangesAsync();
return rawToken;
}
/// <inheritdoc/>
public async Task<(bool Success, string CategoryLabel)> UnsubscribeAsync(string token)
{
if (string.IsNullOrWhiteSpace(token))
return (false, string.Empty);
// Buscamos el token en la base de datos
var record = await _context.UnsubscribeTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.Token == token);
if (record == null)
{
_logger.LogWarning("Intento de baja con token inexistente.");
return (false, string.Empty);
}
if (record.IsUsed)
{
// El token ya fue usado previamente; la baja ya está aplicada
return (true, GetCategoryLabel(record.Category));
}
if (record.ExpiresAt < DateTime.UtcNow)
{
_logger.LogWarning("Intento de baja con token expirado. UserID={UserId}", record.UserID);
return (false, string.Empty);
}
// Verificamos la firma del token para garantizar integridad
if (!VerifySignedToken(record.Token, record.UserID, record.Category))
{
_logger.LogWarning("Token de baja con firma inválida. UserID={UserId}", record.UserID);
return (false, string.Empty);
}
// Actualizamos la preferencia → deshabilitar categoría
var pref = await _context.NotificationPreferences
.FirstOrDefaultAsync(p => p.UserID == record.UserID && p.Category == record.Category);
if (pref == null)
{
_context.NotificationPreferences.Add(new UserNotificationPreference
{
UserID = record.UserID,
Category = record.Category,
IsEnabled = false,
UpdatedAt = DateTime.UtcNow
});
}
else
{
pref.IsEnabled = false;
pref.UpdatedAt = DateTime.UtcNow;
}
// Marcamos el token como usado (one-click = un solo uso)
record.IsUsed = true;
await _context.SaveChangesAsync();
_logger.LogInformation(
"Baja procesada correctamente. UserID={UserId}, Category={Category}",
record.UserID, record.Category);
return (true, GetCategoryLabel(record.Category));
}
// ─── Métodos privados de seguridad ────────────────────────────────────────
/// <summary>
/// Genera un token opaco: GUID aleatorio codificado en base64url + separador + HMAC-SHA256 del payload.
/// Formato: {guid_b64url}.{hmac_b64url}
/// </summary>
private string GenerateSignedToken(int userId, string category)
{
// Parte aleatoria para garantizar unicidad
var randomPart = Convert.ToBase64String(Guid.NewGuid().ToByteArray())
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
// Payload a firmar
var payload = $"{userId}:{category}:{randomPart}";
var signature = ComputeHmac(payload);
return $"{randomPart}.{signature}";
}
/// <summary>
/// Verifica que el token almacenado corresponde realmente a userId + category
/// recalculando el HMAC y comparando de forma segura.
/// NOTA: el token en la DB ya nos garantiza la asociación UserID/Category;
/// aquí adicionalmente verificamos que nadie manipuló la columna.
/// </summary>
private bool VerifySignedToken(string token, int userId, string category)
{
// El token tiene formato: {randomPart}.{signature}
var dot = token.LastIndexOf('.');
if (dot < 0) return false;
var randomPart = token[..dot];
var storedSig = token[(dot + 1)..];
var payload = $"{userId}:{category}:{randomPart}";
var expected = ComputeHmac(payload);
// CryptographicOperations.FixedTimeEquals protege contra timing attacks
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(storedSig),
Encoding.UTF8.GetBytes(expected));
}
private string ComputeHmac(string payload)
{
var keyBytes = Encoding.UTF8.GetBytes(_hmacSecret);
var dataBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(keyBytes);
var hash = hmac.ComputeHash(dataBytes);
return Convert.ToBase64String(hash)
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
}
private static string GetCategoryLabel(string category) => category switch
{
NotificationCategory.Sistema => "Avisos del Sistema",
NotificationCategory.Marketing => "Promociones y Marketing",
NotificationCategory.Rendimiento => "Resumen de Rendimiento",
NotificationCategory.Mensajes => "Recordatorio de Mensajes",
_ => category
};
}

View File

@@ -15,11 +15,31 @@ public class NotificationService : INotificationService
_emailService = emailService; _emailService = emailService;
_logger = logger; _logger = logger;
// Leemos la URL del appsettings o usamos localhost como fallback // Leemos la URL del appsettings o usamos localhost como fallback
_frontendUrl = config["AppSettings:FrontendUrl"] ?? "http://localhost:5173"; _frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
} }
private string GetEmailShell(string title, string content) // ─── Shell del correo ──────────────────────────────────────────────────────
/// <summary>
/// Genera el HTML completo del correo con header, contenido y footer.
/// Si se provee unsubscribeUrl, agrega el enlace de baja en el footer.
/// </summary>
private string GetEmailShell(string title, string content, string? unsubscribeUrl = null)
{ {
// Footer de baja: solo se muestra si el correo tiene categoría de preferencia
var unsubscribeBlock = string.IsNullOrEmpty(unsubscribeUrl)
? string.Empty
: $@"
<div style='margin-top: 18px; padding-top: 18px; border-top: 1px solid #1f2937;'>
<p style='color: #4b5563; font-size: 11px; margin: 0; text-align: center;'>
¿No querés recibir más este tipo de correos?
<a href='{unsubscribeUrl}'
style='color: #6b7280; text-decoration: underline; font-size: 11px;'>
Darte de baja
</a>
</p>
</div>";
return $@" return $@"
<div style='background-color: #0a0c10; color: #e5e7eb; font-family: sans-serif; padding: 40px; line-height: 1.6;'> <div style='background-color: #0a0c10; color: #e5e7eb; font-family: sans-serif; padding: 40px; line-height: 1.6;'>
<div style='max-width: 600px; margin: 0 auto; background-color: #12141a; border: 1px solid #1f2937; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);'> <div style='max-width: 600px; margin: 0 auto; background-color: #12141a; border: 1px solid #1f2937; border-radius: 24px; overflow: hidden; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);'>
@@ -34,12 +54,20 @@ public class NotificationService : INotificationService
</div> </div>
<div style='padding: 20px; border-top: 1px solid #1f2937; text-align: center; background-color: #0d0f14;'> <div style='padding: 20px; border-top: 1px solid #1f2937; text-align: center; background-color: #0d0f14;'>
<p style='color: #4b5563; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; margin: 0;'>Motores Argentinos - La Plata, Buenos Aires, Argentina</p> <p style='color: #4b5563; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; margin: 0;'>Motores Argentinos - La Plata, Buenos Aires, Argentina</p>
{unsubscribeBlock}
</div> </div>
</div> </div>
</div>"; </div>";
} }
public async Task SendChatNotificationEmailAsync(string toEmail, string fromUser, string message, int adId) // ─── Implementaciones ──────────────────────────────────────────────────────
/// <summary>
/// Notificación de nuevo mensaje de chat. Categoría: mensajes.
/// </summary>
public async Task SendChatNotificationEmailAsync(
string toEmail, string fromUser, string message, int adId,
string? unsubscribeUrl = null)
{ {
string subject = "Tienes un nuevo mensaje - Motores Argentinos"; string subject = "Tienes un nuevo mensaje - Motores Argentinos";
string content = $@" string content = $@"
@@ -53,10 +81,15 @@ public class NotificationService : INotificationService
<a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>VER MENSAJES</a> <a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>VER MENSAJES</a>
</div>"; </div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Nuevo Mensaje", content)); await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Nuevo Mensaje", content, unsubscribeUrl));
} }
public async Task SendAdStatusChangedEmailAsync(string toEmail, string adTitle, string status, string? reason = null) /// <summary>
/// Aviso de cambio de estado del aviso. Categoría: sistema.
/// </summary>
public async Task SendAdStatusChangedEmailAsync(
string toEmail, string adTitle, string status, string? reason = null,
string? unsubscribeUrl = null)
{ {
string subject = "Estado de tu aviso - Motores Argentinos"; string subject = "Estado de tu aviso - Motores Argentinos";
string color = status.ToUpper() == "APROBADO" ? "#10b981" : "#ef4444"; string color = status.ToUpper() == "APROBADO" ? "#10b981" : "#ef4444";
@@ -70,9 +103,12 @@ public class NotificationService : INotificationService
{(string.IsNullOrEmpty(reason) ? "" : $"<p style='background: #1f2937; padding: 15px; border-radius: 8px; border-left: 4px solid #ef4444;'><strong>Motivo:</strong> {reason}</p>")} {(string.IsNullOrEmpty(reason) ? "" : $"<p style='background: #1f2937; padding: 15px; border-radius: 8px; border-left: 4px solid #ef4444;'><strong>Motivo:</strong> {reason}</p>")}
<p style='margin-top: 20px;'>Gracias por confiar en nosotros.</p>"; <p style='margin-top: 20px;'>Gracias por confiar en nosotros.</p>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Cambio de Estado", content)); await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Cambio de Estado", content, unsubscribeUrl));
} }
/// <summary>
/// Alerta de seguridad crítica. SIN enlace de baja (siempre se envía).
/// </summary>
public async Task SendSecurityAlertEmailAsync(string toEmail, string actionDescription) public async Task SendSecurityAlertEmailAsync(string toEmail, string actionDescription)
{ {
string subject = "Alerta de Seguridad - Motores Argentinos"; string subject = "Alerta de Seguridad - Motores Argentinos";
@@ -85,10 +121,16 @@ public class NotificationService : INotificationService
<p>Si no fuiste tú, te recomendamos cambiar tu contraseña inmediatamente y contactar a nuestro equipo de soporte.</p> <p>Si no fuiste tú, te recomendamos cambiar tu contraseña inmediatamente y contactar a nuestro equipo de soporte.</p>
<p style='margin-top: 20px;'>Atentamente,<br>Equipo de Seguridad.</p>"; <p style='margin-top: 20px;'>Atentamente,<br>Equipo de Seguridad.</p>";
// Sin unsubscribeUrl: los correos de seguridad no tienen baja
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Alerta de Seguridad", content)); await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Alerta de Seguridad", content));
} }
public async Task SendExpirationWarningEmailAsync(string toEmail, string userName, string adTitle, DateTime expirationDate) /// <summary>
/// Aviso de próximo vencimiento. Categoría: sistema.
/// </summary>
public async Task SendExpirationWarningEmailAsync(
string toEmail, string userName, string adTitle, DateTime expirationDate,
string? unsubscribeUrl = null)
{ {
string subject = "Tu aviso está por vencer - Motores Argentinos"; string subject = "Tu aviso está por vencer - Motores Argentinos";
string content = $@" string content = $@"
@@ -99,10 +141,15 @@ public class NotificationService : INotificationService
<a href='{_frontendUrl}/mis-avisos' style='background-color: #f59e0b; color: #000; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>RENOVAR AVISO</a> <a href='{_frontendUrl}/mis-avisos' style='background-color: #f59e0b; color: #000; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>RENOVAR AVISO</a>
</div>"; </div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Aviso por Vencer", content)); await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Aviso por Vencer", content, unsubscribeUrl));
} }
public async Task SendAdExpiredEmailAsync(string toEmail, string userName, string adTitle) /// <summary>
/// Aviso de vencimiento consumado. Categoría: sistema.
/// </summary>
public async Task SendAdExpiredEmailAsync(
string toEmail, string userName, string adTitle,
string? unsubscribeUrl = null)
{ {
string subject = "Tu aviso ha finalizado - Motores Argentinos"; string subject = "Tu aviso ha finalizado - Motores Argentinos";
string content = $@" string content = $@"
@@ -113,10 +160,15 @@ public class NotificationService : INotificationService
<a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>REPUBLICAR AHORA</a> <a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>REPUBLICAR AHORA</a>
</div>"; </div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Aviso Finalizado", content)); await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Aviso Finalizado", content, unsubscribeUrl));
} }
public async Task SendWeeklyPerformanceEmailAsync(string toEmail, string userName, string adTitle, int views, int favorites) /// <summary>
/// Resumen semanal de rendimiento del aviso. Categoría: rendimiento.
/// </summary>
public async Task SendWeeklyPerformanceEmailAsync(
string toEmail, string userName, string adTitle, int views, int favorites,
string? unsubscribeUrl = null)
{ {
string subject = "Resumen semanal de tu aviso - Motores Argentinos"; string subject = "Resumen semanal de tu aviso - Motores Argentinos";
@@ -158,10 +210,15 @@ public class NotificationService : INotificationService
<![endif]--> <![endif]-->
</div>"; </div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Rendimiento Semanal", content)); await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Rendimiento Semanal", content, unsubscribeUrl));
} }
public async Task SendPaymentReminderEmailAsync(string toEmail, string userName, string adTitle, string link) /// <summary>
/// Recordatorio de carrito abandonado. Categoría: marketing.
/// </summary>
public async Task SendPaymentReminderEmailAsync(
string toEmail, string userName, string adTitle, string link,
string? unsubscribeUrl = null)
{ {
string subject = "Finaliza la publicación de tu aviso - Motores Argentinos"; string subject = "Finaliza la publicación de tu aviso - Motores Argentinos";
string content = $@" string content = $@"
@@ -172,10 +229,14 @@ public class NotificationService : INotificationService
<a href='{link}' style='background-color: #10b981; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>FINALIZAR PUBLICACIÓN</a> <a href='{link}' style='background-color: #10b981; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>FINALIZAR PUBLICACIÓN</a>
</div>"; </div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Acción Requerida", content)); await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Acción Requerida", content, unsubscribeUrl));
} }
public async Task SendPaymentReceiptEmailAsync(string toEmail, string userName, string adTitle, decimal amount, string operationCode) /// <summary>
/// Recibo de pago transaccional. SIN enlace de baja (siempre se envía).
/// </summary>
public async Task SendPaymentReceiptEmailAsync(
string toEmail, string userName, string adTitle, decimal amount, string operationCode)
{ {
string subject = "Comprobante de Pago - Motores Argentinos"; string subject = "Comprobante de Pago - Motores Argentinos";
string content = $@" string content = $@"
@@ -191,10 +252,16 @@ public class NotificationService : INotificationService
</div> </div>
<p>Tu aviso ha pasado a la etapa de moderación y será activado a la brevedad.</p>"; <p>Tu aviso ha pasado a la etapa de moderación y será activado a la brevedad.</p>";
// Sin unsubscribeUrl: los comprobantes de pago son transaccionales obligatorios
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Recibo de Pago", content)); await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Recibo de Pago", content));
} }
public async Task SendUnreadMessagesReminderEmailAsync(string toEmail, string userName, int unreadCount) /// <summary>
/// Recordatorio de mensajes sin leer. Categoría: mensajes.
/// </summary>
public async Task SendUnreadMessagesReminderEmailAsync(
string toEmail, string userName, int unreadCount,
string? unsubscribeUrl = null)
{ {
string subject = "Tienes mensajes sin leer - Motores Argentinos"; string subject = "Tienes mensajes sin leer - Motores Argentinos";
string content = $@" string content = $@"
@@ -205,6 +272,6 @@ public class NotificationService : INotificationService
<a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>IR A MIS MENSAJES</a> <a href='{_frontendUrl}/mis-avisos' style='background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; text-transform: uppercase; font-size: 12px;'>IR A MIS MENSAJES</a>
</div>"; </div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Mensajes Pendientes", content)); await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Mensajes Pendientes", content, unsubscribeUrl));
} }
} }

View File

@@ -0,0 +1,147 @@
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Infrastructure.Data;
namespace MotoresArgentinosV2.Infrastructure.Services;
/// <summary>
/// Servicio de fondo que genera sitemap.xml dinámicamente.
/// Ejecuta al iniciar la app y luego cada 6 horas.
/// Incluye rutas estáticas del frontend + vehículos activos.
/// </summary>
public class SitemapGeneratorService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<SitemapGeneratorService> _logger;
private static readonly TimeSpan Interval = TimeSpan.FromHours(6);
private const string BaseUrl = "https://motoresargentinos.com";
// Rutas estáticas cuyo contenido cambia con cada aviso nuevo (usan lastmod dinámico)
private static readonly (string Path, string Priority, string ChangeFreq)[] DynamicDateRoutes =
[
("/", "1.0", "daily"),
("/explorar", "0.8", "daily"),
];
// Rutas estáticas cuyo contenido rara vez cambia (sin lastmod para no mentirle a Google)
private static readonly (string Path, string Priority, string ChangeFreq)[] FixedRoutes =
[
("/publicar", "0.6", "monthly"),
("/vender", "0.6", "monthly"),
("/condiciones", "0.3", "yearly"),
];
public SitemapGeneratorService(IServiceProvider serviceProvider, ILogger<SitemapGeneratorService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("SitemapGeneratorService iniciado.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await GenerateSitemapAsync();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generando sitemap.");
}
await Task.Delay(Interval, stoppingToken);
}
}
private async Task GenerateSitemapAsync()
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
// Ruta de salida: configurable para Docker (volumen compartido) o dev local
var outputPath = config["AppSettings:SitemapOutputPath"] ?? Path.Combine("wwwroot", "sitemap.xml");
// Obtener todos los avisos activos
var activeAds = await context.Ads
.AsNoTracking()
.Where(a => a.StatusID == (int)AdStatusEnum.Active)
.Select(a => new { a.AdID, a.PublishedAt })
.ToListAsync();
// Fecha del aviso más reciente (para rutas cuyo contenido cambia con nuevos avisos)
var latestPublished = activeAds
.Where(a => a.PublishedAt.HasValue)
.Max(a => a.PublishedAt)
?.ToString("yyyy-MM-dd") ?? DateTime.UtcNow.ToString("yyyy-MM-dd");
var sb = new StringBuilder();
sb.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
sb.AppendLine("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">");
// 1. Rutas cuyo contenido cambia con cada aviso nuevo (/, /explorar)
foreach (var (path, priority, changeFreq) in DynamicDateRoutes)
{
sb.AppendLine(" <url>");
sb.AppendLine($" <loc>{BaseUrl}{path}</loc>");
sb.AppendLine($" <lastmod>{latestPublished}</lastmod>");
sb.AppendLine($" <changefreq>{changeFreq}</changefreq>");
sb.AppendLine($" <priority>{priority}</priority>");
sb.AppendLine(" </url>");
}
// 2. Rutas fijas (sin lastmod — no mentirle a Google)
foreach (var (path, priority, changeFreq) in FixedRoutes)
{
sb.AppendLine(" <url>");
sb.AppendLine($" <loc>{BaseUrl}{path}</loc>");
sb.AppendLine($" <changefreq>{changeFreq}</changefreq>");
sb.AppendLine($" <priority>{priority}</priority>");
sb.AppendLine(" </url>");
}
// 3. Rutas dinámicas (vehículos activos — lastmod = fecha de publicación real)
foreach (var ad in activeAds)
{
var lastmod = ad.PublishedAt?.ToString("yyyy-MM-dd") ?? latestPublished;
sb.AppendLine(" <url>");
sb.AppendLine($" <loc>{BaseUrl}/vehiculo/{ad.AdID}</loc>");
sb.AppendLine($" <lastmod>{lastmod}</lastmod>");
sb.AppendLine(" <changefreq>weekly</changefreq>");
sb.AppendLine(" <priority>0.7</priority>");
sb.AppendLine(" </url>");
}
sb.AppendLine("</urlset>");
// Escritura atómica: escribir en .tmp, luego mover
var dir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
var tempPath = outputPath + ".tmp";
await File.WriteAllTextAsync(tempPath, sb.ToString(), Encoding.UTF8);
File.Move(tempPath, outputPath, overwrite: true);
var totalUrls = DynamicDateRoutes.Length + FixedRoutes.Length + activeAds.Count;
_logger.LogInformation("Sitemap generado con {TotalUrls} URLs ({StaticCount} estáticas + {DynamicCount} vehículos). Archivo: {Path}",
totalUrls, DynamicDateRoutes.Length + FixedRoutes.Length, activeAds.Count, outputPath);
}
}

View File

@@ -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.AddMinutes(15), Expires = DateTime.UtcNow.AddMinutes(60),
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"]

View File

@@ -1,14 +1,25 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XTD4SD01DV"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XTD4SD01DV');
</script>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo-ma.svg" /> <link rel="icon" type="image/svg+xml" href="/logo-ma.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Motores Argentinos</title> <title>Motores Argentinos</title>
<script src="https://sdk.mercadopago.com/js/v2"></script> <script src="https://sdk.mercadopago.com/js/v2"></script>
</head> </head>
<body>
<body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -5,6 +5,11 @@ server {
# Seguridad: Limitar tamaño de subida para prevenir DoS # Seguridad: Limitar tamaño de subida para prevenir DoS
client_max_body_size 20M; client_max_body_size 20M;
# Sitemap dinámico (generado por backend en volumen compartido)
location = /sitemap.xml {
alias /usr/share/nginx/html/sitemap-data/sitemap.xml;
}
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;

View File

@@ -0,0 +1,15 @@
User-agent: *
Allow: /
Disallow: /perfil
Disallow: /seguridad
Disallow: /mis-avisos
Disallow: /admin
Disallow: /restablecer-clave
Disallow: /verificar-email
Disallow: /confirmar-cambio-email
Disallow: /baja/
Disallow: /pago-confirmado
Disallow: /baja-exitosa
Disallow: /baja-error
Sitemap: https://motoresargentinos.com/sitemap.xml

View File

@@ -17,6 +17,10 @@ import { FaHome, FaSearch, FaCar, FaUser, FaShieldAlt } from 'react-icons/fa';
import { initMercadoPago } from '@mercadopago/sdk-react'; import { initMercadoPago } from '@mercadopago/sdk-react';
import { AuthProvider, useAuth } from './context/AuthContext'; import { AuthProvider, useAuth } from './context/AuthContext';
import ConfirmEmailChangePage from './pages/ConfirmEmailChangePage'; import ConfirmEmailChangePage from './pages/ConfirmEmailChangePage';
import BajaExitosaPage from './pages/BajaExitosaPage';
import BajaErrorPage from './pages/BajaErrorPage';
import ProcessUnsubscribePage from './pages/ProcessUnsubscribePage';
import CondicionesPage from './pages/CondicionesPage';
function AdminGuard({ children }: { children: React.ReactNode }) { function AdminGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -239,7 +243,7 @@ function Navbar() {
)} )}
{showLoginModal && ( {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="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-xl animate-fade-in p-4">
<div className="relative w-full max-w-md"> <div className="relative w-full max-w-md">
<LoginModal <LoginModal
onSuccess={handleLoginSuccess} onSuccess={handleLoginSuccess}
@@ -270,6 +274,11 @@ function FooterLegal() {
<p>© {currentYear} MotoresArgentinos. Todos los derechos reservados. <span className="text-gray-400 font-bold ml-1">Edición número: {currentEdition}.</span></p> <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 : RL-2024-70042723-APN-DNDA#MJ - Propietario: Publiéxito S.A.</p> <p>Registro DNDA : 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> <p>Director: Leonardo Mario Forclaz - 46 N 423 - La Plata - Pcia. de Bs. As.</p>
<div className="mt-1 pt-1 border-t border-white/5">
<Link to="/condiciones" className="text-blue-400/60 hover:text-blue-400 transition-colors font-bold tracking-widest text-[9px] uppercase">
Términos y Condiciones
</Link>
</div>
</div> </div>
</div> </div>
</footer> </footer>
@@ -307,6 +316,10 @@ function MainLayout() {
<Route path="/perfil" element={<PerfilPage />} /> <Route path="/perfil" element={<PerfilPage />} />
<Route path="/seguridad" element={<SeguridadPage />} /> <Route path="/seguridad" element={<SeguridadPage />} />
<Route path="/confirmar-cambio-email" element={<ConfirmEmailChangePage />} /> <Route path="/confirmar-cambio-email" element={<ConfirmEmailChangePage />} />
<Route path="/baja/procesar" element={<ProcessUnsubscribePage />} />
<Route path="/baja-exitosa" element={<BajaExitosaPage />} />
<Route path="/baja-error" element={<BajaErrorPage />} />
<Route path="/condiciones" element={<CondicionesPage />} />
<Route path="/admin" element={ <Route path="/admin" element={
<AdminGuard> <AdminGuard>
<AdminPage /> <AdminPage />

View File

@@ -64,7 +64,7 @@ export default function ChatModal({ isOpen, onClose, adId, adTitle, sellerId, cu
if (!isOpen) return null; if (!isOpen) return null;
return ( 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="fixed inset-0 z-[200] flex items-end md:items-center justify-center p-4 md:p-6 bg-black/80 backdrop-blur-xl 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"> <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 */} {/* Header Neutro */}

View File

@@ -37,7 +37,7 @@ export default function ConfirmationModal({
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 animate-fade-in"> <div className="fixed inset-0 z-[200] flex items-center justify-center p-4 animate-fade-in">
{/* Backdrop */} {/* Backdrop */}
<div <div
className="absolute inset-0 bg-black/80 backdrop-blur-sm" className="absolute inset-0 bg-black/80 backdrop-blur-xl"
onClick={onCancel} onClick={onCancel}
></div> ></div>

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import { QRCodeSVG } from "qrcode.react";
interface Props { interface Props {
onSuccess: (user: UserSession) => void; onSuccess: (user: UserSession) => void;
onClose: () => void; onClose: () => void;
initialMode?: "LOGIN" | "REGISTER";
} }
// --- ICONOS SVG --- // --- ICONOS SVG ---
@@ -94,9 +95,9 @@ const NeutralCircleIcon = () => (
</svg> </svg>
); );
export default function LoginModal({ onSuccess, onClose }: Props) { export default function LoginModal({ onSuccess, onClose, initialMode }: Props) {
// Toggle entre Login y Registro // Toggle entre Login y Registro
const [mode, setMode] = useState<"LOGIN" | "REGISTER">("LOGIN"); const [mode, setMode] = useState<"LOGIN" | "REGISTER">(initialMode || "LOGIN");
// Estados de Login // Estados de Login
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -459,7 +460,7 @@ export default function LoginModal({ onSuccess, onClose }: Props) {
return ( return (
// CAMBIO: max-w-lg (más ancho) y overflow-hidden para evitar scrollbars feos // 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"> <div className="bg-[#161a22] px-8 py-6 rounded-3xl border border-white/10 shadow-2xl max-w-lg w-full animate-fade-in-up relative overflow-hidden backdrop-blur-3xl">
{loading && ( {loading && (
<div className="absolute top-0 left-0 w-full h-1 bg-blue-500 animate-pulse"></div> <div className="absolute top-0 left-0 w-full h-1 bg-blue-500 animate-pulse"></div>
)} )}

View File

@@ -131,7 +131,7 @@ export default function PremiumGallery({
{/* MINIATURAS (THUMBNAILS) */} {/* MINIATURAS (THUMBNAILS) */}
{photos.length > 1 && ( {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"> <div className="flex gap-3 md:gap-4 overflow-x-auto pt-4 pb-4 scrollbar-hide no-scrollbar items-center justify-start">
{photos.map((p, idx) => ( {photos.map((p, idx) => (
<button <button
key={idx} key={idx}

View File

@@ -63,7 +63,7 @@ export const STATUS_CONFIG: Record<number, { label: string; color: string; bg: s
icon: '📝' icon: '📝'
}, },
[AD_STATUSES.DELETED]: { [AD_STATUSES.DELETED]: {
label: 'Eliminar', label: 'Eliminado',
color: 'text-white', color: 'text-white',
bg: 'bg-red-700/90', bg: 'bg-red-700/90',
border: 'border-red-500/50', border: 'border-red-500/50',

View File

@@ -1,6 +1,13 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; import {
import { AuthService, type UserSession } from '../services/auth.service'; createContext,
import { ChatService } from '../services/chat.service'; useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import { AuthService, type UserSession } from "../services/auth.service";
import { ChatService } from "../services/chat.service";
interface AuthContextType { interface AuthContextType {
user: UserSession | null; user: UserSession | null;
@@ -19,7 +26,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const fetchUnreadCount = async () => { // Función para cerrar sesión limpiamente
const logout = useCallback(() => {
AuthService.logout();
setUser(null);
setUnreadCount(0);
localStorage.removeItem("userProfile");
// Redirigir a home
if (window.location.pathname !== "/") {
window.location.href = "/";
}
}, []);
const fetchUnreadCount = useCallback(async () => {
const currentUser = AuthService.getCurrentUser(); const currentUser = AuthService.getCurrentUser();
if (currentUser) { if (currentUser) {
try { try {
@@ -29,64 +48,105 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUnreadCount(0); setUnreadCount(0);
} }
} }
}; }, []);
// Verificar sesión al cargar la app (Solo una vez) // Función centralizada para verificar la sesión
useEffect(() => { const verifySession = useCallback(async () => {
const initAuth = async () => {
try { try {
const sessionUser = await AuthService.checkSession(); const sessionUser = await AuthService.checkSession();
if (sessionUser) { if (sessionUser) {
if (sessionUser.id !== user?.id) {
setUser(sessionUser); setUser(sessionUser);
await fetchUnreadCount(); // <--- 5. LLAMAR AL CARGAR LA APP
} else {
setUser(null);
setUnreadCount(0);
} }
} catch (error) { await fetchUnreadCount();
setUser(null); } else {
setUnreadCount(0); // El backend respondió 200 OK pero con body null (sesión inválida explícita)
if (user) logout();
}
} catch (error: any) {
// Solo desloguear si es un error 401 (No autorizado) o 403 (Prohibido).
// Si es un error de red (sin response) o 500, mantenemos al usuario "logueado" visualmente
// hasta que recupere conexión o intente una acción que falle.
if (
error.response &&
(error.response.status === 401 || error.response.status === 403)
) {
if (user) logout();
} else {
console.warn(
"No se pudo verificar la sesión (posible error de red), se mantiene estado actual.",
);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [user, logout, fetchUnreadCount]);
initAuth();
}, []);
const login = (userData: UserSession) => { const login = (userData: UserSession) => {
setUser(userData); setUser(userData);
localStorage.setItem('userProfile', JSON.stringify(userData)); localStorage.setItem("userProfile", JSON.stringify(userData));
fetchUnreadCount(); fetchUnreadCount();
}; };
const logout = () => { const refreshSession = async () => {
AuthService.logout(); await verifySession();
setUser(null);
setUnreadCount(0);
localStorage.removeItem('userProfile');
}; };
const refreshSession = async () => { // 1. Carga Inicial
const sessionUser = await AuthService.checkSession(); useEffect(() => {
setUser(sessionUser); verifySession();
if (sessionUser) { }, [verifySession]);
await fetchUnreadCount();
// 2. DETECTOR DE PESTAÑA ACTIVA
useEffect(() => {
const handleVisibilityChange = () => {
// Solo verificamos cuando el usuario VUELVE a la pestaña
if (document.visibilityState === "visible") {
console.log("🔄 Pestaña activa: Verificando sesión...");
verifySession();
} }
}; };
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [verifySession]);
// 3. HEARTBEAT - Verifica la sesión cada 5 minutos mientras la pestaña esté abierta
useEffect(() => {
if (!user) return; // No gastar recursos si no hay usuario
const interval = setInterval(
() => {
verifySession();
},
5 * 60 * 1000,
); // 5 minutos
return () => clearInterval(interval);
}, [user, verifySession]);
return ( return (
<AuthContext.Provider value={{ user, loading, unreadCount, login, logout, refreshSession, fetchUnreadCount }}> <AuthContext.Provider
value={{
user,
loading,
unreadCount,
login,
logout,
refreshSession,
fetchUnreadCount,
}}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
} }
// Hook personalizado para usar el contexto fácilmente
export function useAuth() { export function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider'); throw new Error("useAuth must be used within an AuthProvider");
} }
return context; return context;
} }

View File

@@ -1,13 +1,15 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { AdminService } from '../services/admin.service'; import { AdminService } from '../services/admin.service';
import { AdsV2Service } from '../services/ads.v2.service';
import ModerationModal from '../components/ModerationModal'; import ModerationModal from '../components/ModerationModal';
import UserModal from '../components/UserModal'; import UserModal from '../components/UserModal';
import { parseUTCDate, getImageUrl } from '../utils/app.utils'; import { parseUTCDate, getImageUrl } from '../utils/app.utils';
import { STATUS_CONFIG } from '../constants/adStatuses'; import { STATUS_CONFIG, AD_STATUSES } from '../constants/adStatuses';
import AdDetailsModal from '../components/AdDetailsModal'; import AdDetailsModal from '../components/AdDetailsModal';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import ConfirmationModal from '../components/ConfirmationModal';
type TabType = 'stats' | 'ads' | 'moderation' | 'transactions' | 'users' | 'audit'; type TabType = 'stats' | 'ads' | 'moderation' | 'transactions' | 'users' | 'audit' | 'trash';
export default function AdminPage() { export default function AdminPage() {
const [activeTab, setActiveTab] = useState<TabType>('stats'); const [activeTab, setActiveTab] = useState<TabType>('stats');
@@ -21,6 +23,69 @@ export default function AdminPage() {
const [selectedAd, setSelectedAd] = useState<any>(null); const [selectedAd, setSelectedAd] = useState<any>(null);
const [selectedUser, setSelectedUser] = useState<number | null>(null); const [selectedUser, setSelectedUser] = useState<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
});
const initiateStatusChange = (adId: number, newStatus: number) => {
let title = "Cambiar Estado";
let message = "¿Estás seguro de realizar esta acción?";
let isDanger = false;
if (newStatus === AD_STATUSES.DELETED) {
title = "¿Mover a la Papelera?";
message = "El aviso se ocultará de los listados y se moverá a la Papelera. Se mantendrá allí por 60 días antes de su eliminación definitiva.\n\n¿Estás seguro de continuar?";
isDanger = true;
} else if (newStatus === AD_STATUSES.PAUSED) {
title = "Pausar Publicación";
message = "Al pausar el aviso dejará de ser visible en los listados.\n\nPodrás reactivarlo cuando quieras.";
} else if (newStatus === AD_STATUSES.SOLD) {
title = "¡Felicitaciones!";
message = "Al marcar como VENDIDO el aviso mostrará la etiqueta \"Vendido\" al público.\n\n¿Confirmas que ya vendiste el vehículo?";
} else if (newStatus === AD_STATUSES.RESERVED) {
title = "Reservar Vehículo";
message = "Se indicará a los interesados que el vehículo está reservado.\n\n¿Deseas continuar?";
} 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 });
await AdsV2Service.changeStatus(adId, newStatus);
loadData();
} catch (error) {
alert("Error al actualizar estado");
}
};
// Estados para filtros de Usuarios // Estados para filtros de Usuarios
const [userSearch, setUserSearch] = useState(''); const [userSearch, setUserSearch] = useState('');
const [userPage, setUserPage] = useState(1); const [userPage, setUserPage] = useState(1);
@@ -114,6 +179,13 @@ export default function AdminPage() {
page: adsFilters.page page: adsFilters.page
}); });
break; break;
case 'trash':
res = await AdminService.getAllAds({
q: adsFilters.q,
statusId: 9, // Forzamos 9 para papelera
page: adsFilters.page
});
break;
} }
setData(res); setData(res);
} catch (err) { } catch (err) {
@@ -157,20 +229,20 @@ export default function AdminPage() {
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'}`} 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"> <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'} {activeTab === 'stats' ? '📊 Resumen' : activeTab === 'ads' ? '📦 Avisos' : activeTab === 'moderation' ? '🛡️ Moderación' : activeTab === 'transactions' ? '💰 Pagos' : activeTab === 'users' ? '👥 Usuarios' : activeTab === 'audit' ? '📋 Auditoría' : '🗑️ Papelera'}
</span> </span>
<span className={`transition-transform duration-300 ${isMobileMenuOpen ? 'rotate-180 text-blue-400' : 'text-gray-500'}`}></span> <span className={`transition-transform duration-300 ${isMobileMenuOpen ? 'rotate-180 text-blue-400' : 'text-gray-500'}`}></span>
</button> </button>
{isMobileMenuOpen && ( {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"> <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 => ( {(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit', 'trash'] as TabType[]).map(tab => (
<button <button
key={tab} key={tab}
onClick={() => handleTabChange(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'}`} 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'} {tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : tab === 'audit' ? '📋 Auditoría' : '🗑️ Papelera'}
</button> </button>
))} ))}
</div> </div>
@@ -179,13 +251,13 @@ export default function AdminPage() {
{/* Menú tradicional para Escritorio */} {/* Menú tradicional para Escritorio */}
<div className="hidden md:flex bg-white/5 p-1.5 rounded-2xl border border-white/5 backdrop-blur-xl"> <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 => ( {(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit', 'trash'] as TabType[]).map(tab => (
<button <button
key={tab} key={tab}
onClick={() => handleTabChange(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'}`} 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'} {tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : tab === 'audit' ? '📋 Auditoría' : '🗑️ Papelera'}
</button> </button>
))} ))}
</div> </div>
@@ -276,8 +348,10 @@ export default function AdminPage() {
onChange={e => setAdsFilters({ ...adsFilters, statusId: e.target.value })} 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" 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> <option value="" className="bg-gray-900">Activos y Otros</option>
{Object.entries(STATUS_CONFIG).map(([id, config]) => ( {Object.entries(STATUS_CONFIG)
.filter(([id]) => id !== "9") // Excluimos eliminados de la lista de Avisos
.map(([id, config]) => (
<option key={id} value={id} className="bg-gray-900">{config.label}</option> <option key={id} value={id} className="bg-gray-900">{config.label}</option>
))} ))}
</select> </select>
@@ -292,7 +366,7 @@ export default function AdminPage() {
</div> </div>
{/* Lista de Avisos (Escritorio / Tabla) */} {/* Lista de Avisos (Escritorio / Tabla) */}
<div className="hidden md:block glass rounded-[2.5rem] overflow-hidden border border-white/5"> <div className="hidden md:block glass rounded-[2.5rem] overflow-visible border border-white/5">
<table className="w-full text-left"> <table className="w-full text-left">
<thead className="bg-white/5"> <thead className="bg-white/5">
<tr> <tr>
@@ -303,10 +377,9 @@ export default function AdminPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
{data.ads.map((ad: any) => { {data.ads.map((ad: any, index: number) => {
const statusConfig = STATUS_CONFIG[ad.statusID] || { label: 'Desc.', bg: 'bg-gray-500', color: 'text-white' };
return ( return (
<tr key={ad.adID} className="hover:bg-white/5 transition-colors"> <tr key={ad.adID} className={`hover:bg-white/5 transition-colors relative ${ad.statusID === 9 ? 'opacity-60 bg-red-900/5' : ''}`} style={{ zIndex: 50 - index }}>
<td className="px-8 py-5"> <td className="px-8 py-5">
<div className="flex items-center gap-4"> <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="" /> <img src={getImageUrl(ad.thumbnail)} className="w-20 h-14 object-cover rounded-xl border border-white/10" alt="" />
@@ -330,9 +403,12 @@ export default function AdminPage() {
</div> </div>
</td> </td>
<td className="px-8 py-5"> <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}`}> <div className="w-40 relative">
{statusConfig.label} <StatusDropdown
</span> currentStatus={ad.statusID}
onChange={(newStatus) => initiateStatusChange(ad.adID, newStatus)}
/>
</div>
</td> </td>
<td className="px-8 py-5 text-right"> <td className="px-8 py-5 text-right">
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
@@ -367,11 +443,10 @@ export default function AdminPage() {
{/* Lista de Avisos (Móvil / Cards) */} {/* Lista de Avisos (Móvil / Cards) */}
<div className="md:hidden space-y-4"> <div className="md:hidden space-y-4">
{data.ads.map((ad: any) => { {data.ads.map((ad: any, index: number) => {
const statusConfig = STATUS_CONFIG[ad.statusID] || { label: 'Desc.', bg: 'bg-gray-500', color: 'text-white' };
return ( return (
<div key={ad.adID} className="glass p-5 rounded-3xl border border-white/5 space-y-4 shadow-xl"> <div key={ad.adID} className={`glass p-5 rounded-3xl border space-y-4 shadow-xl relative transition-all ${ad.statusID === 9 ? 'border-red-500/30 bg-red-900/10 opacity-70' : 'border-white/5'}`} style={{ zIndex: 50 - index }}>
<div className="flex gap-4"> <div className="flex gap-4 items-start">
<img src={getImageUrl(ad.thumbnail)} className="w-24 h-16 object-cover rounded-xl border border-white/10" alt="" /> <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"> <div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1"> <div className="flex flex-wrap items-center gap-2 mb-1">
@@ -382,9 +457,12 @@ export default function AdminPage() {
</span> </span>
)} )}
</div> </div>
<span className={`inline-block px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-tighter border ${statusConfig.bg} ${statusConfig.color} ${statusConfig.border}`}> <div className="mt-2 w-full max-w-[200px]">
{statusConfig.label} <StatusDropdown
</span> currentStatus={ad.statusID}
onChange={(newStatus) => initiateStatusChange(ad.adID, newStatus)}
/>
</div>
</div> </div>
</div> </div>
@@ -457,6 +535,152 @@ export default function AdminPage() {
</div> </div>
)} )}
{/* === VISTA PAPELERA === */}
{activeTab === 'trash' && data.ads && (
<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-red-500/10 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 en papelera..."
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-2xl px-12 py-4 text-sm text-white outline-none focus:border-red-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>
<button
onClick={() => { setAdsFilters({ ...adsFilters, page: 1 }); loadData(); }}
className="w-full md:w-auto bg-red-600 hover:bg-red-500 text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95"
>
Filtrar Papelera
</button>
</div>
<div className="hidden md:block glass rounded-[2.5rem] overflow-hidden border border-red-500/10">
<table className="w-full text-left">
<thead className="bg-red-900/10">
<tr>
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-red-500">Aviso Eliminado</th>
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-gray-500">Eliminado el</th>
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-red-400">Borrado Definitivo</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 deleteDate = ad.deletedAt ? parseUTCDate(ad.deletedAt) : null;
const hardDeleteDate = deleteDate ? new Date(deleteDate.getTime() + (60 * 24 * 60 * 60 * 1000)) : null;
return (
<tr key={ad.adID} className="hover:bg-red-900/5 transition-colors">
<td className="px-8 py-5">
<div className="flex items-center gap-4">
<img src={getImageUrl(ad.thumbnail)} className="w-16 h-12 object-cover rounded-lg border border-white/10 grayscale opacity-50" alt="" />
<div>
<span className="text-sm font-black text-gray-300 uppercase block">{ad.brandName} {ad.versionName}</span>
<span className="text-[10px] text-gray-600 font-bold">ID: #{ad.adID} {ad.userName}</span>
</div>
</div>
</td>
<td className="px-8 py-5">
<span className="text-sm text-gray-400 font-medium">
{deleteDate ? deleteDate.toLocaleDateString() : 'N/A'}
</span>
</td>
<td className="px-8 py-5">
<div className="flex flex-col">
<span className="text-sm text-red-400 font-black">
{hardDeleteDate ? hardDeleteDate.toLocaleDateString() : 'N/A'}
</span>
<span className="text-[9px] text-red-400/50 uppercase font-bold italic">Auto-limpieza programada</span>
</div>
</td>
<td className="px-8 py-5 text-right">
<button
onClick={() => initiateStatusChange(ad.adID, AD_STATUSES.DRAFT)}
className="bg-white/5 hover:bg-amber-600/20 text-amber-400 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest border border-white/10 hover:border-amber-500/30 transition-all"
>
Restaurar como Borrador
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Móvil Papelera */}
<div className="md:hidden space-y-4">
{data.ads.map((ad: any) => {
const deleteDate = ad.deletedAt ? parseUTCDate(ad.deletedAt) : null;
const hardDeleteDate = deleteDate ? new Date(deleteDate.getTime() + (60 * 24 * 60 * 60 * 1000)) : null;
return (
<div key={ad.adID} className="glass p-5 rounded-3xl border border-red-500/10 space-y-4 opacity-80">
<div className="flex gap-4">
<img src={getImageUrl(ad.thumbnail)} className="w-20 h-14 object-cover rounded-xl grayscale opacity-50" alt="" />
<div className="flex-1">
<h4 className="text-sm font-black text-white uppercase">{ad.brandName} {ad.versionName}</h4>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest">ID: #{ad.adID}</p>
</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">Eliminado el</span>
<span className="text-xs text-white">{deleteDate ? deleteDate.toLocaleDateString() : 'N/A'}</span>
</div>
<div className="flex flex-col">
<span className="text-[8px] font-black text-red-400 uppercase tracking-widest">Borrado definitivo</span>
<span className="text-xs text-red-400 font-bold">{hardDeleteDate ? hardDeleteDate.toLocaleDateString() : 'N/A'}</span>
</div>
</div>
<button
onClick={() => initiateStatusChange(ad.adID, AD_STATUSES.DRAFT)}
className="w-full bg-amber-600/10 text-amber-400 py-3 rounded-xl border border-amber-500/20 text-[10px] font-black uppercase tracking-widest"
>
Restaurar como Borrador
</button>
</div>
);
})}
</div>
{data.ads.length === 0 && (
<div className="p-20 text-center glass rounded-[2.5rem] border-dashed border-2 border-white/5">
<p className="text-gray-500 font-bold uppercase tracking-widest">La papelera está vacía.</p>
</div>
)}
{/* Paginación Papelera */}
{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"
>
</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">
{data.page} / {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"
>
</button>
</div>
)}
</div>
)}
{/* VISTA MODERACIÓN */} {/* VISTA MODERACIÓN */}
{activeTab === 'moderation' && Array.isArray(data) && ( {activeTab === 'moderation' && Array.isArray(data) && (
<div className="space-y-6"> <div className="space-y-6">
@@ -965,6 +1189,21 @@ export default function AdminPage() {
onUpdate={loadData} onUpdate={loadData}
/> />
)} )}
{/* 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> </div>
); );
} }
@@ -987,3 +1226,86 @@ function DashboardMiniCard({ label, value, icon, color = 'blue' }: { label: stri
</div> </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 left-0">
{ALLOWED_STATUSES.map((statusId) => {
const config = STATUS_CONFIG[statusId];
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>
);
}

View File

@@ -0,0 +1,60 @@
import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
/**
* Página pública que el backend redirige cuando el token de baja es inválido o expirado.
*/
export default function BajaErrorPage() {
const [show, setShow] = useState(false);
useEffect(() => {
const t = setTimeout(() => setShow(true), 80);
return () => clearTimeout(t);
}, []);
return (
<div className="min-h-screen bg-[#0a0c10] flex items-center justify-center p-6">
<div className="fixed top-[-10%] left-[-10%] w-[40%] h-[40%] bg-red-600/5 blur-[120px] rounded-full pointer-events-none" />
<div className="fixed bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-orange-400/5 blur-[120px] rounded-full pointer-events-none" />
<div
className={`relative max-w-md w-full glass p-10 rounded-[2.5rem] border border-white/10 shadow-2xl text-center
transition-all duration-700 ${show ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'}`}
>
{/* Ícono */}
<div className="w-20 h-20 bg-red-600/15 rounded-full flex items-center justify-center mx-auto mb-6 border border-red-500/20 shadow-lg shadow-red-500/10">
<svg className="w-10 h-10 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
{/* Título */}
<h1 className="text-3xl font-black uppercase tracking-tighter text-white mb-3">
Enlace <span className="text-red-400">Inválido</span>
</h1>
<p className="text-gray-400 text-sm font-medium leading-relaxed mb-8">
El enlace de baja que usaste es inválido, ya fue utilizado o expiró.
Si querés gestionar tus preferencias, iniciá sesión y accedé a tu perfil.
</p>
<div className="flex flex-col gap-3">
<Link
to="/perfil"
className="inline-block bg-blue-600 hover:bg-blue-500 text-white py-3.5 px-10 rounded-2xl
text-[11px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20
hover:shadow-blue-500/40 hover:-translate-y-0.5 active:scale-95"
>
Ir a Mi Perfil
</Link>
<Link
to="/"
className="inline-block text-gray-500 hover:text-gray-300 text-xs font-bold uppercase
tracking-widest transition-colors"
>
Ir al Inicio
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useSearchParams, Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
/**
* Página pública que el backend redirige tras procesar una baja exitosa.
* No requiere inicio de sesión.
*/
export default function BajaExitosaPage() {
const [params] = useSearchParams();
const categoria = params.get('categoria') ?? 'la lista de correos';
const [show, setShow] = useState(false);
// Animación de entrada suave
useEffect(() => {
const t = setTimeout(() => setShow(true), 80);
return () => clearTimeout(t);
}, []);
return (
<div className="min-h-screen bg-[#0a0c10] flex items-center justify-center p-6">
{/* Fondo decorativo */}
<div className="fixed top-[-10%] left-[-10%] w-[40%] h-[40%] bg-green-600/5 blur-[120px] rounded-full pointer-events-none" />
<div className="fixed bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-cyan-400/5 blur-[120px] rounded-full pointer-events-none" />
<div
className={`relative max-w-md w-full glass p-10 rounded-[2.5rem] border border-white/10 shadow-2xl text-center
transition-all duration-700 ${show ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'}`}
>
{/* Ícono */}
<div className="w-20 h-20 bg-green-600/15 rounded-full flex items-center justify-center mx-auto mb-6 border border-green-500/20 shadow-lg shadow-green-500/10">
<svg className="w-10 h-10 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
{/* Título */}
<h1 className="text-3xl font-black uppercase tracking-tighter text-white mb-3">
Baja <span className="text-green-400">Procesada</span>
</h1>
<p className="text-gray-400 text-sm font-medium leading-relaxed mb-2">
Te diste de baja exitosamente de:
</p>
<p className="text-white font-black text-base uppercase tracking-widest mb-8 px-4 py-2 bg-white/5 rounded-xl border border-white/10">
{categoria}
</p>
<p className="text-gray-500 text-xs font-medium mb-8 leading-relaxed">
Ya no recibirás correos de esta categoría.
Podés volver a activarlos en cualquier momento desde la sección{' '}
<span className="text-blue-400 font-bold">Preferencias de Notificación</span> en tu perfil.
</p>
<Link
to="/"
className="inline-block bg-blue-600 hover:bg-blue-500 text-white py-3.5 px-10 rounded-2xl
text-[11px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20
hover:shadow-blue-500/40 hover:-translate-y-0.5 active:scale-95"
>
Ir al Inicio
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useState } from 'react';
/**
* Página de Términos y Condiciones.
*/
export default function CondicionesPage() {
const [show, setShow] = useState(false);
useEffect(() => {
window.scrollTo(0, 0);
const t = setTimeout(() => setShow(true), 100);
return () => clearTimeout(t);
}, []);
return (
<div className="container mx-auto px-6 py-12 max-w-4xl">
<div className={`transition-all duration-700 ${show ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'}`}>
<div className="mb-12">
<h1 className="text-5xl font-black tracking-tighter uppercase mb-2">
Términos y <span className="text-blue-500">Condiciones</span>
</h1>
<p className="text-gray-500 font-bold tracking-widest uppercase text-xs">
Información legal y condiciones de uso del sitio
</p>
</div>
<div className="glass p-8 md:p-12 rounded-[2.5rem] border border-white/5 space-y-8 relative overflow-hidden">
{/* Decoración sutil de fondo */}
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-500/5 blur-[100px] rounded-full -mr-32 -mt-32 pointer-events-none" />
<div className="prose prose-invert max-w-none">
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
1. ACEPTACIÓN DE LAS CONDICIONES.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
El servicio de publicación de avisos de venta de vehículos (en adelante el "Servicio") es ofrecido por El Día S.A., en su carácter de responsable de la operación y explotación del sitio motoresargentinos.com (de aquí en más el "Sitio") a los anunciantes y/o usuarios (en adelante individualmente el "Usuario" y genéricamente los "Usuarios") que accedan y se registren en el Sitio motoresargentinos.com a fines de proceder con una o más publicaciones, con la condición de que acepten sin ninguna objeción todas las condiciones que se describen a continuación. Asimismo, debido a que ciertos contenidos que puedan ser accedidos a través del Sitio podrán estar alcanzados por normas específicas que reglamenten y complementen las presentes, se recomienda a los Usuarios tomar conocimiento específico de ellas a través del sitio.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
2. ALCANCE DE LAS CONDICIONES.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
Además de lo estipulado en la última parte del apartado precedente, las presentes condiciones y las normas que los complementan sólo serán aplicables a los servicios y contenidos prestados y/o accesibles directamente en el Sitio y no a aquellos a los que los Usuarios puedan acceder a través de un hipervínculo (link), una barra co-branded, y/o cualquier otra herramienta de navegación ubicada en el Sitio que los lleve a navegar un recurso diferente.
La probable aparición de dichos links en el Sitio no implica de modo alguno la asunción de garantía por parte del Sitio sobre los productos, servicios, o programas contenidos en ninguna página vinculada al Sitio por tales links. Se declara no haber revisado ninguna de las páginas a las que pueda llegar a accederse desde el Sitio y, en consecuencia, se deslinda toda responsabilidad por el contenido de las mismas. En atención a ello, la utilización de los links para navegar hacia cualquier otra página queda al exclusivo criterio y riesgo de los Usuarios.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
3. INGRESO Y PUBLICACIÓN DE CLASIFICADOS.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
A fin de publicar un aviso clasificado a través del Sitio, el Usuario deberá registrarse e ingresar de manera correcta sus datos personales, así como el texto, fotos y demás información del aviso que pretende publicar. El Usuario deberá aceptar la totalidad de las condiciones de contratación.
Los Usuarios aceptan que sólo podrán publicarse aquellos vehículos cuyas características se encuentren contenidas en los nomencladores de marcas, modelos y versiones bajo los cuales opera el Sitio.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
4. PUBLICACIÓN EN EL SITIO WEB.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
Cumplidos los requisitos del apartado anterior, se publicarán los avisos en el Sitio por treinta (30) días, pudiendo ser los mismos modificados (excepto la información de marca, modelo y año de fabricación) y/o dados de baja por el Usuario.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
5. ESPACIO ASIGNADO EN EL SERVICIO.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
El Sitio tiene la libre facultad de establecer y modificar la cantidad de espacio mínimo y máximo de MB que el Usuario oferente puede utilizar, para publicar los avisos clasificados.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
5. ESPACIO ASIGNADO EN EL SERVICIO.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
El Sitio tiene la libre facultad de establecer y modificar la cantidad de espacio mínimo y máximo de MB que el Usuario oferente puede utilizar, para publicar los avisos clasificados.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
6. CALIDAD DE LOS PRODUCTOS PROMOCIONADOS.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
El Sitio no pretende contener una lista exhaustiva de todos los productos del mercado y no manifiesta ni garantiza de modo alguno que no existan otros productos en el mercado, incluso más convenientes, en precio o condiciones, como así tampoco que no existan otros productos que cumplan la misma función que los publicados en el Sitio.
En consecuencia, sugiere firmemente que la información brindada por el Sitio respecto de los productos publicados, sea objeto de una investigación independiente y propia de quien esté interesado en la misma, no asumiendo ningún tipo de responsabilidad por la incorrección de la información, su desactualización o falsedad.
Tampoco asume ninguna obligación respecto del Usuario y/o los visitantes en general y se limita tan sólo a publicar en el Sitio en forma similar a aquella en que lo haría una guía telefónica o la sección clasificados de un periódico impreso, los datos de los Usuarios proveedores de productos o servicios que han solicitado tal publicación y en la forma en que tales datos han sido proporcionados por tales Usuarios.
Tampoco garantiza en forma alguna dichos productos y servicios, ya sea respecto de su calidad, condiciones de entrega, como respecto de ningún otro aspecto, ni garantiza a los Usuarios y/o visitantes en general respecto de la existencia, crédito, capacidad ni sobre ningún otro aspecto de los Usuarios proveedores de tales productos y servicios.
El contrato de compraventa de productos o la contratación de los servicios con sus proveedores se realiza fuera de la esfera de participación del sitio y sin su intervención, directamente entre el Usuario oferente y quien resulte comprador del producto o servicio por aquél ofrecido. En virtud de ello, no otorga garantía de evicción ni por vicios ocultos o aparentes de los bienes publicados por el Usuario oferente y adquiridos por quien resulte comprador.
Asimismo, los Usuarios aceptan que el sitio no controla, ni supervisa, el cumplimiento de los requisitos legales para ofrecer y vender los productos o servicios, ni sobre la capacidad y legitimación de los Usuarios oferentes para promocionar, ofrecer y/o vender sus bienes o servicios.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
7. PROCEDIMIENTO
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
MotoresArgentinos.com coloca gratuitamente a disposición de los usuarios, cierta cantidad de precios informados por ciertos proveedores de determinados bienes y servicios del sector automotor.
Si el usuario está interesado en adquirir alguno de los productos que figuran en MotoresArgentinos.com, deberá hacerlo saber a través de la opción existente en la página.
MotoresArgentinos.com informará al proveedor del producto en que el usuario esté interesado a fin de que tal proveedor contacte directamente al usuario.
La remuneración que percibe MotoresArgentinos.com del proveedor está determinada por un monto fijo en relación a la publicación (clasificado) activa en MotoresArgentinos.com. que no garantiza en modo alguno que el Proveedor respete tal precio o tales condiciones al momento de contratar y, en consecuencia, no se hace responsable de ningún gasto que hubiera podido realizar el usuario ni de ningún daño que hubiera podido sufrir el usuario sobre la base de su asunción de que tal precio o tales condiciones serían mantenidos.
Debe tenerse presente que se debe cumplir con el artículo 7 de la Ley de Defensa del Consumidor e informar la fecha precisa de comienzo y finalización de la oferta así como sus modalidades condiciones y limitaciones y, si el proveedor limita cuantitativamente su oferta, informar la cantidad con que cuenta para cubrirla; en caso de que se otorgue financiamiento deberá consignarse precio de contado, saldo de deuda, el total de los intereses a pagar, la tasa de interés efectiva anual, la forma de amortización de los intereses, otros gastos si los hubiere, cantidad de pagos a realizar y su periodicidad, gastos extras o adicionales si los hubiera y monto total financiado a pagar.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
8. CONDUCTA DE LOS USUARIOS.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
Los Usuarios se comprometen a no utilizar el Servicio para:
a. ofrecer o publicar material ilegal, difamatorio, obsceno, pornográfico, racista, discriminatorio, agraviante, injurioso o que afecte la privacidad de las personas;
b. ofrecer o publicar fotografías, propias o de terceros, cuya imagen sea obscena, inmoral o contraria a las buenas costumbres;
c. ofrecer o publicar material mediante la falsificación de su identidad;
d. ofrecer o publicar material en infracción a la ley; violar cualquier legislación aplicable local, federal, nacional o internacional.
e. ofrecer, publicar o crear una base de datos personales de terceros.
El incumplimiento por parte de los Usuarios de cualquiera de las condiciones precedentes, implicará de inmediato la no publicación o baja del aviso en el Sitio, conforme lo estipulado en el apartado 9.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
9. LOS USUARIOS EXPRESAMENTE COMPRENDEN Y ACEPTAN QUE:
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
a. La utilización del Servicio es a su solo riesgo;
b. No se garantiza que el Servicio sea el adecuado a sus necesidades;
c. El Servicio puede ser suspendido o interrumpido;
d. El Servicio puede contener errores;
e. El Sitio no será responsable por ningún daño o perjuicio, directo o indirecto, incluyendo, sin ningún tipo de limitación, daños producidos por la pérdida o deterioro de información;
f. Los Usuarios son los únicos responsables de los contenidos de la información que se publica a través del Servicio;
g. El Sitio se reserva el derecho a terminar el Servicio.
h. El Servicio puede no siempre estar disponible debido a dificultades técnicas o fallas de Internet, o por cualquier otro motivo ajeno, por lo que no podrá imputarse responsabilidad alguna.
i. El contenido de las distintas pantallas del Sitio, junto con sus programas, bases de datos, redes y archivos, son de propiedad de MotoresArgentinos.com., en forma alternativa o conjunta sin limitación alguna Su uso indebido así como su reproducción no autorizada podrá dar lugar a las acciones judiciales que correspondan.
j. La utilización del Servicio no podrá, en ningún supuesto, ser interpretada como una autorización y/o concesión de licencia para la utilización de los derechos intelectuales del Sitio y/o de un tercero.
k. La utilización de Internet en general y del Sitio en particular, implica la asunción de riesgos de potenciales daños al software y al hardware del Usuario. Por tal motivo, el equipo terminal desde el cual acceda al Sitio el Usuario, estaría en condiciones de resultar atacado y dañado por la acción de hackers quienes podrían incluso acceder a la información contenida en el equipo terminal del Usuario, extraerla, sustraerla y/o dañarla.
l. Paralelamente, el intercambio de información a través de Internet tiene el riesgo de que tal información pueda ser captada por un tercero y el Sitio no se hace responsable de las consecuencias que pudiera acarrear al Usuario tal hipótesis.
m. No existe obligación alguna de conservar información que haya estado disponible para los Usuarios, ni que le haya sido enviada por éstos últimos.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
10. RESPONSABILIDAD.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
Los Usuarios resultan responsables de toda afirmación y/o expresión y/o acto celebrado con su nombre de usuario y contraseña.
Los Usuarios aceptan y reconocen que el Sitio no será responsable, contractual o extracontractualmente, por ningún daño o perjuicio, directo o indirecto, derivado de la utilización del Servicio.
Los usuarios resultan responsables de toda información suministrada en los campos de texto disponibles. MotoresArgentinos.com no se responsabiliza por la publicación de números telefónicos en dichos campos, siendo esto total responsabilidad del cliente.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
11. INDEMNIDAD.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
Los Usuarios asumen total responsabilidad frente al Sitio y a terceros por los daños y/o perjuicios de toda clase que se generen como consecuencia del uso del Servicio, debiendo indemnizar y mantener indemne al Sitio y a terceros ante cualquier reclamo (incluyendo honorarios profesionales) que pudiera corresponder en los supuestos indicados.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
12. BASE DE DATOS.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
motoresargentinos.com se compromete a no ceder, vender, ni entregar a otras empresas o personas físicas, la información suministrada por los Usuarios.
Los Usuarios aceptan por el hecho de registrarse como tales en el Sitio, el derecho de comunicarse con ellos en forma telefónica o vía electrónica; ello, hasta tanto los Usuarios hagan saber su decisión en contrario por medio fehaciente.
Los Usuarios no podrán hacer responsable a MotoresArgentinos.com y/o a ningún tercero por la suspensión o terminación del Servicio.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
13. ARBITRAJE; LEY APLICABLE Y JURISDICCION.
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
Las presentes condiciones y las normas que lo complementan, constituyen un acuerdo legal entre los Usuarios y MotoresArgentinos.com. Toda controversia que se suscite en relación a su existencia, validez, calificación, interpretación, alcance o cumplimiento, se resolverá definitivamente por el Tribunal de Arbitraje General de la Bolsa de Comercio de Buenos Aires de acuerdo con la reglamentación vigente de dicho tribunal.
El presente contrato será interpretado y hecho valer de acuerdo con las leyes de la República Argentina, sin tomar en cuenta sus normas sobre conflictos de leyes. Respecto de la ejecución del laudo arbitral, las partes se someterán a la jurisdicción de los tribunales de competencia comercial de la ciudad de Buenos Aires. Las partes irrevocablemente renuncian a ejecutar cualquier objeción basada en la jurisdicción o contender esta bajo cualquier argumento por razón de sus domicilios presentes, futuros o por cualquier otra causa.
La utilización del Servicio está expresamente prohibida en toda jurisdicción en donde no puedan ser aplicadas las condiciones aquí establecidas.
Si los Usuarios utilizan el Servicio, significa que han leído, entendido y acordado las normas antes expuestas. Si no están de acuerdo con ellas, tienen la opción de no utilizar el Servicio.
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-tight mb-4 border-b border-white/10 pb-2">
14. DOMICILIO
</h2>
<p className="text-gray-400 leading-relaxed mb-8">
Toda notificación u otra comunicación que deba efectuarse bajo estas condiciones, deberá realizarse por escrito: I- al Usuario: a la cuenta de correo electrónico por él ingresada o por carta documento dirigida al domicilio declarado en su ficha de registración o II- al Sitio a la cuenta de correo electrónico contacto@motoresargentinos.com.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,17 +1,17 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { useSearchParams, Link } from 'react-router-dom'; import { useSearchParams, Link } from "react-router-dom";
import { AdsV2Service, type AdListingDto } from '../services/ads.v2.service'; import { AdsV2Service, type AdListingDto } from "../services/ads.v2.service";
import { getImageUrl, formatCurrency } from '../utils/app.utils'; import { getImageUrl, formatCurrency } from "../utils/app.utils";
import SearchableSelect from '../components/SearchableSelect'; import SearchableSelect from "../components/SearchableSelect";
import AdStatusBadge from '../components/AdStatusBadge'; import AdStatusBadge from "../components/AdStatusBadge";
import { import {
AUTO_SEGMENTS, AUTO_SEGMENTS,
MOTO_SEGMENTS, MOTO_SEGMENTS,
AUTO_TRANSMISSIONS, AUTO_TRANSMISSIONS,
MOTO_TRANSMISSIONS, MOTO_TRANSMISSIONS,
FUEL_TYPES, FUEL_TYPES,
VEHICLE_CONDITIONS VEHICLE_CONDITIONS,
} from '../constants/vehicleOptions'; } from "../constants/vehicleOptions";
export default function ExplorarPage() { export default function ExplorarPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -19,25 +19,29 @@ export default function ExplorarPage() {
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 [minPrice, setMinPrice] = useState(searchParams.get('minPrice') || ''); const [minPrice, setMinPrice] = useState(searchParams.get("minPrice") || "");
const [maxPrice, setMaxPrice] = useState(searchParams.get('maxPrice') || ''); const [maxPrice, setMaxPrice] = useState(searchParams.get("maxPrice") || "");
const [currencyFilter, setCurrencyFilter] = useState(searchParams.get('currency') || ''); const [currencyFilter, setCurrencyFilter] = useState(
const [minYear, setMinYear] = useState(searchParams.get('minYear') || ''); searchParams.get("currency") || "",
const [maxYear, setMaxYear] = useState(searchParams.get('maxYear') || ''); );
const [brandId, setBrandId] = useState(searchParams.get('brandId') || ''); const [minYear, setMinYear] = useState(searchParams.get("minYear") || "");
const [modelId, setModelId] = useState(searchParams.get('modelId') || ''); const [maxYear, setMaxYear] = useState(searchParams.get("maxYear") || "");
const [fuel, setFuel] = useState(searchParams.get('fuel') || ''); const [brandId, setBrandId] = useState(searchParams.get("brandId") || "");
const [transmission, setTransmission] = useState(searchParams.get('transmission') || ''); 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 [brands, setBrands] = useState<{ id: number; name: string }[]>([]);
const [models, setModels] = useState<{ id: number, name: string }[]>([]); const [models, setModels] = useState<{ id: number; name: string }[]>([]);
const q = searchParams.get('q') || ''; const q = searchParams.get("q") || "";
const c = searchParams.get('c') || 'ALL'; const c = searchParams.get("c") || "ALL";
useEffect(() => { useEffect(() => {
if (c !== 'ALL') { if (c !== "ALL") {
const typeId = c === 'EAUTOS' ? 1 : 2; const typeId = c === "EAUTOS" ? 1 : 2;
AdsV2Service.getBrands(typeId).then(setBrands); AdsV2Service.getBrands(typeId).then(setBrands);
} else { } else {
setBrands([]); setBrands([]);
@@ -61,7 +65,7 @@ export default function ExplorarPage() {
try { try {
const data = await AdsV2Service.getAll({ const data = await AdsV2Service.getAll({
q, q,
c: c === 'ALL' ? undefined : c, c: c === "ALL" ? undefined : c,
minPrice: minPrice ? Number(minPrice) : undefined, minPrice: minPrice ? Number(minPrice) : undefined,
maxPrice: maxPrice ? Number(maxPrice) : undefined, maxPrice: maxPrice ? Number(maxPrice) : undefined,
currency: currencyFilter || undefined, currency: currencyFilter || undefined,
@@ -70,7 +74,13 @@ export default function ExplorarPage() {
brandId: brandId ? Number(brandId) : undefined, brandId: brandId ? Number(brandId) : undefined,
modelId: modelId ? Number(modelId) : undefined, modelId: modelId ? Number(modelId) : undefined,
fuel: fuel || undefined, fuel: fuel || undefined,
transmission: transmission || undefined transmission: transmission || undefined,
color: searchParams.get("color") || undefined,
location: searchParams.get("location") || undefined,
condition: searchParams.get("condition") || undefined,
segment: searchParams.get("segment") || undefined,
doorCount: searchParams.get("doorCount") ? Number(searchParams.get("doorCount")) : undefined,
steering: searchParams.get("steering") || undefined,
}); });
setListings(data); setListings(data);
} catch (err) { } catch (err) {
@@ -86,32 +96,47 @@ export default function ExplorarPage() {
const applyFilters = () => { const applyFilters = () => {
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
if (minPrice) newParams.set('minPrice', minPrice); else newParams.delete('minPrice'); if (minPrice) newParams.set("minPrice", minPrice);
if (maxPrice) newParams.set('maxPrice', maxPrice); else newParams.delete('maxPrice'); else newParams.delete("minPrice");
if (currencyFilter) newParams.set('currency', currencyFilter); else newParams.delete('currency'); if (maxPrice) newParams.set("maxPrice", maxPrice);
if (minYear) newParams.set('minYear', minYear); else newParams.delete('minYear'); else newParams.delete("maxPrice");
if (maxYear) newParams.set('maxYear', maxYear); else newParams.delete('maxYear'); if (currencyFilter) newParams.set("currency", currencyFilter);
if (brandId) newParams.set('brandId', brandId); else newParams.delete('brandId'); else newParams.delete("currency");
if (modelId) newParams.set('modelId', modelId); else newParams.delete('modelId'); if (minYear) newParams.set("minYear", minYear);
if (fuel) newParams.set('fuel', fuel); else newParams.delete('fuel'); else newParams.delete("minYear");
if (transmission) newParams.set('transmission', transmission); else newParams.delete('transmission'); 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); setSearchParams(newParams);
}; };
const clearFilters = () => { const clearFilters = () => {
setMinPrice(''); setMaxPrice(''); setMinYear(''); setMaxYear(''); setMinPrice("");
setCurrencyFilter(''); setMaxPrice("");
setBrandId(''); setModelId(''); setFuel(''); setTransmission(''); setMinYear("");
setMaxYear("");
setCurrencyFilter("");
setBrandId("");
setModelId("");
setFuel("");
setTransmission("");
const newParams = new URLSearchParams(); const newParams = new URLSearchParams();
if (q) newParams.set('q', q); if (q) newParams.set("q", q);
if (c !== 'ALL') newParams.set('c', c); if (c !== "ALL") newParams.set("c", c);
setSearchParams(newParams); setSearchParams(newParams);
}; };
const handleCategoryFilter = (cat: string) => { const handleCategoryFilter = (cat: string) => {
const newParams = new URLSearchParams(); const newParams = new URLSearchParams();
if (q) newParams.set('q', q); if (q) newParams.set("q", q);
if (cat !== 'ALL') newParams.set('c', cat); if (cat !== "ALL") newParams.set("c", cat);
setSearchParams(newParams); setSearchParams(newParams);
}; };
@@ -119,23 +144,29 @@ export default function ExplorarPage() {
<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"> <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 <button
onClick={() => setShowMobileFilters(true)} 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'}`} 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> <span>🔍 FILTRAR</span>
</button> </button>
{/* Sidebar Filters - NATURAL FLOW (NO STICKY, NO SCROLL INTERNO) */} {/* Sidebar Filters - NATURAL FLOW (NO STICKY, NO SCROLL INTERNO) */}
<aside className={` <aside
className={`
fixed inset-0 z-[105] bg-black/80 backdrop-blur-xl transition-all duration-500 overflow-y-auto md:overflow-visible 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 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'} ${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=" >
<div
className="
glass p-6 rounded-[2rem] border border-white/5 shadow-2xl glass p-6 rounded-[2rem] border border-white/5 shadow-2xl
h-fit m-6 mt-28 md:m-0 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"> <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> <h3 className="text-xl font-black tracking-tighter uppercase">
FILTROS
</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={clearFilters} onClick={clearFilters}
@@ -155,22 +186,32 @@ export default function ExplorarPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Categoría */} {/* Categoría */}
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Tipo de Vehículo</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
Tipo de Vehículo
</label>
<select <select
value={c} value={c}
onChange={(e) => handleCategoryFilter(e.target.value)} 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" 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="ALL" className="bg-gray-900">
<option value="EAUTOS" className="bg-gray-900">Automóviles</option> Todos
<option value="EMOTOS" className="bg-gray-900">Motos</option> </option>
<option value="EAUTOS" className="bg-gray-900">
Automóviles
</option>
<option value="EMOTOS" className="bg-gray-900">
Motos
</option>
</select> </select>
</div> </div>
{c !== 'ALL' && ( {c !== "ALL" && (
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Marca</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
Marca
</label>
<SearchableSelect <SearchableSelect
options={brands} options={brands}
value={brandId} value={brandId}
@@ -181,15 +222,25 @@ export default function ExplorarPage() {
{brandId && ( {brandId && (
<div className="animate-fade-in"> <div className="animate-fade-in">
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Modelo</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
Modelo
</label>
<select <select
value={modelId} value={modelId}
onChange={e => setModelId(e.target.value)} 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" 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> <option value="" className="bg-gray-900 text-gray-500">
{models.map(m => ( Todos los modelos
<option key={m.id} value={m.id} className="bg-gray-900 text-white">{m.name}</option> </option>
{models.map((m) => (
<option
key={m.id}
value={m.id}
className="bg-gray-900 text-white"
>
{m.name}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -198,85 +249,231 @@ export default function ExplorarPage() {
)} )}
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Moneda</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
Moneda
</label>
<select <select
value={currencyFilter} value={currencyFilter}
onChange={e => setCurrencyFilter(e.target.value)} 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" 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="" className="bg-gray-900 text-gray-500">
<option value="ARS" className="bg-gray-900 text-white">Pesos (ARS)</option> Indistinto
<option value="USD" className="bg-gray-900 text-white">Dólares (USD)</option> </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> </select>
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Precio Máximo</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
<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" /> 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>
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Desde Año</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
<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" /> 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>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Combustible</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
<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"> Combustible
<option value="" className="bg-gray-900 text-gray-500">Todos</option> </label>
{FUEL_TYPES.map(f => (<option key={f} value={f} className="bg-gray-900 text-white">{f}</option>))} <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> </select>
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Transmisión</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
<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"> Transmisión
<option value="" className="bg-gray-900 text-gray-500">Todas</option> </label>
{(c === 'EMOTOS' ? MOTO_TRANSMISSIONS : AUTO_TRANSMISSIONS).map(t => ( <select
<option key={t} value={t} className="bg-gray-900 text-white">{t}</option> 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> </select>
</div> </div>
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Color</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
<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" /> 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>
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Ubicación</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
<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" /> 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>
<div className={`grid ${c === 'EMOTOS' ? 'grid-cols-1' : 'grid-cols-2'} gap-2`}> <div
className={`grid ${c === "EMOTOS" ? "grid-cols-1" : "grid-cols-2"} gap-2`}
>
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Estado</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
<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"> Estado
<option value="" className="bg-gray-900">Todos</option> </label>
{VEHICLE_CONDITIONS.map(o => <option key={o} value={o} className="bg-gray-900">{o}</option>)} <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> </select>
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Segmento</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
<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"> Segmento
<option value="" className="bg-gray-900">Todos</option> </label>
{(c === 'EMOTOS' ? MOTO_SEGMENTS : AUTO_SEGMENTS).map(o => ( <select
<option key={o} value={o} className="bg-gray-900">{o}</option> 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> </select>
</div> </div>
</div> </div>
{c !== 'EMOTOS' && ( {c !== "EMOTOS" && (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Puertas</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
<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" /> 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>
<div> <div>
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Dirección</label> <label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
<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" /> 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> </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"> <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 Aplicar Filtros
</button> </button>
</div> </div>
@@ -285,10 +482,25 @@ export default function ExplorarPage() {
<div className="w-full md:flex-1 md:min-w-0"> <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 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"> <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> <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"> <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" /> <input
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-blue-500 transition-colors">🔍</span> 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>
</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"> <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">
@@ -297,22 +509,45 @@ export default function ExplorarPage() {
</div> </div>
{loading ? ( {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="flex justify-center p-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
) : error ? ( ) : 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> <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 ? ( ) : listings.length === 0 ? (
<div className="glass p-20 rounded-[2.5rem] text-center border-dashed border-2 border-white/10"> <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> <span className="text-6xl mb-6 block">🔍</span>
<h3 className="text-2xl font-bold text-gray-400 uppercase tracking-tighter">Sin coincidencias</h3> <h3 className="text-2xl font-bold text-gray-400 uppercase tracking-tighter">
<p className="text-gray-600 max-w-xs mx-auto mt-2 italic">No encontramos vehículos que coincidan con los filtros seleccionados.</p> Sin coincidencias
<button onClick={clearFilters} className="mt-8 text-blue-400 font-black uppercase text-[10px] tracking-widest">Ver todos los avisos</button> </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>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-8">
{listings.map(car => ( {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"> <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"> <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" /> <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 --- */} {/* --- BLOQUE PARA EL BADGE --- */}
<div className="absolute top-4 left-4 z-10"> <div className="absolute top-4 left-4 z-10">
@@ -331,8 +566,12 @@ export default function ExplorarPage() {
</h3> </h3>
<div className="flex justify-between items-center mt-auto"> <div className="flex justify-between items-center mt-auto">
<div className="flex flex-col"> <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-gray-500 text-[10px] font-black uppercase tracking-widest mb-1">
<span className="text-white font-black text-2xl tracking-tighter">{formatCurrency(car.price, car.currency)}</span> {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> </div>
</div> </div>
@@ -341,6 +580,6 @@ export default function ExplorarPage() {
</div> </div>
)} )}
</div> </div>
</div > </div>
); );
} }

View File

@@ -1,13 +1,13 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from "react";
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from "react-router-dom";
import { AdsV2Service, type AdListingDto } from '../services/ads.v2.service'; import { AdsV2Service, type AdListingDto } from "../services/ads.v2.service";
import { getImageUrl, formatCurrency } from '../utils/app.utils'; import { getImageUrl, formatCurrency } from "../utils/app.utils";
import AdStatusBadge from '../components/AdStatusBadge'; import AdStatusBadge from "../components/AdStatusBadge";
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [query, setQuery] = useState(''); const [query, setQuery] = useState("");
const [category, setCategory] = useState('ALL'); const [category, setCategory] = useState("ALL");
const [featuredAds, setFeaturedAds] = useState<AdListingDto[]>([]); const [featuredAds, setFeaturedAds] = useState<AdListingDto[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -19,10 +19,10 @@ export default function HomePage() {
// Cargar destacados // Cargar destacados
useEffect(() => { useEffect(() => {
AdsV2Service.getAll({ isFeatured: true }) AdsV2Service.getAll({ isFeatured: true })
.then(data => { .then((data) => {
setFeaturedAds(data.slice(0, 3)); setFeaturedAds(data.slice(0, 3));
}) })
.catch(err => console.error("Error cargando destacados:", err)) .catch((err) => console.error("Error cargando destacados:", err))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -45,7 +45,10 @@ export default function HomePage() {
// --- LÓGICA PARA CERRAR SUGERENCIAS AL HACER CLIC FUERA --- // --- LÓGICA PARA CERRAR SUGERENCIAS AL HACER CLIC FUERA ---
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (searchWrapperRef.current && !searchWrapperRef.current.contains(event.target as Node)) { if (
searchWrapperRef.current &&
!searchWrapperRef.current.contains(event.target as Node)
) {
setShowSuggestions(false); setShowSuggestions(false);
} }
} }
@@ -57,7 +60,7 @@ export default function HomePage() {
const handleSearch = (searchTerm: string = query) => { const handleSearch = (searchTerm: string = query) => {
setShowSuggestions(false); setShowSuggestions(false);
// Si la categoría es 'ALL', no enviamos el parámetro 'c' // Si la categoría es 'ALL', no enviamos el parámetro 'c'
const categoryParam = category === 'ALL' ? '' : `&c=${category}`; const categoryParam = category === "ALL" ? "" : `&c=${category}`;
navigate(`/explorar?q=${searchTerm}${categoryParam}`); navigate(`/explorar?q=${searchTerm}${categoryParam}`);
}; };
@@ -87,7 +90,8 @@ export default function HomePage() {
ENCONTRÁ TU <span className="text-gradient">PRÓXIMO</span> VEHÍCULO ENCONTRÁ TU <span className="text-gradient">PRÓXIMO</span> VEHÍCULO
</h1> </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"> <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 web más avanzada para la compra y venta de Autos y Motos en Argentina. La web más avanzada para la compra y venta de Autos y Motos en
Argentina.
</p> </p>
{/* --- CONTENEDOR DEL BUSCADOR CON ref y onFocus --- */} {/* --- CONTENEDOR DEL BUSCADOR CON ref y onFocus --- */}
@@ -95,20 +99,20 @@ export default function HomePage() {
{/* Botones de categoría arriba del buscador */} {/* Botones de categoría arriba del buscador */}
<div className="flex gap-2 mb-3 justify-center"> <div className="flex gap-2 mb-3 justify-center">
<button <button
onClick={() => setCategory('ALL')} 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'}`} 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 Todos
</button> </button>
<button <button
onClick={() => setCategory('EAUTOS')} 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'}`} 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 🚗 Automóviles
</button> </button>
<button <button
onClick={() => setCategory('EMOTOS')} 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'}`} 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 🏍 Motos
</button> </button>
@@ -121,7 +125,7 @@ export default function HomePage() {
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onFocus={() => setShowSuggestions(true)} onFocus={() => setShowSuggestions(true)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} 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" 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 <button
@@ -156,10 +160,19 @@ export default function HomePage() {
<section className="container mx-auto px-4 md:px-6"> <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 className="flex flex-col md:flex-row justify-between items-start md:items-end mb-6 md:mb-10 gap-4">
<div> <div>
<h2 className="text-3xl md:text-4xl font-bold mb-2">Avisos <span className="text-gradient">Destacados</span></h2> <h2 className="text-3xl md:text-4xl font-bold mb-2">
<p className="text-gray-400 text-base md:text-lg italic">Las mejores ofertas seleccionadas para vos.</p> 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> </div>
<Link to="/explorar" className="text-blue-400 hover:text-white transition text-sm md:text-base">Ver todos </Link> <Link
to="/explorar"
className="text-blue-400 hover:text-white transition text-sm md:text-base"
>
Ver todos
</Link>
</div> </div>
{loading ? ( {loading ? (
@@ -168,12 +181,18 @@ export default function HomePage() {
</div> </div>
) : featuredAds.length === 0 ? ( ) : featuredAds.length === 0 ? (
<div className="text-center p-10 glass rounded-3xl border border-white/5"> <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> <p className="text-gray-500 text-xl font-bold uppercase tracking-widest">
No hay avisos destacados por el momento.
</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-8">
{featuredAds.map(car => ( {featuredAds.map((car) => (
<Link to={`/vehiculo/${car.id}`} key={car.id} className="glass-card rounded-2xl md:rounded-3xl overflow-hidden group"> <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"> <div className="aspect-[4/3] overflow-hidden relative bg-[#07090d] flex items-center justify-center border-b border-white/5">
<img <img
src={getImageUrl(car.image)} src={getImageUrl(car.image)}
@@ -184,10 +203,14 @@ export default function HomePage() {
<AdStatusBadge statusId={car.statusId || 4} /> <AdStatusBadge statusId={car.statusId || 4} />
</div> </div>
{car.isFeatured && ( {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 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"> <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> <span className="text-xl font-bold">
{formatCurrency(car.price, car.currency)}
</span>
</div> </div>
</div> </div>
<div className="p-6"> <div className="p-6">
@@ -197,8 +220,12 @@ export default function HomePage() {
</h3> </h3>
</div> </div>
<div className="flex gap-4 text-[10px] text-gray-400 font-black tracking-widest uppercase"> <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">
<span className="bg-gray-800/80 px-3 py-1.5 rounded-lg border border-white/5">{car.km.toLocaleString()} KM</span> {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>
</div> </div>
</Link> </Link>

View File

@@ -1,25 +1,31 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from "react";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import { AdsV2Service, type AdListingDto } from '../services/ads.v2.service'; import { AdsV2Service, type AdListingDto } from "../services/ads.v2.service";
import { useAuth } from '../context/AuthContext'; import { useAuth } from "../context/AuthContext";
import { ChatService, type ChatMessage } from '../services/chat.service'; import { ChatService, type ChatMessage } from "../services/chat.service";
import ChatModal from '../components/ChatModal'; import ChatModal from "../components/ChatModal";
import { getImageUrl, parseUTCDate } from '../utils/app.utils'; import LoginModal from "../components/LoginModal";
import { AD_STATUSES, STATUS_CONFIG } from '../constants/adStatuses'; import { formatCurrency, getImageUrl, parseUTCDate } from "../utils/app.utils";
import ConfirmationModal from '../components/ConfirmationModal'; import { AD_STATUSES, STATUS_CONFIG } from "../constants/adStatuses";
import ConfirmationModal from "../components/ConfirmationModal";
type TabType = 'avisos' | 'favoritos' | 'mensajes'; type TabType = "avisos" | "favoritos" | "mensajes";
export default function MisAvisosPage() { export default function MisAvisosPage() {
const [activeTab, setActiveTab] = useState<TabType>('avisos'); const [activeTab, setActiveTab] = useState<TabType>("avisos");
const [avisos, setAvisos] = useState<AdListingDto[]>([]); const [avisos, setAvisos] = useState<AdListingDto[]>([]);
const [favoritos, setFavoritos] = useState<AdListingDto[]>([]); const [favoritos, setFavoritos] = useState<AdListingDto[]>([]);
const [mensajes, setMensajes] = useState<ChatMessage[]>([]); const [mensajes, setMensajes] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { user, fetchUnreadCount } = useAuth(); const { user, fetchUnreadCount, login } = useAuth();
const [showLoginModal, setShowLoginModal] = useState(false);
const [selectedChat, setSelectedChat] = useState<{ adId: number, name: string, otherUserId: number } | null>(null); const [selectedChat, setSelectedChat] = useState<{
adId: number;
name: string;
otherUserId: number;
} | null>(null);
const [modalConfig, setModalConfig] = useState<{ const [modalConfig, setModalConfig] = useState<{
isOpen: boolean; isOpen: boolean;
@@ -30,21 +36,21 @@ export default function MisAvisosPage() {
isDanger: boolean; isDanger: boolean;
}>({ }>({
isOpen: false, isOpen: false,
title: '', title: "",
message: '', message: "",
adId: null, adId: null,
newStatus: null, newStatus: null,
isDanger: false isDanger: false,
}); });
// Función para forzar chequeo manual desde Gestión // Función para forzar chequeo manual desde Gestión
const handleVerifyPayment = async (adId: number) => { const handleVerifyPayment = async (adId: number) => {
try { try {
const res = await AdsV2Service.checkPaymentStatus(adId); const res = await AdsV2Service.checkPaymentStatus(adId);
if (res.status === 'approved') { if (res.status === "approved") {
alert("¡Pago confirmado! El aviso pasará a moderación."); alert("¡Pago confirmado! El aviso pasará a moderación.");
cargarAvisos(user!.id); cargarAvisos(user!.id);
} else if (res.status === 'rejected') { } else if (res.status === "rejected") {
alert("El pago fue rechazado. Puedes intentar pagar nuevamente."); alert("El pago fue rechazado. Puedes intentar pagar nuevamente.");
cargarAvisos(user!.id); // Debería volver a estado Draft/1 cargarAvisos(user!.id); // Debería volver a estado Draft/1
} else { } else {
@@ -58,42 +64,47 @@ export default function MisAvisosPage() {
useEffect(() => { useEffect(() => {
if (user) { if (user) {
cargarMensajes(user.id); cargarMensajes(user.id);
if (activeTab === 'avisos') cargarAvisos(user.id); if (activeTab === "avisos") cargarAvisos(user.id);
else if (activeTab === 'favoritos') cargarFavoritos(user.id); else if (activeTab === "favoritos") cargarFavoritos(user.id);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, activeTab]); }, [user?.id, activeTab]);
const initiateStatusChange = (adId: number, newStatus: number) => { const initiateStatusChange = (adId: number, newStatus: number) => {
let title = 'Cambiar Estado'; let title = "Cambiar Estado";
let message = '¿Estás seguro de realizar esta acción?'; let message = "¿Estás seguro de realizar esta acción?";
let isDanger = false; let isDanger = false;
// 1. ELIMINAR // 1. ELIMINAR
if (newStatus === AD_STATUSES.DELETED) { if (newStatus === AD_STATUSES.DELETED) {
title = '¿Eliminar Aviso?'; title = "¿Eliminar Aviso?";
message = 'Esta acción eliminará el aviso permanentemente. No se puede deshacer.\n\n¿Estás seguro de continuar?'; message =
"Esta acción eliminará el aviso permanentemente. No se puede deshacer.\n\n¿Estás seguro de continuar?";
isDanger = true; isDanger = true;
} }
// 2. PAUSAR // 2. PAUSAR
else if (newStatus === AD_STATUSES.PAUSED) { else if (newStatus === AD_STATUSES.PAUSED) {
title = 'Pausar Publicación'; 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.'; 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 // 3. VENDIDO
else if (newStatus === AD_STATUSES.SOLD) { else if (newStatus === AD_STATUSES.SOLD) {
title = '¡Felicitaciones!'; 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?'; 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 // 4. RESERVADO
else if (newStatus === AD_STATUSES.RESERVED) { else if (newStatus === AD_STATUSES.RESERVED) {
title = 'Reservar Vehículo'; 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?'; 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) // 5. ACTIVAR (Desde Pausado/Reservado)
else if (newStatus === AD_STATUSES.ACTIVE) { else if (newStatus === AD_STATUSES.ACTIVE) {
title = 'Reactivar Aviso'; title = "Reactivar Aviso";
message = 'El aviso volverá a estar visible para todos y recibirás consultas nuevamente.'; message =
"El aviso volverá a estar visible para todos y recibirás consultas nuevamente.";
} }
setModalConfig({ setModalConfig({
@@ -102,7 +113,7 @@ export default function MisAvisosPage() {
message, message,
adId, adId,
newStatus, newStatus,
isDanger isDanger,
}); });
}; };
@@ -116,7 +127,7 @@ export default function MisAvisosPage() {
await AdsV2Service.changeStatus(adId, newStatus); await AdsV2Service.changeStatus(adId, newStatus);
if (user) cargarAvisos(user.id); if (user) cargarAvisos(user.id);
} catch (error) { } catch (error) {
alert('Error al actualizar estado'); alert("Error al actualizar estado");
} }
}; };
@@ -156,24 +167,35 @@ export default function MisAvisosPage() {
// Marcar como leídos en DB // Marcar como leídos en DB
const openChatForAd = async (adId: number, adTitle: string) => { const openChatForAd = async (adId: number, adTitle: string) => {
if (!user) return; if (!user) return;
const relatedMsg = mensajes.find(m => m.adID === adId); const relatedMsg = mensajes.find((m) => m.adID === adId);
if (relatedMsg) { if (relatedMsg) {
const otherId = relatedMsg.senderID === user.id ? relatedMsg.receiverID : relatedMsg.senderID; const otherId =
relatedMsg.senderID === user.id
? relatedMsg.receiverID
: relatedMsg.senderID;
// Identificar mensajes no leídos para este chat // Identificar mensajes no leídos para este chat
const unreadMessages = mensajes.filter(m => m.adID === adId && !m.isRead && m.receiverID === user.id); const unreadMessages = mensajes.filter(
(m) => m.adID === adId && !m.isRead && m.receiverID === user.id,
);
if (unreadMessages.length > 0) { if (unreadMessages.length > 0) {
// Optimización visual: actualiza la UI localmente de inmediato // Optimización visual: actualiza la UI localmente de inmediato
setMensajes(prev => prev.map(m => setMensajes((prev) =>
unreadMessages.some(um => um.messageID === m.messageID) ? { ...m, isRead: true } : m prev.map((m) =>
)); unreadMessages.some((um) => um.messageID === m.messageID)
? { ...m, isRead: true }
: m,
),
);
try { try {
// Crea un array de promesas para todas las llamadas a la API // Crea un array de promesas para todas las llamadas a la API
const markAsReadPromises = unreadMessages.map(m => const markAsReadPromises = unreadMessages.map((m) =>
m.messageID ? ChatService.markAsRead(m.messageID) : Promise.resolve() m.messageID
? ChatService.markAsRead(m.messageID)
: Promise.resolve(),
); );
// Espera a que TODAS las llamadas al backend terminen // Espera a que TODAS las llamadas al backend terminen
@@ -181,7 +203,6 @@ export default function MisAvisosPage() {
// SOLO DESPUÉS de que el backend confirme, actualizamos el contador global // SOLO DESPUÉS de que el backend confirme, actualizamos el contador global
await fetchUnreadCount(); await fetchUnreadCount();
} catch (error) { } catch (error) {
console.error("Error al marcar mensajes como leídos:", error); console.error("Error al marcar mensajes como leídos:", error);
// Opcional: podrías revertir el estado local si la API falla // Opcional: podrías revertir el estado local si la API falla
@@ -215,82 +236,134 @@ export default function MisAvisosPage() {
if (!user) { if (!user) {
return ( return (
<div className="container mx-auto px-6 py-24 text-center animate-fade-in-up"> <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"> <div className="bg-[#111318]/90 backdrop-blur-3xl p-12 rounded-[3rem] max-w-2xl mx-auto border border-white/5 shadow-2xl">
<span className="text-7xl mb-8 block">🔒</span> <span className="text-7xl mb-8 block">🔒</span>
<h2 className="text-5xl font-black mb-4 uppercase tracking-tighter">Área Privada</h2> <h2 className="text-5xl font-black mb-4 uppercase tracking-tighter">
Registrate o Inicia Sesión
</h2>
<p className="text-gray-400 mb-10 text-lg italic"> <p className="text-gray-400 mb-10 text-lg italic">
Para gestionar tus publicaciones, primero debes iniciar sesión. Para ver y gestionar tus avisos, primero debes registrarte o iniciar sesión.
</p> </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"> <div className="flex flex-col md:flex-row gap-4 justify-center">
Identificarse <button
</Link> onClick={() => setShowLoginModal(true)}
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"
>
Registrarse
</button>
</div> </div>
</div> </div>
{showLoginModal && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-xl animate-fade-in p-4">
<div className="relative w-full max-w-md text-left">
<LoginModal
initialMode="REGISTER"
onSuccess={(u) => {
login(u);
setShowLoginModal(false);
}}
onClose={() => setShowLoginModal(false)}
/>
</div>
</div>
)}
</div>
); );
} }
const totalVisitas = avisos.reduce((acc, curr) => acc + (curr.viewsCounter || 0), 0); const totalVisitas = avisos.reduce(
const avisosActivos = avisos.filter(a => a.statusId === 4).length; (acc, curr) => acc + (curr.viewsCounter || 0),
0,
);
const avisosActivos = avisos.filter((a) => a.statusId === 4).length;
return ( return (
<div className="container mx-auto px-6 py-12 animate-fade-in-up min-h-screen"> <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"> <header className="flex flex-col md:flex-row justify-between items-start md:items-end mb-16 gap-8">
<div> <div>
<h2 className="text-5xl font-black tracking-tighter uppercase mb-4">Mis <span className="text-blue-500">Avisos</span></h2> <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="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"> <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()} {user.username.charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<span className="text-white text-xl font-black block leading-none">{user.firstName} {user.lastName}</span> <span className="text-white text-xl font-black block leading-none">
<span className="text-gray-500 text-[10px] uppercase font-black tracking-[0.3em]">{user.email}</span> {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>
</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"> <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 => ( {(["avisos", "favoritos", "mensajes"] as TabType[]).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setActiveTab(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'}`} 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'} {tab === "avisos"
? "📦 Mis Avisos"
: tab === "favoritos"
? "⭐ Favoritos"
: "💬 Mensajes"}
</button> </button>
))} ))}
</div> </div>
</header> </header>
<div className="animate-fade-in space-y-8 md:space-y-12"> <div className="animate-fade-in space-y-8 md:space-y-12">
{activeTab === "avisos" && (
{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"> <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="Visualizaciones"
value={totalVisitas}
icon="👁️"
/>
<MetricCard label="Activos" value={avisosActivos} icon="✅" /> <MetricCard label="Activos" value={avisosActivos} icon="✅" />
<MetricCard label="Favoritos" value={favoritos.length} icon="⭐" /> <MetricCard label="Favoritos" value={favoritos.length} icon="⭐" />
</div> </div>
)} )}
{loading ? ( {loading ? (
<div className="flex justify-center p-24"> <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 className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
</div> </div>
) : ( ) : (
<> <>
{activeTab === 'avisos' && ( {activeTab === "avisos" && (
<div className="space-y-6"> <div className="space-y-6">
{avisos.filter(a => a.statusId !== 9).length === 0 ? ( {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"> <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> <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> <h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">
<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> 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> </div>
) : ( ) : (
avisos.filter(a => a.statusId !== 9).map((av, index) => { avisos
const hasMessages = mensajes.some(m => m.adID === av.id); .filter((a) => a.statusId !== 9)
const hasUnread = mensajes.some(m => m.adID === av.id && !m.isRead && m.receiverID === user.id); .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 ( return (
// 'relative z-index' dinámico // 'relative z-index' dinámico
@@ -301,11 +374,16 @@ export default function MisAvisosPage() {
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" 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 }} 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"> <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}`} /> <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"> <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> <span className="text-[9px] font-bold text-white">
#{av.id}
</span>
</div> </div>
</div> </div>
@@ -314,27 +392,38 @@ export default function MisAvisosPage() {
<h3 className="text-2xl font-black text-white uppercase tracking-tighter truncate max-w-md"> <h3 className="text-2xl font-black text-white uppercase tracking-tighter truncate max-w-md">
{av.brandName} {av.versionName} {av.brandName} {av.versionName}
</h3> </h3>
<span className="text-blue-400 font-bold text-lg">{av.currency} {av.price.toLocaleString()}</span> <span className="text-blue-400 font-bold text-lg">
{formatCurrency(av.price, av.currency)}
</span>
</div> </div>
<div className="flex flex-wrap gap-3 justify-center md:justify-start"> <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"> <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-[10px] text-gray-500 font-bold uppercase">
<span className="text-xs text-white font-bold">{av.year}</span> Año
</span>
<span className="text-xs text-white font-bold">
{av.year}
</span>
</div> </div>
<div className="bg-white/5 border border-white/5 px-3 py-1.5 rounded-lg flex items-center gap-2"> <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-[10px] text-gray-500 font-bold uppercase">
<span className="text-xs text-white font-bold">{av.viewsCounter || 0}</span> Visitas
</span>
<span className="text-xs text-white font-bold">
{av.viewsCounter || 0}
</span>
</div> </div>
{av.isFeatured && ( {av.isFeatured && (
<div className="bg-blue-600/20 border border-blue-500/30 px-3 py-1.5 rounded-lg"> <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> <span className="text-[9px] text-blue-300 font-black uppercase tracking-widest">
Destacado
</span>
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="w-full md:w-auto flex flex-col gap-3 min-w-[180px]"> <div className="w-full md:w-auto flex flex-col gap-3 min-w-[180px]">
{/* CASO 1: BORRADOR (1) -> Botón de Pagar */} {/* CASO 1: BORRADOR (1) -> Botón de Pagar */}
{av.statusId === AD_STATUSES.DRAFT && ( {av.statusId === AD_STATUSES.DRAFT && (
<Link <Link
@@ -349,7 +438,9 @@ export default function MisAvisosPage() {
{av.statusId === AD_STATUSES.PAYMENT_PENDING && ( {av.statusId === AD_STATUSES.PAYMENT_PENDING && (
<div className="flex flex-col gap-2"> <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"> <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> <span className="block text-[10px] font-black uppercase tracking-widest">
Pago Pendiente
</span>
</div> </div>
<button <button
onClick={() => handleVerifyPayment(av.id)} onClick={() => handleVerifyPayment(av.id)}
@@ -363,8 +454,12 @@ export default function MisAvisosPage() {
{/* CASO 3: EN REVISIÓN (3) -> Cartel informativo */} {/* CASO 3: EN REVISIÓN (3) -> Cartel informativo */}
{av.statusId === AD_STATUSES.MODERATION_PENDING && ( {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"> <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="block text-[10px] font-black uppercase tracking-widest">
<span className="text-[8px] opacity-70">No editable</span> En Revisión
</span>
<span className="text-[8px] opacity-70">
No editable
</span>
</div> </div>
)} )}
@@ -372,7 +467,9 @@ export default function MisAvisosPage() {
{av.statusId === AD_STATUSES.EXPIRED && ( {av.statusId === AD_STATUSES.EXPIRED && (
<div className="flex flex-col gap-2"> <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"> <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> <span className="block text-[10px] font-black uppercase tracking-widest">
Finalizado
</span>
</div> </div>
<Link <Link
to={`/publicar?edit=${av.id}`} to={`/publicar?edit=${av.id}`}
@@ -387,13 +484,39 @@ export default function MisAvisosPage() {
{av.statusId !== AD_STATUSES.DRAFT && {av.statusId !== AD_STATUSES.DRAFT &&
av.statusId !== AD_STATUSES.PAYMENT_PENDING && av.statusId !== AD_STATUSES.PAYMENT_PENDING &&
av.statusId !== AD_STATUSES.MODERATION_PENDING && av.statusId !== AD_STATUSES.MODERATION_PENDING &&
av.statusId !== AD_STATUSES.EXPIRED && ( av.statusId !== AD_STATUSES.EXPIRED &&
av.statusId !== AD_STATUSES.REJECTED && (
<StatusDropdown <StatusDropdown
currentStatus={av.statusId || AD_STATUSES.ACTIVE} currentStatus={
onChange={(newStatus) => initiateStatusChange(av.id, newStatus)} av.statusId || AD_STATUSES.ACTIVE
}
onChange={(newStatus) =>
initiateStatusChange(av.id, newStatus)
}
/> />
)} )}
{/* --- NUEVO: CASO 6: RECHAZADO --- */}
{av.statusId === AD_STATUSES.REJECTED && (
<div className="flex flex-col gap-2">
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-center">
<span className="block text-[10px] font-black uppercase tracking-widest">
Aviso Rechazado
</span>
<span className="text-[10px] opacity-70">
Revisa los motivos en tu email y edita para
corregir
</span>
</div>
<Link
to={`/publicar?edit=${av.id}`}
className="bg-white/10 hover:bg-white/20 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-2.5 rounded-xl border border-white/10 transition-all flex items-center justify-center gap-2"
>
Corregir Aviso
</Link>
</div>
)}
{/* BOTONES COMUNES (Siempre visibles) */} {/* BOTONES COMUNES (Siempre visibles) */}
<div className="grid grid-cols-1 gap-2 mt-1"> <div className="grid grid-cols-1 gap-2 mt-1">
<Link <Link
@@ -405,7 +528,12 @@ export default function MisAvisosPage() {
{hasMessages && ( {hasMessages && (
<button <button
onClick={() => openChatForAd(av.id, `${av.brandName} ${av.versionName}`)} 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" 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 💬 Mensajes
@@ -423,25 +551,53 @@ export default function MisAvisosPage() {
</div> </div>
)} )}
{activeTab === 'favoritos' && ( {activeTab === "favoritos" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{favoritos.length === 0 ? ( {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"> <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> <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> <h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">
<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> 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> </div>
) : ( ) : (
favoritos.map((fav) => ( 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
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"> <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}`} /> <img
<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> 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>
<div className="p-6"> <div className="p-6">
<h3 className="text-lg font-black text-white uppercase tracking-tighter mb-1 truncate">{fav.brandName} {fav.versionName}</h3> <h3 className="text-lg font-black text-white uppercase tracking-tighter mb-1 truncate">
<p className="text-blue-400 font-extrabold text-xl mb-4">{fav.currency} {fav.price.toLocaleString()}</p> {fav.brandName} {fav.versionName}
<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> </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> </div>
)) ))
@@ -449,49 +605,74 @@ export default function MisAvisosPage() {
</div> </div>
)} )}
{activeTab === 'mensajes' && ( {activeTab === "mensajes" && (
<div className="space-y-4"> <div className="space-y-4">
{mensajes.length === 0 ? ( {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"> <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> <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> <h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">
<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> 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> </div>
) : ( ) : (
Object.values(mensajes.reduce((acc: any, curr) => { Object.values(
mensajes.reduce((acc: any, curr) => {
const key = curr.adID; const key = curr.adID;
if (!acc[key]) acc[key] = { msg: curr, count: 0, unread: false }; if (!acc[key])
acc[key] = { msg: curr, count: 0, unread: false };
acc[key].count++; acc[key].count++;
if (!curr.isRead && curr.receiverID === user.id) acc[key].unread = true; if (!curr.isRead && curr.receiverID === user.id)
if (new Date(curr.sentAt!) > new Date(acc[key].msg.sentAt!)) acc[key].msg = curr; acc[key].unread = true;
if (
new Date(curr.sentAt!) > new Date(acc[key].msg.sentAt!)
)
acc[key].msg = curr;
return acc; return acc;
}, {})).map((item: any) => { }, {}),
const aviso = avisos.find(a => a.id === item.msg.adID); ).map((item: any) => {
const tituloAviso = aviso ? `${aviso.brandName} ${aviso.versionName}` : `Aviso #${item.msg.adID}`; const aviso = avisos.find((a) => a.id === item.msg.adID);
const tituloAviso = aviso
? `${aviso.brandName} ${aviso.versionName}`
: `Aviso #${item.msg.adID}`;
return ( return (
<div <div
key={item.msg.adID} key={item.msg.adID}
onClick={() => openChatForAd(item.msg.adID, tituloAviso)} 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" 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="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-1">
<div className="flex justify-between items-center mb-1"> <div className="flex justify-between items-center mb-1">
<h4 className="font-black uppercase tracking-tighter text-white"> <h4 className="font-black uppercase tracking-tighter text-white">
{tituloAviso} {tituloAviso}
</h4> </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> <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> </div>
<p className="text-sm text-gray-400 line-clamp-1"> <p className="text-sm text-gray-400 line-clamp-1">
{item.msg.senderID === user.id ? 'Tú: ' : ''}{item.msg.messageText} {item.msg.senderID === user.id ? "Tú: " : ""}
{item.msg.messageText}
</p> </p>
</div> </div>
{item.unread && ( {item.unread && (
<div className="w-3 h-3 bg-red-500 rounded-full shadow-lg shadow-red-500/50"></div> <div className="w-3 h-3 bg-red-500 rounded-full shadow-lg shadow-red-500/50"></div>
)} )}
</div> </div>
) );
}) })
)} )}
</div> </div>
@@ -518,24 +699,34 @@ export default function MisAvisosPage() {
onConfirm={confirmStatusChange} onConfirm={confirmStatusChange}
onCancel={() => setModalConfig({ ...modalConfig, isOpen: false })} onCancel={() => setModalConfig({ ...modalConfig, isOpen: false })}
isDanger={modalConfig.isDanger} isDanger={modalConfig.isDanger}
confirmText={modalConfig.newStatus === AD_STATUSES.SOLD ? "¡Sí, vendido!" : "Confirmar"} confirmText={
modalConfig.newStatus === AD_STATUSES.SOLD
? "¡Sí, vendido!"
: "Confirmar"
}
/> />
</div> </div>
); );
} }
// DROPDOWN DE ESTADO // DROPDOWN DE ESTADO
function StatusDropdown({ currentStatus, onChange }: { currentStatus: number, onChange: (val: number) => void }) { function StatusDropdown({
currentStatus,
onChange,
}: {
currentStatus: number;
onChange: (val: number) => void;
}) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
// Fallback seguro si currentStatus no tiene config // Fallback seguro si currentStatus no tiene config
const currentConfig = STATUS_CONFIG[currentStatus] || { const currentConfig = STATUS_CONFIG[currentStatus] || {
label: 'Desconocido', label: "Desconocido",
color: 'text-gray-400', color: "text-gray-400",
bg: 'bg-gray-500/10', bg: "bg-gray-500/10",
border: 'border-gray-500/20', border: "border-gray-500/20",
icon: '❓' icon: "❓",
}; };
const ALLOWED_STATUSES = [ const ALLOWED_STATUSES = [
@@ -543,12 +734,15 @@ function StatusDropdown({ currentStatus, onChange }: { currentStatus: number, on
AD_STATUSES.PAUSED, AD_STATUSES.PAUSED,
AD_STATUSES.RESERVED, AD_STATUSES.RESERVED,
AD_STATUSES.SOLD, AD_STATUSES.SOLD,
AD_STATUSES.DELETED AD_STATUSES.DELETED,
]; ];
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { if (
wrapperRef.current &&
!wrapperRef.current.contains(event.target as Node)
) {
setIsOpen(false); setIsOpen(false);
} }
} }
@@ -564,7 +758,9 @@ function StatusDropdown({ currentStatus, onChange }: { currentStatus: number, on
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{currentConfig.icon}</span> <span>{currentConfig.icon}</span>
<span className="text-[10px] font-black uppercase tracking-widest">{currentConfig.label}</span> <span className="text-[10px] font-black uppercase tracking-widest">
{currentConfig.label}
</span>
</div> </div>
<span className="text-xs"></span> <span className="text-xs"></span>
</button> </button>
@@ -580,8 +776,11 @@ function StatusDropdown({ currentStatus, onChange }: { currentStatus: number, on
return ( return (
<button <button
key={statusId} key={statusId}
onClick={() => { onChange(statusId); setIsOpen(false); }} onClick={() => {
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'}`} 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> <span className="text-sm">{config.icon}</span>
{config.label} {config.label}
@@ -594,13 +793,27 @@ function StatusDropdown({ currentStatus, onChange }: { currentStatus: number, on
); );
} }
function MetricCard({ label, value, icon }: { label: string, value: any, icon: string }) { function MetricCard({
label,
value,
icon,
}: {
label: string;
value: any;
icon: string;
}) {
return ( 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="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 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> <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-2xl md:text-3xl font-black text-white tracking-tighter block leading-none mb-1">
<span className="text-[9px] md:text-[10px] font-black uppercase tracking-widest text-gray-500 block leading-tight">{label}</span> {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>
</div> </div>
); );

View File

@@ -2,6 +2,10 @@ import { useState, useEffect } from "react";
import { ProfileService } from "../services/profile.service"; import { ProfileService } from "../services/profile.service";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { AuthService } from "../services/auth.service"; import { AuthService } from "../services/auth.service";
import {
NotificationPreferencesService,
type NotificationPreferences,
} from "../services/notification-preferences.service";
export default function PerfilPage() { export default function PerfilPage() {
const { user, refreshSession } = useAuth(); const { user, refreshSession } = useAuth();
@@ -19,10 +23,46 @@ export default function PerfilPage() {
phoneNumber: "", phoneNumber: "",
}); });
// Estado de preferencias de notificación
const [notifPrefs, setNotifPrefs] = useState<NotificationPreferences>({
sistema: true,
marketing: true,
rendimiento: true,
mensajes: true,
});
const [savingPrefs, setSavingPrefs] = useState(false);
const [prefsSaved, setPrefsSaved] = useState(false);
const [loadingPrefs, setLoadingPrefs] = useState(true);
useEffect(() => { useEffect(() => {
loadProfile(); loadProfile();
loadNotifPrefs();
}, []); }, []);
const loadNotifPrefs = async () => {
try {
const data = await NotificationPreferencesService.getPreferences();
setNotifPrefs(data);
} catch (err) {
console.error("Error cargando preferencias de notificación", err);
} finally {
setLoadingPrefs(false);
}
};
const handleSaveNotifPrefs = async () => {
setSavingPrefs(true);
try {
await NotificationPreferencesService.updatePreferences(notifPrefs);
setPrefsSaved(true);
setTimeout(() => setPrefsSaved(false), 3000);
} catch (err) {
alert("Error al guardar las preferencias de notificación.");
} finally {
setSavingPrefs(false);
}
};
const loadProfile = async () => { const loadProfile = async () => {
try { try {
const data = await ProfileService.getProfile(); const data = await ProfileService.getProfile();
@@ -97,7 +137,7 @@ export default function PerfilPage() {
</div> </div>
</div> </div>
{/* Edit Form */} {/* Formulario de datos personales */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -189,6 +229,118 @@ export default function PerfilPage() {
</button> </button>
</div> </div>
</form> </form>
{/* ─── Preferencias de Notificación ─── */}
<div className="mt-8 glass p-8 rounded-[2.5rem] border border-white/5">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-blue-600/15 rounded-2xl flex items-center justify-center text-xl border border-blue-500/20">
🔔
</div>
<div>
<h3 className="text-sm font-black uppercase tracking-widest text-white">
Preferencias de Notificación
</h3>
<p className="text-[10px] text-gray-500 font-medium mt-0.5">
Elegir qué correos querés recibir
</p>
</div>
</div>
{loadingPrefs ? (
<div className="flex items-center gap-3 py-4 text-gray-500 text-xs">
<div className="w-5 h-5 border-2 border-blue-500/40 border-t-blue-500 rounded-full animate-spin" />
Cargando preferencias...
</div>
) : (
<div className="space-y-4">
{([
{
key: "sistema" as const,
label: "Avisos del Sistema",
desc: "Vencimiento de avisos, renovaciones y alertas importantes.",
icon: "⚙️",
},
{
key: "marketing" as const,
label: "Promociones y Marketing",
desc: "Ofertas especiales, recordatorio de carrito abandonado.",
icon: "🎁",
},
{
key: "rendimiento" as const,
label: "Resumen Semanal",
desc: "Visitas y favoritos de tus avisos publicados.",
icon: "📊",
},
{
key: "mensajes" as const,
label: "Recordatorio de Mensajes",
desc: "Aviso cuando tenés mensajes sin leer por más de 4 horas.",
icon: "💬",
},
] as const).map(({ key, label, desc, icon }) => (
<label
key={key}
className="flex items-center justify-between gap-4 p-4 rounded-2xl bg-white/3
border border-white/5 hover:bg-white/5 transition-all cursor-pointer group"
>
<div className="flex items-center gap-3">
<span className="text-xl">{icon}</span>
<div>
<p className="text-xs font-black text-white uppercase tracking-wider">
{label}
</p>
<p className="text-[10px] text-gray-500 font-medium mt-0.5">{desc}</p>
</div>
</div>
{/* Toggle switch */}
<div className="relative flex-shrink-0">
<input
type="checkbox"
className="sr-only"
checked={notifPrefs[key]}
onChange={(e) =>
setNotifPrefs((prev) => ({ ...prev, [key]: e.target.checked }))
}
/>
<div
className={`w-12 h-6 rounded-full transition-all duration-300 cursor-pointer
${notifPrefs[key]
? 'bg-blue-600 shadow-lg shadow-blue-600/30'
: 'bg-white/10'}`}
>
<div
className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow transition-all duration-300
${notifPrefs[key] ? 'left-7' : 'left-1'}`}
/>
</div>
</div>
</label>
))}
<div className="pt-4 border-t border-white/5 flex items-center gap-4">
<button
type="button"
onClick={handleSaveNotifPrefs}
disabled={savingPrefs}
className="bg-blue-600 hover:bg-blue-500 text-white py-3.5 px-10 rounded-2xl
text-[10px] font-black uppercase tracking-widest transition-all
shadow-lg shadow-blue-600/20 active:scale-95 disabled:opacity-50"
>
{savingPrefs ? "Guardando..." : "Guardar Preferencias"}
</button>
{prefsSaved && (
<span className="text-green-400 text-[10px] font-black uppercase tracking-widest
animate-pulse">
Preferencias guardadas
</span>
)}
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
{showEmailModal && ( {showEmailModal && (

View File

@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { NotificationPreferencesService } from '../services/notification-preferences.service';
/**
* Página intermedia que toma el ?token=xxx de la URL (cuando el usuario hace clic en el mail)
* y llama silenciosamente a la API pública. Dependiendo del resultado, redirige
* a /baja-exitosa o a /baja-error.
*/
export default function ProcessUnsubscribePage() {
const [params] = useSearchParams();
const token = params.get('token');
const navigate = useNavigate();
useEffect(() => {
// Si entró sin token, mandarlo al error directamente
if (!token) {
navigate('/baja-error', { replace: true });
return;
}
// Procesa el token con el backend
NotificationPreferencesService.unsubscribeUsingToken(token)
.then((data) => {
if (data.success) {
// Todo bien -> Éxito
navigate(`/baja-exitosa?categoria=${encodeURIComponent(data.category)}`, { replace: true });
} else {
// El token no sirvió pero la API respondió
navigate('/baja-error', { replace: true });
}
})
.catch((_) => {
// Falló la red o dio 400/500
navigate('/baja-error', { replace: true });
});
}, [token, navigate]);
return (
<div className="min-h-screen bg-[#0a0c10] flex flex-col items-center justify-center p-6 text-center">
{/* Spinner llamativo para mostrar que algo está cargando */}
<div className="relative mb-6">
<div className="absolute inset-0 bg-blue-500/20 blur-xl rounded-full animate-pulse"></div>
<div className="relative animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500"></div>
</div>
<h1 className="text-2xl font-black uppercase tracking-widest text-white mb-2">
<span className="text-blue-400">Procesando</span> Baja
</h1>
<p className="text-gray-400 font-medium">
Estamos verificando tu solicitud, un momento por favor...
</p>
</div>
);
}

View File

@@ -2,9 +2,10 @@ import { useState, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom"; import { useSearchParams, useNavigate } from "react-router-dom";
import { AvisosService } from "../services/avisos.service"; import { AvisosService } from "../services/avisos.service";
import { AdsV2Service } from "../services/ads.v2.service"; import { AdsV2Service } from "../services/ads.v2.service";
import { AuthService, type UserSession } from "../services/auth.service";
import type { DatosAvisoDto } from "../types/aviso.types"; import type { DatosAvisoDto } from "../types/aviso.types";
import FormularioAviso from "../components/FormularioAviso"; import FormularioAviso from "../components/FormularioAviso";
import { useAuth } from "../context/AuthContext";
import LoginModal from "../components/LoginModal"; import LoginModal from "../components/LoginModal";
const TAREAS_DISPONIBLES = [ const TAREAS_DISPONIBLES = [
@@ -34,9 +35,8 @@ export default function PublicarAvisoPage() {
const [planSeleccionado, setPlanSeleccionado] = const [planSeleccionado, setPlanSeleccionado] =
useState<DatosAvisoDto | null>(null); useState<DatosAvisoDto | null>(null);
const [fixedCategory, setFixedCategory] = useState<string | null>(null); const [fixedCategory, setFixedCategory] = useState<string | null>(null);
const [user, setUser] = useState<UserSession | null>( const [showLoginModal, setShowLoginModal] = useState(false);
AuthService.getCurrentUser(), const { user, login } = useAuth();
);
useEffect(() => { useEffect(() => {
if (editId) { if (editId) {
@@ -57,13 +57,44 @@ export default function PublicarAvisoPage() {
// Determinamos la categoría para cargar las tarifas correspondientes // Determinamos la categoría para cargar las tarifas correspondientes
const categoryCode = ad.vehicleTypeID === 1 ? "EAUTOS" : "EMOTOS"; const categoryCode = ad.vehicleTypeID === 1 ? "EAUTOS" : "EMOTOS";
setCategorySelection(categoryCode); setCategorySelection(categoryCode);
// Bloquear el cambio de categoría // Bloquear el cambio de categoría
setFixedCategory(categoryCode); setFixedCategory(categoryCode);
/**
* LÓGICA DE EDICIÓN DIRECTA:
* Si el aviso está en un estado que implica que ya fue procesado o rechazado,
* saltamos la selección de planes y cargamos el plan correspondiente.
* Status: 4 (Activo), 5 (Rechazado), 6 (Pausado), 7 (Vendido), 10 (Reservado)
* El estado 1 (Borrador) y 8 (Vencido) NO entran aquí para permitir re-selección/pago.
*/
const statusesEdicionDirecta = [4, 5, 6, 7, 10];
if (statusesEdicionDirecta.includes(ad.statusID)) {
// Obtenemos la configuración de tarifa que coincide con el estado del aviso (Destacado o no)
const paqueteId = ad.isFeatured ? 1 : 0;
const tarifasData = await AvisosService.obtenerConfiguracion(
"EMOTORES",
paqueteId,
);
const tarifaReal = tarifasData[0];
if (tarifaReal) {
const vehicleTypeId = categoryCode === "EAUTOS" ? 1 : 2;
const nombrePlanAmigable = ad.isFeatured
? "PLAN DESTACADO"
: "PLAN ESTÁNDAR";
setPlanSeleccionado({
...tarifaReal,
idRubro: vehicleTypeId,
nomavi: nombrePlanAmigable,
});
}
}
} catch (err) { } catch (err) {
console.error(err); console.error("Error cargando aviso para edición:", err);
setError("Error al cargar el aviso."); setError("Error al cargar el aviso.");
} finally { } finally {
setLoading(false); setLoading(false);
@@ -116,17 +147,43 @@ export default function PublicarAvisoPage() {
if (!user) { if (!user) {
return ( return (
<div className="flex justify-center items-center py-20 min-h-[60vh]"> <div className="container mx-auto px-6 py-24 text-center animate-fade-in-up">
<div className="bg-[#111318]/90 backdrop-blur-3xl 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">
Registrate o Inicia Sesión
</h2>
<p className="text-gray-400 mb-10 text-lg italic">
Para publicar tus avisos, primero debes registrarte o iniciar sesión.
</p>
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button
onClick={() => setShowLoginModal(true)}
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"
>
Registrarse
</button>
</div>
</div>
{showLoginModal && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-xl animate-fade-in p-4">
<div className="relative w-full max-w-md">
<LoginModal <LoginModal
onSuccess={(u) => setUser(u)} initialMode="REGISTER"
onClose={() => navigate("/")} onSuccess={(u) => {
login(u);
setShowLoginModal(false);
}}
onClose={() => setShowLoginModal(false)}
/> />
</div> </div>
</div>
)}
</div>
); );
} }
// ELIMINADO: Bloque if (publicacionExitosa) { return ... }
if (planSeleccionado) { if (planSeleccionado) {
return ( return (
<div className="max-w-6xl mx-auto py-8 px-6"> <div className="max-w-6xl mx-auto py-8 px-6">

View File

@@ -1,12 +1,17 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from "react";
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from "react-router-dom";
import { AdsV2Service } from '../services/ads.v2.service'; import { AdsV2Service } from "../services/ads.v2.service";
import { AuthService } from '../services/auth.service'; import { AuthService } from "../services/auth.service";
import ChatModal from '../components/ChatModal'; import ChatModal from "../components/ChatModal";
import { FaWhatsapp, FaMapMarkerAlt, FaInfoCircle, FaShareAlt } from 'react-icons/fa'; import {
import { AD_STATUSES } from '../constants/adStatuses'; FaWhatsapp,
import AdStatusBadge from '../components/AdStatusBadge'; FaMapMarkerAlt,
import PremiumGallery from '../components/PremiumGallery'; 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() { export default function VehiculoDetailPage() {
const { id } = useParams(); const { id } = useParams();
@@ -43,7 +48,9 @@ export default function VehiculoDetailPage() {
}, [id, user?.id]); }, [id, user?.id]);
useEffect(() => { useEffect(() => {
return () => { viewRegistered.current = false; }; return () => {
viewRegistered.current = false;
};
}, [id]); }, [id]);
const handleFavoriteToggle = async () => { const handleFavoriteToggle = async () => {
@@ -52,52 +59,102 @@ export default function VehiculoDetailPage() {
if (isFavorite) await AdsV2Service.removeFavorite(user.id, Number(id)); if (isFavorite) await AdsV2Service.removeFavorite(user.id, Number(id));
else await AdsV2Service.addFavorite(user.id, Number(id)!); else await AdsV2Service.addFavorite(user.id, Number(id)!);
setIsFavorite(!isFavorite); setIsFavorite(!isFavorite);
} catch (err) { console.error(err); } } catch (err) {
console.error(err);
}
}; };
const getWhatsAppLink = (phone: string, title: string) => { const getWhatsAppLink = (phone: string, title: string) => {
if (!phone) return '#'; if (!phone) return "#";
let number = phone.replace(/[^\d]/g, ''); let number = phone.replace(/[^\d]/g, "");
if (number.startsWith('0')) number = number.substring(1); if (number.startsWith("0")) number = number.substring(1);
if (!number.startsWith('54')) number = `549${number}`; if (!number.startsWith("54")) number = `549${number}`;
const message = `Hola, vi tu aviso "${title}" en Motores Argentinos y me interesa.`; const message = `Hola, vi tu aviso "${title}" en Motores Argentinos y me interesa.`;
return `https://wa.me/${number}?text=${encodeURIComponent(message)}`; return `https://wa.me/${number}?text=${encodeURIComponent(message)}`;
}; };
const handleShare = (platform: 'wa' | 'fb' | 'copy') => { const handleShare = (platform: "wa" | "fb" | "copy") => {
const url = window.location.href; const url = window.location.href;
const vehicleTitle = `${vehicle.brand?.name || ''} ${vehicle.versionName}`.trim(); const vehicleTitle =
`${vehicle.brand?.name || ""} ${vehicle.versionName}`.trim();
const text = `Mira este ${vehicleTitle} en Motores Argentinos!`; const text = `Mira este ${vehicleTitle} en Motores Argentinos!`;
switch (platform) { switch (platform) {
case 'wa': window.open(`https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`, '_blank'); break; case "wa":
case 'fb': window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, '_blank'); break; window.open(
case 'copy': navigator.clipboard.writeText(url); alert('Enlace copiado al portapapeles'); break; `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 ( if (loading)
return (
<div className="flex flex-col items-center justify-center p-40 gap-6"> <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> <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> <span className="text-gray-500 font-black uppercase tracking-widest text-xs animate-pulse">
Cargando...
</span>
</div> </div>
); );
if (error || !vehicle) return <div className="text-white p-20 text-center">{error || "Vehículo no encontrado"}</div>; if (error || !vehicle)
return (
<div className="text-white p-20 text-center">
{error || "Vehículo no encontrado"}
</div>
);
// HELPER: Valida que el dato exista y no sea basura ("0", vacío, etc)
const hasData = (val: string | null | undefined) => {
return val && val.trim().length > 0 && val !== "0";
};
const isOwnerAdmin = vehicle.ownerUserType === 3; const isOwnerAdmin = vehicle.ownerUserType === 3;
const isAdActive = vehicle.statusID === AD_STATUSES.ACTIVE; const isAdActive = vehicle.statusID === AD_STATUSES.ACTIVE;
const isContactable = isAdActive && !isOwnerAdmin;
// CALCULAMOS LA DISPONIBILIDAD REAL
// 1. WhatsApp: Debe estar habilitado Y tener un teléfono válido
const canShowWhatsApp =
vehicle.allowWhatsApp && hasData(vehicle.contactPhone);
// 2. Teléfono: Debe estar habilitado ("Mostrar Número") Y tener un teléfono válido
const canShowPhone = vehicle.showPhone && hasData(vehicle.contactPhone);
// 3. Email: Debe estar habilitado Y tener un email válido
const canShowEmail = vehicle.showEmail && hasData(vehicle.contactEmail);
// Es contactable si está Activo Y (Tiene WhatsApp O Tiene Teléfono O Tiene Email)
const isContactable =
isAdActive && (canShowWhatsApp || canShowPhone || canShowEmail);
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">
<Link to="/" className="hover:text-white transition-colors">Inicio</Link> / <Link to="/" className="hover:text-white transition-colors">
<Link to="/explorar" className="hover:text-white transition-colors">Explorar</Link> / Inicio
<span className="text-blue-400 truncate">{vehicle.brand?.name} {vehicle.versionName || 'Detalle'}</span> </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> </nav>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-12 relative items-start"> <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) */} {/* 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">
<PremiumGallery <PremiumGallery
@@ -106,8 +163,21 @@ export default function VehiculoDetailPage() {
isFavorite={isFavorite} isFavorite={isFavorite}
onFavoriteToggle={handleFavoriteToggle} onFavoriteToggle={handleFavoriteToggle}
statusBadge={<AdStatusBadge statusId={vehicle.statusID} />} 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>} featuredBadge={
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>} 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) */} {/* BLOQUE 3: Información General y Técnica (Acomodado debajo de la galería) */}
@@ -118,7 +188,10 @@ export default function VehiculoDetailPage() {
<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"> <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> <span className="text-2xl">📝</span>
</div> </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> <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> </div>
<p className="text-gray-300 leading-relaxed font-light whitespace-pre-wrap text-base md:text-lg"> <p className="text-gray-300 leading-relaxed font-light whitespace-pre-wrap text-base md:text-lg">
{vehicle.description} {vehicle.description}
@@ -130,18 +203,54 @@ export default function VehiculoDetailPage() {
<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"> <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 /> <FaInfoCircle />
</div> </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> <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>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 md:gap-8"> <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
<TechnicalItem label="Combustible" value={vehicle.fuelType} icon="⛽" /> label="Kilómetros"
<TechnicalItem label="Transmisión" value={vehicle.transmission} icon="⚙️" /> 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="Color" value={vehicle.color} icon="🎨" />
<TechnicalItem label="Segmento" value={vehicle.segment} icon="🚗" /> <TechnicalItem
{vehicle.condition && <TechnicalItem label="Estado" value={vehicle.condition} icon="✨" />} label="Segmento"
{vehicle.doorCount && <TechnicalItem label="Puertas" value={vehicle.doorCount} icon="🚪" />} value={vehicle.segment}
{vehicle.engineSize && <TechnicalItem label="Motor" value={vehicle.engineSize} icon="⚡" />} 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> </div>
</div> </div>
@@ -158,7 +267,9 @@ export default function VehiculoDetailPage() {
</div> </div>
<h1 className="text-3xl md:text-4xl font-black tracking-tighter uppercase leading-tight mb-4 text-white"> <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> <span className="text-blue-500 mr-2">
{vehicle.brand?.name}
</span>
{vehicle.versionName} {vehicle.versionName}
</h1> </h1>
@@ -169,57 +280,99 @@ export default function VehiculoDetailPage() {
</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"> <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> <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"> <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> <span className="text-blue-400 text-3xl md:text-5xl font-black tracking-tighter">
{vehicle.price === 0
? "CONSULTAR"
: `${vehicle.currency} ${vehicle.price?.toLocaleString()}`}
</span>
</div> </div>
</div> </div>
{isContactable ? ( {isContactable ? (
<div className="space-y-4"> <div className="space-y-4">
<a href={getWhatsAppLink(vehicle.contactPhone, `${vehicle.brand?.name} ${vehicle.versionName}`)} target="_blank" rel="noopener noreferrer" {/* BOTÓN WHATSAPP: Usamos la nueva variable canShowWhatsApp */}
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"> {canShowWhatsApp && (
<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" /> <FaWhatsapp className="text-3xl group-hover:scale-110 transition-transform text-green-400 group-hover:text-white" />
<span>Contactar</span> <span>Contactar</span>
</a> </a>
)}
{vehicle.contactPhone && ( {/* CAJA DE TELÉFONO: Usamos canShowPhone */}
{canShowPhone && (
<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"> <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="text-blue-400">📞</span>
<span className="opacity-60 font-bold">Llamar:</span> <span className="opacity-60 font-bold">Llamar:</span>
<span className="tracking-widest">{vehicle.contactPhone}</span> <span className="tracking-widest select-all">
{vehicle.contactPhone}
</span>
</div>
)}
{/* CAJA DE EMAIL: Usamos canShowEmail */}
{canShowEmail && (
<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.1em] text-[9px] shadow-sm overflow-hidden">
<span className="text-blue-400 text-base"></span>
<span className="tracking-widest truncate max-w-[200px] select-all">
{vehicle.contactEmail}
</span>
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 text-center"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6 text-center">
<div className="text-3xl mb-3"> <div className="text-3xl mb-3">
{isOwnerAdmin ? '' : {isOwnerAdmin
vehicle.statusID === AD_STATUSES.MODERATION_PENDING ? '⏳' : ? ""
vehicle.statusID === AD_STATUSES.PAYMENT_PENDING ? '💳' : : vehicle.statusID === AD_STATUSES.MODERATION_PENDING
vehicle.statusID === AD_STATUSES.SOLD ? '🤝' : '🔒'} ? "⏳"
: vehicle.statusID === AD_STATUSES.PAYMENT_PENDING
? "💳"
: vehicle.statusID === AD_STATUSES.SOLD
? "🤝"
: "🔒"}
</div> </div>
<h3 className="text-lg font-bold text-white uppercase tracking-tight mb-2"> <h3 className="text-lg font-bold text-white uppercase tracking-tight mb-2">
{isOwnerAdmin ? 'Contacto en descripción' : {isOwnerAdmin
vehicle.statusID === AD_STATUSES.MODERATION_PENDING ? 'Aviso en Revisión' : ? "Contacto en descripción"
vehicle.statusID === AD_STATUSES.PAYMENT_PENDING ? 'Pago Pendiente' : : vehicle.statusID === AD_STATUSES.MODERATION_PENDING
vehicle.statusID === AD_STATUSES.SOLD ? 'Vehículo Vendido' : 'No disponible'} ? "Aviso en Revisión"
: vehicle.statusID === AD_STATUSES.PAYMENT_PENDING
? "Pago Pendiente"
: vehicle.statusID === AD_STATUSES.SOLD
? "Vehículo Vendido"
: "No disponible"}
</h3> </h3>
<p className="text-xs text-gray-400 leading-relaxed"> <p className="text-xs text-gray-400 leading-relaxed">
{isOwnerAdmin {isOwnerAdmin
? 'Revisa la descripción para contactar al vendedor.' ? "Revisa la descripción para contactar al vendedor."
: vehicle.statusID === AD_STATUSES.MODERATION_PENDING : vehicle.statusID === AD_STATUSES.MODERATION_PENDING
? 'Este aviso está siendo verificado por un moderador. Estará activo pronto.' ? "Este aviso está siendo verificado por un moderador. Estará activo pronto."
: vehicle.statusID === AD_STATUSES.PAYMENT_PENDING : vehicle.statusID === AD_STATUSES.PAYMENT_PENDING
? 'El pago de este aviso aún no ha sido procesado completamente.' ? "El pago de este aviso aún no ha sido procesado completamente."
: vehicle.statusID === AD_STATUSES.SOLD : vehicle.statusID === AD_STATUSES.SOLD
? 'Este vehículo ya ha sido vendido a otro usuario.' ? "Este vehículo ya ha sido vendido a otro usuario."
: 'Este vehículo ya no se encuentra disponible para la venta.'} : "Revisa la descripción para contactar al vendedor."}
</p> </p>
</div> </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"> <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 <FaShareAlt /> Compartir Aviso
</button> </button>
</div> </div>
@@ -240,11 +393,22 @@ export default function VehiculoDetailPage() {
); );
} }
function TechnicalItem({ label, value, icon }: { label: string, value: string, icon: string }) { function TechnicalItem({
if ((value === undefined || value === null || value === '') || value === 'N/A') return null; label,
value,
icon,
}: {
label: string;
value: string;
icon: string;
}) {
if (value === undefined || value === null || value === "" || value === "N/A")
return null;
return ( return (
<div className="bg-white/5 p-4 rounded-xl md:rounded-2xl border border-white/5 hover:bg-white/10 transition-colors"> <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> <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"> <div className="flex items-center gap-2">
<span className="text-sm md:text-base">{icon}</span> <span className="text-sm md:text-base">{icon}</span>
<span className="text-white font-bold text-xs md:text-sm">{value}</span> <span className="text-white font-bold text-xs md:text-sm">{value}</span>

View File

@@ -4,56 +4,45 @@ const BASE_URL = import.meta.env.VITE_API_BASE_URL;
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: BASE_URL, baseURL: BASE_URL,
withCredentials: true, // Importante para enviar Cookies withCredentials: true,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
// Interceptor de Respuesta (Manejo de Errores y Refresh) // Interceptor de Respuesta
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
const originalRequest = error.config; 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 ( if (
error.response?.status === 401 && error.response?.status === 401 &&
!originalRequest._retry && !originalRequest._retry &&
!originalRequest.url.includes('/auth/refresh-token') !originalRequest.url.includes('/auth/refresh-token')&&
!originalRequest.url.includes('/auth/login')
) { ) {
originalRequest._retry = true; originalRequest._retry = true;
try { try {
// Intentamos renovar el token
await apiClient.post('/auth/refresh-token'); await apiClient.post('/auth/refresh-token');
// Si el refresh funciona, reintentamos la petición original
return apiClient(originalRequest); return apiClient(originalRequest);
} catch (refreshError) { } catch (refreshError) {
// Si el refresh falla (401 o cualquier otro), no hay nada que hacer. // SI FALLA EL REFRESH (Sesión caducada definitivamente)
// Forzamos logout en el cliente para limpiar basura
localStorage.removeItem('session');
localStorage.removeItem('userProfile');
// Opcional: Redirigir a login o recargar para limpiar estado // Limpiamos todo rastro de sesión local
// window.location.href = '/'; localStorage.removeItem('userProfile');
localStorage.removeItem('session');
// Redirigir a home y recargar para limpiar estado de React
// Esto asegura que visualmente se vea deslogueado
window.location.href = '/';
return Promise.reject(refreshError); return Promise.reject(refreshError);
} }
} }
return Promise.reject(error); 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; export default apiClient;

View File

@@ -0,0 +1,30 @@
import apiClient from './axios.client';
export interface NotificationPreferences {
sistema: boolean;
marketing: boolean;
rendimiento: boolean;
mensajes: boolean;
}
export const NotificationPreferencesService = {
/** Obtiene las preferencias actuales del usuario autenticado */
getPreferences: async (): Promise<NotificationPreferences> => {
const response = await apiClient.get('/Profile/notification-preferences');
return response.data;
},
/** Actualiza las preferencias desde el panel de perfil */
updatePreferences: async (data: NotificationPreferences): Promise<void> => {
await apiClient.put('/Profile/notification-preferences', data);
},
/**
* Procesa la baja one-click tomando el token de la URL.
* Endpoint público (no requiere token JWT).
*/
unsubscribeUsingToken: async (token: string): Promise<{ success: boolean; category: string }> => {
const response = await apiClient.get(`/Unsubscribe?token=${encodeURIComponent(token)}`);
return response.data;
},
};

View File

@@ -31,6 +31,11 @@ export const getImageUrl = (path?: string): string => {
* Formatea un número como moneda ARS o USD. * Formatea un número como moneda ARS o USD.
*/ */
export const formatCurrency = (amount: number, currency: string = 'ARS') => { export const formatCurrency = (amount: number, currency: string = 'ARS') => {
// Lógica para precio 0
if (amount === 0) {
return 'CONSULTAR';
}
return new Intl.NumberFormat('es-AR', { return new Intl.NumberFormat('es-AR', {
style: 'currency', style: 'currency',
currency: currency, currency: currency,

View File

@@ -5,38 +5,41 @@ services:
dockerfile: Backend/Dockerfile.API dockerfile: Backend/Dockerfile.API
container_name: motores-backend container_name: motores-backend
restart: always restart: always
# Eliminamos ports para que NO sea accesible desde afuera, solo por motores-frontend
env_file: env_file:
- Backend/MotoresArgentinosV2.API/.env - Backend/MotoresArgentinosV2.API/.env
environment: environment:
- ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_HTTP_PORTS=8080 - ASPNETCORE_HTTP_PORTS=8080
# Soportamos ambos: el dominio final y la IP de pruebas para CORS - AppSettings__FrontendUrl=https://motoresargentinos.com,https://www.motoresargentinos.com,http://192.168.5.129:8086,http://localhost:5173,https://clasificados.eldia.com
- AppSettings__FrontendUrl=https://www.motoresargentinos.com,http://192.168.5.129:8086,http://localhost:5173
# Para links generados (pagos/confirmaciones), usamos la IP por ahora si vas a probar sin dominio
- AppSettings__BaseUrl=http://192.168.5.129:8086/api - AppSettings__BaseUrl=http://192.168.5.129:8086/api
- AppSettings__SitemapOutputPath=/app/sitemap-output/sitemap.xml
networks: networks:
- motores-network - motores-network
volumes: volumes:
- /mnt/MotoresImg:/app/wwwroot/uploads - /mnt/MotoresImg:/app/wwwroot/uploads
- sitemap-data:/app/sitemap-output
motores-frontend: motores-frontend:
build: build:
context: ./Frontend context: ./Frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
# Al usar Nginx como proxy, podemos usar rutas relativas desde el navegador
- VITE_API_BASE_URL=/api - VITE_API_BASE_URL=/api
- VITE_STATIC_BASE_URL= - VITE_STATIC_BASE_URL=
- VITE_MP_PUBLIC_KEY=TEST-2c7996fc-da9a-4e40-b693-a5ab386ad88e - VITE_MP_PUBLIC_KEY=APP_USR-12bbd874-5ea7-49cf-b9d9-0f3e7df089b3
container_name: motores-frontend container_name: motores-frontend
restart: always restart: always
ports: ports:
- "8086:80" # Puerto libre detectado en el análisis de Portainer - "8086:80"
depends_on: depends_on:
- motores-backend - motores-backend
networks: networks:
- motores-network - motores-network
volumes:
- sitemap-data:/usr/share/nginx/html/sitemap-data
volumes:
sitemap-data:
networks: networks:
motores-network: motores-network: