Compare commits
26 Commits
9a2b5a5f91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d7e5c2eb | |||
| 3135241aaa | |||
| f837f446b9 | |||
| 8bd8384715 | |||
| dd4b32dd7e | |||
| 37869fa8b4 | |||
| 96fca4d9c7 | |||
| f1a9bb9099 | |||
| 88b558afd4 | |||
| c60d7be293 | |||
| 1bc93972ef | |||
| 0802dae400 | |||
| df777400ab | |||
| 47d47d42fb | |||
| 18a142e070 | |||
| 2c3b7b2336 | |||
| 9e57eb7f54 | |||
| 8569f57a62 | |||
| 042cd8c6f1 | |||
| 2dfd5f1fb8 | |||
| 84bbb676f8 | |||
| ba9b0b3547 | |||
| 5a7c3f62f1 | |||
| bd45e89bd2 | |||
| 46a41dc29d | |||
| 0ebb2b15e5 |
27
.dockerignore
Normal file
27
.dockerignore
Normal 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
1
.gitignore
vendored
@@ -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
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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")]
|
||||||
@@ -39,26 +47,35 @@ public class ChatController : ControllerBase
|
|||||||
|
|
||||||
if (receiver != null && !string.IsNullOrEmpty(receiver.Email))
|
if (receiver != null && !string.IsNullOrEmpty(receiver.Email))
|
||||||
{
|
{
|
||||||
// LÓGICA DE NOMBRE DE REMITENTE
|
// Solo enviar correo si la preferencia "mensajes" está habilitada
|
||||||
string senderDisplayName;
|
if (await _prefService.IsEnabledAsync(receiver.UserID, NotificationCategory.Mensajes))
|
||||||
|
|
||||||
if (sender != null && sender.UserType == 3) // 3 = ADMIN
|
|
||||||
{
|
{
|
||||||
// Caso: Moderador escribe a Usuario
|
// LÓGICA DE NOMBRE DE REMITENTE
|
||||||
senderDisplayName = "Un moderador de Motores Argentinos";
|
string senderDisplayName;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Caso: Usuario responde a Moderador
|
|
||||||
string name = sender?.UserName ?? "Un usuario";
|
|
||||||
senderDisplayName = $"El usuario {name}";
|
|
||||||
}
|
|
||||||
|
|
||||||
await _notificationService.SendChatNotificationEmailAsync(
|
if (sender != null && sender.UserType == 3) // 3 = ADMIN
|
||||||
receiver.Email,
|
{
|
||||||
senderDisplayName, // Pasamos el nombre formateado
|
// Caso: Moderador escribe a Usuario
|
||||||
msg.MessageText,
|
senderDisplayName = "Un moderador de Motores Argentinos";
|
||||||
msg.AdID);
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Caso: Usuario responde a Moderador
|
||||||
|
string name = sender?.UserName ?? "Un usuario";
|
||||||
|
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(
|
||||||
|
receiver.Email,
|
||||||
|
senderDisplayName, // Pasamos el nombre formateado
|
||||||
|
msg.MessageText,
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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." });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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." });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -215,4 +241,61 @@ public class AdViewLog
|
|||||||
public int AdID { get; set; }
|
public int AdID { get; set; }
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 CheckExpiredAdsAsync()
|
private async Task CleanupUnsubscribeTokensAsync()
|
||||||
{
|
{
|
||||||
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 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()
|
||||||
|
{
|
||||||
|
using (var scope = _serviceProvider.CreateScope())
|
||||||
|
{
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||||
|
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();
|
||||||
@@ -129,8 +161,11 @@ public class AdExpirationService : BackgroundService
|
|||||||
{
|
{
|
||||||
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 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();
|
||||||
|
|
||||||
@@ -150,12 +185,22 @@ public class AdExpirationService : BackgroundService
|
|||||||
{
|
{
|
||||||
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
||||||
|
|
||||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||||
var expDate = ad.PublishedAt!.Value.AddDays(30);
|
var expDate = ad.PublishedAt!.Value.AddDays(30);
|
||||||
|
|
||||||
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 */ }
|
||||||
@@ -169,8 +214,11 @@ public class AdExpirationService : BackgroundService
|
|||||||
{
|
{
|
||||||
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 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;
|
||||||
@@ -212,11 +273,11 @@ public class AdExpirationService : BackgroundService
|
|||||||
{
|
{
|
||||||
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 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;
|
||||||
|
|
||||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
// Respetamos la preferencia de la categoría "marketing" (carrito abandonado)
|
||||||
var link = $"{frontendUrl}/publicar?edit={ad.AdID}";
|
if (await prefService.IsEnabledAsync(ad.User.UserID, NotificationCategory.Marketing))
|
||||||
|
{
|
||||||
|
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -252,10 +322,13 @@ public class AdExpirationService : BackgroundService
|
|||||||
{
|
{
|
||||||
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";
|
||||||
|
|
||||||
// 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.
|
||||||
|
|
||||||
var messageThreshold = DateTime.UtcNow.AddHours(-4);
|
var messageThreshold = DateTime.UtcNow.AddHours(-4);
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 = $@"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo-ma.svg" />
|
<!-- Google tag (gtag.js) -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XTD4SD01DV"></script>
|
||||||
<title>Motores Argentinos</title>
|
<script>
|
||||||
<script src="https://sdk.mercadopago.com/js/v2"></script>
|
window.dataLayer = window.dataLayer || [];
|
||||||
</head>
|
function gtag() { dataLayer.push(arguments); }
|
||||||
<body>
|
gtag('js', new Date());
|
||||||
<div id="root"></div>
|
gtag('config', 'G-XTD4SD01DV');
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
</script>
|
||||||
</body>
|
<meta charset="UTF-8" />
|
||||||
</html>
|
<link rel="icon" type="image/svg+xml" href="/logo-ma.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Motores Argentinos</title>
|
||||||
|
<script src="https://sdk.mercadopago.com/js/v2"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
|||||||
15
Frontend/public/robots.txt
Normal file
15
Frontend/public/robots.txt
Normal 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
|
||||||
@@ -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 Nº: RL-2024-70042723-APN-DNDA#MJ - Propietario: Publiéxito S.A.</p>
|
<p>Registro DNDA Nº: RL-2024-70042723-APN-DNDA#MJ - Propietario: Publiéxito S.A.</p>
|
||||||
<p>Director: Leonardo Mario Forclaz - 46 N 423 - La Plata - Pcia. de Bs. As.</p>
|
<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 />
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
|
||||||
useEffect(() => {
|
|
||||||
const initAuth = async () => {
|
|
||||||
try {
|
|
||||||
const sessionUser = await AuthService.checkSession();
|
|
||||||
if (sessionUser) {
|
|
||||||
setUser(sessionUser);
|
|
||||||
await fetchUnreadCount(); // <--- 5. LLAMAR AL CARGAR LA APP
|
|
||||||
} else {
|
|
||||||
setUser(null);
|
|
||||||
setUnreadCount(0);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setUser(null);
|
|
||||||
setUnreadCount(0);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initAuth();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Función centralizada para verificar la sesión
|
||||||
|
const verifySession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const sessionUser = await AuthService.checkSession();
|
||||||
|
if (sessionUser) {
|
||||||
|
if (sessionUser.id !== user?.id) {
|
||||||
|
setUser(sessionUser);
|
||||||
|
}
|
||||||
|
await fetchUnreadCount();
|
||||||
|
} else {
|
||||||
|
// 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 {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user, logout, fetchUnreadCount]);
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +348,12 @@ 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)
|
||||||
<option key={id} value={id} className="bg-gray-900">{config.label}</option>
|
.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>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -986,4 +1225,87 @@ function DashboardMiniCard({ label, value, icon, color = 'blue' }: { label: stri
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
60
Frontend/src/pages/BajaErrorPage.tsx
Normal file
60
Frontend/src/pages/BajaErrorPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
Frontend/src/pages/BajaExitosaPage.tsx
Normal file
64
Frontend/src/pages/BajaExitosaPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
Frontend/src/pages/CondicionesPage.tsx
Normal file
177
Frontend/src/pages/CondicionesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -208,4 +235,4 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,233 +236,368 @@ 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>
|
||||||
|
|
||||||
|
{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>
|
</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
|
||||||
// Esto permite que el dropdown se salga de la tarjeta sin cortarse.
|
// Esto permite que el dropdown se salga de la tarjeta sin cortarse.
|
||||||
// Usamos un z-index decreciente para que los dropdowns de arriba tapen a las tarjetas de abajo.
|
// Usamos un z-index decreciente para que los dropdowns de arriba tapen a las tarjetas de abajo.
|
||||||
<div
|
<div
|
||||||
key={av.id}
|
key={av.id}
|
||||||
className="glass p-6 rounded-[2.5rem] flex flex-col md:flex-row items-center gap-8 border border-white/5 hover:border-blue-500/20 transition-all relative"
|
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
|
||||||
<img src={getImageUrl(av.image)} className="w-full h-full object-cover" alt={`${av.brandName} ${av.versionName}`} />
|
src={getImageUrl(av.image)}
|
||||||
<div className="absolute top-3 left-3 bg-black/60 backdrop-blur-md px-2 py-1 rounded-lg border border-white/10">
|
className="w-full h-full object-cover"
|
||||||
<span className="text-[9px] font-bold text-white">#{av.id}</span>
|
alt={`${av.brandName} ${av.versionName}`}
|
||||||
|
/>
|
||||||
|
<div className="absolute top-3 left-3 bg-black/60 backdrop-blur-md px-2 py-1 rounded-lg border border-white/10">
|
||||||
|
<span className="text-[9px] font-bold text-white">
|
||||||
|
#{av.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 w-full text-center md:text-left">
|
<div className="flex-1 w-full text-center md:text-left">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<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 className="flex flex-wrap gap-3 justify-center md:justify-start">
|
||||||
|
<div className="bg-white/5 border border-white/5 px-3 py-1.5 rounded-lg flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-gray-500 font-bold uppercase">
|
||||||
|
Año
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white font-bold">
|
||||||
|
{av.year}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/5 border border-white/5 px-3 py-1.5 rounded-lg flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-gray-500 font-bold uppercase">
|
||||||
|
Visitas
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white font-bold">
|
||||||
|
{av.viewsCounter || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{av.isFeatured && (
|
||||||
|
<div className="bg-blue-600/20 border border-blue-500/30 px-3 py-1.5 rounded-lg">
|
||||||
|
<span className="text-[9px] text-blue-300 font-black uppercase tracking-widest">
|
||||||
|
⭐ Destacado
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3 justify-center md:justify-start">
|
|
||||||
<div className="bg-white/5 border border-white/5 px-3 py-1.5 rounded-lg flex items-center gap-2">
|
|
||||||
<span className="text-[10px] text-gray-500 font-bold uppercase">Año</span>
|
|
||||||
<span className="text-xs text-white font-bold">{av.year}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/5 border border-white/5 px-3 py-1.5 rounded-lg flex items-center gap-2">
|
|
||||||
<span className="text-[10px] text-gray-500 font-bold uppercase">Visitas</span>
|
|
||||||
<span className="text-xs text-white font-bold">{av.viewsCounter || 0}</span>
|
|
||||||
</div>
|
|
||||||
{av.isFeatured && (
|
|
||||||
<div className="bg-blue-600/20 border border-blue-500/30 px-3 py-1.5 rounded-lg">
|
|
||||||
<span className="text-[9px] text-blue-300 font-black uppercase tracking-widest">⭐ Destacado</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full md:w-auto flex flex-col gap-3 min-w-[180px]">
|
<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
|
|
||||||
to={`/publicar?edit=${av.id}`}
|
|
||||||
className="bg-blue-600 hover:bg-blue-500 text-white text-xs font-black uppercase tracking-widest rounded-xl px-4 py-3 text-center shadow-lg shadow-blue-600/20 transition-all"
|
|
||||||
>
|
|
||||||
Continuar Pago ➔
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CASO 2: PAGO PENDIENTE (2) -> Botón de Verificar */}
|
|
||||||
{av.statusId === AD_STATUSES.PAYMENT_PENDING && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-400 px-4 py-2 rounded-xl text-center">
|
|
||||||
<span className="block text-[10px] font-black uppercase tracking-widest">⏳ Pago Pendiente</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleVerifyPayment(av.id)}
|
|
||||||
className="bg-white/5 hover:bg-white/10 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-2.5 rounded-xl border border-white/10 transition-all hover:border-white/20 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
🔄 Verificar Ahora
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CASO 3: EN REVISIÓN (3) -> Cartel informativo */}
|
|
||||||
{av.statusId === AD_STATUSES.MODERATION_PENDING && (
|
|
||||||
<div className="bg-blue-500/10 border border-blue-500/20 text-blue-300 px-4 py-3 rounded-xl text-center">
|
|
||||||
<span className="block text-[10px] font-black uppercase tracking-widest">⏳ En Revisión</span>
|
|
||||||
<span className="text-[8px] opacity-70">No editable</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CASO 4: VENCIDO (8) -> Botón de Republicar */}
|
|
||||||
{av.statusId === AD_STATUSES.EXPIRED && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="bg-gray-500/10 border border-gray-500/20 text-gray-400 px-4 py-2 rounded-xl text-center">
|
|
||||||
<span className="block text-[10px] font-black uppercase tracking-widest">⛔ Finalizado</span>
|
|
||||||
</div>
|
|
||||||
<Link
|
<Link
|
||||||
to={`/publicar?edit=${av.id}`}
|
to={`/publicar?edit=${av.id}`}
|
||||||
className="bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-2.5 rounded-xl border border-transparent shadow-lg shadow-blue-600/20 transition-all flex items-center justify-center gap-2"
|
className="bg-blue-600 hover:bg-blue-500 text-white text-xs font-black uppercase tracking-widest rounded-xl px-4 py-3 text-center shadow-lg shadow-blue-600/20 transition-all"
|
||||||
>
|
>
|
||||||
🔄 Republicar
|
Continuar Pago ➔
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CASO 5: ACTIVOS/PAUSADOS/OTROS (StatusDropdown) */}
|
|
||||||
{av.statusId !== AD_STATUSES.DRAFT &&
|
|
||||||
av.statusId !== AD_STATUSES.PAYMENT_PENDING &&
|
|
||||||
av.statusId !== AD_STATUSES.MODERATION_PENDING &&
|
|
||||||
av.statusId !== AD_STATUSES.EXPIRED && (
|
|
||||||
<StatusDropdown
|
|
||||||
currentStatus={av.statusId || AD_STATUSES.ACTIVE}
|
|
||||||
onChange={(newStatus) => initiateStatusChange(av.id, newStatus)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* BOTONES COMUNES (Siempre visibles) */}
|
{/* CASO 2: PAGO PENDIENTE (2) -> Botón de Verificar */}
|
||||||
<div className="grid grid-cols-1 gap-2 mt-1">
|
{av.statusId === AD_STATUSES.PAYMENT_PENDING && (
|
||||||
<Link
|
<div className="flex flex-col gap-2">
|
||||||
to={`/vehiculo/${av.id}`}
|
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-400 px-4 py-2 rounded-xl text-center">
|
||||||
className="bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white border border-white/5 px-4 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest text-center transition-all flex items-center justify-center gap-2"
|
<span className="block text-[10px] font-black uppercase tracking-widest">
|
||||||
>
|
⏳ Pago Pendiente
|
||||||
<span>👁️ Ver Detalle</span>
|
</span>
|
||||||
</Link>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleVerifyPayment(av.id)}
|
||||||
|
className="bg-white/5 hover:bg-white/10 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-2.5 rounded-xl border border-white/10 transition-all hover:border-white/20 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
🔄 Verificar Ahora
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasMessages && (
|
{/* CASO 3: EN REVISIÓN (3) -> Cartel informativo */}
|
||||||
<button
|
{av.statusId === AD_STATUSES.MODERATION_PENDING && (
|
||||||
onClick={() => openChatForAd(av.id, `${av.brandName} ${av.versionName}`)}
|
<div className="bg-blue-500/10 border border-blue-500/20 text-blue-300 px-4 py-3 rounded-xl text-center">
|
||||||
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"
|
<span className="block text-[10px] font-black uppercase tracking-widest">
|
||||||
|
⏳ En Revisión
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] opacity-70">
|
||||||
|
No editable
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CASO 4: VENCIDO (8) -> Botón de Republicar */}
|
||||||
|
{av.statusId === AD_STATUSES.EXPIRED && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="bg-gray-500/10 border border-gray-500/20 text-gray-400 px-4 py-2 rounded-xl text-center">
|
||||||
|
<span className="block text-[10px] font-black uppercase tracking-widest">
|
||||||
|
⛔ Finalizado
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/publicar?edit=${av.id}`}
|
||||||
|
className="bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-2.5 rounded-xl border border-transparent shadow-lg shadow-blue-600/20 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
🔄 Republicar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CASO 5: ACTIVOS/PAUSADOS/OTROS (StatusDropdown) */}
|
||||||
|
{av.statusId !== AD_STATUSES.DRAFT &&
|
||||||
|
av.statusId !== AD_STATUSES.PAYMENT_PENDING &&
|
||||||
|
av.statusId !== AD_STATUSES.MODERATION_PENDING &&
|
||||||
|
av.statusId !== AD_STATUSES.EXPIRED &&
|
||||||
|
av.statusId !== AD_STATUSES.REJECTED && (
|
||||||
|
<StatusDropdown
|
||||||
|
currentStatus={
|
||||||
|
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) */}
|
||||||
|
<div className="grid grid-cols-1 gap-2 mt-1">
|
||||||
|
<Link
|
||||||
|
to={`/vehiculo/${av.id}`}
|
||||||
|
className="bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white border border-white/5 px-4 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest text-center transition-all flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
💬 Mensajes
|
<span>👁️ Ver Detalle</span>
|
||||||
{hasUnread && (
|
</Link>
|
||||||
<span className="absolute top-3 right-3 w-2 h-2 bg-red-500 rounded-full animate-pulse shadow-lg shadow-red-500/50"></span>
|
|
||||||
)}
|
{hasMessages && (
|
||||||
</button>
|
<button
|
||||||
)}
|
onClick={() =>
|
||||||
|
openChatForAd(
|
||||||
|
av.id,
|
||||||
|
`${av.brandName} ${av.versionName}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="relative bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white border border-white/5 px-4 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
💬 Mensajes
|
||||||
|
{hasUnread && (
|
||||||
|
<span className="absolute top-3 right-3 w-2 h-2 bg-red-500 rounded-full animate-pulse shadow-lg shadow-red-500/50"></span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</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(
|
||||||
const key = curr.adID;
|
mensajes.reduce((acc: any, curr) => {
|
||||||
if (!acc[key]) acc[key] = { msg: curr, count: 0, unread: false };
|
const key = curr.adID;
|
||||||
acc[key].count++;
|
if (!acc[key])
|
||||||
if (!curr.isRead && curr.receiverID === user.id) acc[key].unread = true;
|
acc[key] = { msg: curr, count: 0, unread: false };
|
||||||
if (new Date(curr.sentAt!) > new Date(acc[key].msg.sentAt!)) acc[key].msg = curr;
|
acc[key].count++;
|
||||||
return acc;
|
if (!curr.isRead && curr.receiverID === user.id)
|
||||||
}, {})).map((item: any) => {
|
acc[key].unread = true;
|
||||||
const aviso = avisos.find(a => a.id === item.msg.adID);
|
if (
|
||||||
const tituloAviso = aviso ? `${aviso.brandName} ${aviso.versionName}` : `Aviso #${item.msg.adID}`;
|
new Date(curr.sentAt!) > new Date(acc[key].msg.sentAt!)
|
||||||
|
)
|
||||||
|
acc[key].msg = curr;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
).map((item: any) => {
|
||||||
|
const aviso = avisos.find((a) => a.id === item.msg.adID);
|
||||||
|
const tituloAviso = aviso
|
||||||
|
? `${aviso.brandName} ${aviso.versionName}`
|
||||||
|
: `Aviso #${item.msg.adID}`;
|
||||||
|
|
||||||
return (
|
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,14 +793,28 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
56
Frontend/src/pages/ProcessUnsubscribePage.tsx
Normal file
56
Frontend/src/pages/ProcessUnsubscribePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
<LoginModal
|
<div className="bg-[#111318]/90 backdrop-blur-3xl p-12 rounded-[3rem] max-w-2xl mx-auto border border-white/5 shadow-2xl">
|
||||||
onSuccess={(u) => setUser(u)}
|
<span className="text-7xl mb-8 block">🔒</span>
|
||||||
onClose={() => navigate("/")}
|
<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
|
||||||
|
initialMode="REGISTER"
|
||||||
|
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">
|
||||||
|
|||||||
@@ -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)
|
||||||
<div className="flex flex-col items-center justify-center p-40 gap-6">
|
return (
|
||||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
|
<div className="flex flex-col items-center justify-center p-40 gap-6">
|
||||||
<span className="text-gray-500 font-black uppercase tracking-widest text-xs animate-pulse">Cargando...</span>
|
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
|
||||||
</div>
|
<span className="text-gray-500 font-black uppercase tracking-widest text-xs animate-pulse">
|
||||||
);
|
Cargando...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (error || !vehicle) return <div className="text-white p-20 text-center">{error || "Vehículo no encontrado"}</div>;
|
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 && (
|
||||||
<FaWhatsapp className="text-3xl group-hover:scale-110 transition-transform text-green-400 group-hover:text-white" />
|
<a
|
||||||
<span>Contactar</span>
|
href={getWhatsAppLink(
|
||||||
</a>
|
vehicle.contactPhone,
|
||||||
|
`${vehicle.brand?.name} ${vehicle.versionName}`,
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="w-full glass border border-green-500/30 hover:bg-green-600 text-white py-5 rounded-2xl font-black uppercase tracking-widest transition-all shadow-lg shadow-green-600/20 flex items-center justify-center gap-3 group hover:border-green-500/50"
|
||||||
|
>
|
||||||
|
<FaWhatsapp className="text-3xl group-hover:scale-110 transition-transform text-green-400 group-hover:text-white" />
|
||||||
|
<span>Contactar</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
{vehicle.contactPhone && (
|
{/* 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,15 +393,26 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
// Limpiamos todo rastro de sesión local
|
||||||
localStorage.removeItem('userProfile');
|
localStorage.removeItem('userProfile');
|
||||||
|
localStorage.removeItem('session');
|
||||||
// Opcional: Redirigir a login o recargar para limpiar estado
|
|
||||||
// window.location.href = '/';
|
// 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;
|
||||||
30
Frontend/src/services/notification-preferences.service.ts
Normal file
30
Frontend/src/services/notification-preferences.service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user