Compare commits
28 Commits
e096ed1590
...
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 | |||
| 9a2b5a5f91 | |||
| ed243ccb78 |
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
|
||||
*.pdf
|
||||
*.txt
|
||||
|
||||
#Directorio de Imagenes
|
||||
Backend/MotoresArgentinosV2.API/wwwroot
|
||||
@@ -18,12 +18,21 @@ public class AdminController : ControllerBase
|
||||
private readonly MotoresV2DbContext _context;
|
||||
private readonly IAdSyncService _syncService;
|
||||
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;
|
||||
_syncService = syncService;
|
||||
_notificationService = notificationService;
|
||||
_prefService = prefService;
|
||||
_frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
|
||||
}
|
||||
|
||||
// --- MODERACIÓN ---
|
||||
@@ -43,6 +52,12 @@ public class AdminController : ControllerBase
|
||||
.AsNoTracking() // Optimización de lectura
|
||||
.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)
|
||||
if (!string.IsNullOrEmpty(q))
|
||||
{
|
||||
@@ -154,12 +169,22 @@ public class AdminController : ControllerBase
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Sincronizar a Legacy
|
||||
// Sincronizar a Legacy y notificar aprobación (categoría: sistema)
|
||||
try
|
||||
{
|
||||
await _syncService.SyncAdToLegacyAsync(id);
|
||||
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)
|
||||
{
|
||||
@@ -191,9 +216,19 @@ public class AdminController : ControllerBase
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Notificar rechazo
|
||||
// Notificar rechazo (categoría: sistema)
|
||||
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." });
|
||||
}
|
||||
@@ -452,7 +487,11 @@ public class AdminController : ControllerBase
|
||||
if (string.IsNullOrEmpty(q)) return Ok(new List<object>());
|
||||
|
||||
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)
|
||||
.Select(u => new { u.UserID, u.UserName, u.Email, u.FirstName, u.LastName, u.PhoneNumber, u.IsBlocked })
|
||||
.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));
|
||||
|
||||
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"]))
|
||||
{
|
||||
@@ -88,8 +93,8 @@ public class AdsV2Controller : ControllerBase
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Request.Query["location"]))
|
||||
{
|
||||
var loc = Request.Query["location"].ToString();
|
||||
query = query.Where(a => a.Location != null && a.Location.Contains(loc));
|
||||
var loc = Request.Query["location"].ToString().ToLower();
|
||||
query = query.Where(a => a.Location != null && a.Location.ToLower().Contains(loc));
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Request.Query["condition"]))
|
||||
{
|
||||
@@ -119,7 +124,7 @@ public class AdsV2Controller : ControllerBase
|
||||
}
|
||||
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 ---
|
||||
@@ -326,6 +331,9 @@ public class AdsV2Controller : ControllerBase
|
||||
contactPhone = ad.ContactPhone,
|
||||
contactEmail = ad.ContactEmail,
|
||||
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 }),
|
||||
features = ad.Features.Select(f => new { f.FeatureKey, f.FeatureValue }),
|
||||
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)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
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
|
||||
var ghost = await _identityService.CreateGhostUserAsync(
|
||||
request.GhostUserEmail,
|
||||
@@ -432,6 +455,9 @@ public class AdsV2Controller : ControllerBase
|
||||
ContactPhone = request.ContactPhone,
|
||||
ContactEmail = request.ContactEmail,
|
||||
DisplayContactInfo = request.DisplayContactInfo,
|
||||
ShowPhone = request.ShowPhone,
|
||||
AllowWhatsApp = request.AllowWhatsApp,
|
||||
ShowEmail = request.ShowEmail,
|
||||
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -500,6 +526,34 @@ public class AdsV2Controller : ControllerBase
|
||||
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")]
|
||||
public async Task<IActionResult> UploadPhotos(int id, [FromForm] IFormFileCollection files)
|
||||
{
|
||||
@@ -552,6 +606,7 @@ public class AdsV2Controller : ControllerBase
|
||||
if (uploadedCount > 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
await NormalizeAdPhotosAsync(id);
|
||||
return Ok(new
|
||||
{
|
||||
message = $"{uploadedCount} fotos procesadas.",
|
||||
@@ -583,6 +638,8 @@ public class AdsV2Controller : ControllerBase
|
||||
_context.AdPhotos.Remove(photo);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await NormalizeAdPhotosAsync(photo.AdID);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -655,8 +712,18 @@ public class AdsV2Controller : ControllerBase
|
||||
ad.ContactPhone = updatedAdDto.ContactPhone;
|
||||
ad.ContactEmail = updatedAdDto.ContactEmail;
|
||||
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)
|
||||
|
||||
// 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
|
||||
var adBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
@@ -732,6 +799,7 @@ public class AdsV2Controller : ControllerBase
|
||||
var ads = await _context.Favorites
|
||||
.Where(f => f.UserID == userId)
|
||||
.Join(_context.Ads, f => f.AdID, a => a.AdID, (f, a) => a)
|
||||
.Where(a => a.StatusID != (int)AdStatusEnum.Deleted)
|
||||
.Include(a => a.Photos)
|
||||
.Select(a => new
|
||||
{
|
||||
@@ -770,11 +838,17 @@ public class AdsV2Controller : ControllerBase
|
||||
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)
|
||||
{
|
||||
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
|
||||
@@ -787,6 +861,15 @@ public class AdsV2Controller : ControllerBase
|
||||
int oldStatus = ad.StatusID;
|
||||
ad.StatusID = newStatus;
|
||||
|
||||
if (newStatus == (int)AdStatusEnum.Deleted)
|
||||
{
|
||||
ad.DeletedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
ad.DeletedAt = null;
|
||||
}
|
||||
|
||||
// 📝 AUDITORÍA
|
||||
var statusBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
@@ -795,7 +878,7 @@ public class AdsV2Controller : ControllerBase
|
||||
Entity = "Ad",
|
||||
EntityID = ad.AdID,
|
||||
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();
|
||||
|
||||
@@ -11,7 +11,6 @@ namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IIdentityService _identityService;
|
||||
@@ -28,21 +27,21 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
// Helper privado para cookies
|
||||
private void SetTokenCookie(string token, string cookieName)
|
||||
private void SetTokenCookie(string token, string cookieName, DateTime expires)
|
||||
{
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
HttpOnly = true, // Seguridad: JS no puede leer esto
|
||||
Expires = DateTime.UtcNow.AddMinutes(15),
|
||||
Secure = false, // Solo HTTPS (Para tests locales 'Secure = false' temporalmente)
|
||||
SameSite = SameSiteMode.Lax, // Protección CSRF (Strict para máxima seguridad, pero puede ser Lax si hay problemas con redirecciones y testeos locales)
|
||||
Expires = expires,
|
||||
Secure = true, // Solo HTTPS (Para tests locales 'Secure = false' temporalmente)
|
||||
SameSite = SameSiteMode.Strict,//SameSiteMode.Strict, Protección CSRF (Strict para máxima seguridad) - (Para tests locales SameSiteMode.Lax temporalmente)
|
||||
IsEssential = true
|
||||
};
|
||||
Response.Cookies.Append(cookieName, token, cookieOptions);
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO (5 intentos/min)
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var (user, message) = await _identityService.AuthenticateAsync(request.Username, request.Password);
|
||||
@@ -89,8 +88,10 @@ public class AuthController : ControllerBase
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 3. Setear Cookies
|
||||
SetTokenCookie(jwtToken, "accessToken");
|
||||
SetTokenCookie(refreshToken.Token, "refreshToken");
|
||||
// El AccessToken dura 60 min (coincide con JWT)
|
||||
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
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
@@ -122,7 +123,6 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("refresh-token")]
|
||||
// NO PROTEGIDO ESTRICTAMENTE (Usa límite global)
|
||||
public async Task<IActionResult> RefreshToken()
|
||||
{
|
||||
var refreshToken = Request.Cookies["refreshToken"];
|
||||
@@ -154,14 +154,14 @@ public class AuthController : ControllerBase
|
||||
var newJwtToken = _tokenService.GenerateJwtToken(user);
|
||||
|
||||
// Actualizar Cookies
|
||||
SetTokenCookie(newJwtToken, "accessToken");
|
||||
SetTokenCookie(newRefreshToken.Token, "refreshToken");
|
||||
SetTokenCookie(newJwtToken, "accessToken", DateTime.UtcNow.AddMinutes(60));
|
||||
// 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" });
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
// NO PROTEGIDO ESTRICTAMENTE
|
||||
public IActionResult Logout()
|
||||
{
|
||||
Response.Cookies.Delete("accessToken");
|
||||
@@ -287,8 +287,8 @@ public class AuthController : ControllerBase
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Setear Cookies Seguras
|
||||
SetTokenCookie(token, "accessToken");
|
||||
SetTokenCookie(refreshToken.Token, "refreshToken");
|
||||
SetTokenCookie(token, "accessToken", DateTime.UtcNow.AddMinutes(60));
|
||||
SetTokenCookie(refreshToken.Token, "refreshToken", DateTime.UtcNow.AddDays(7));
|
||||
|
||||
_context.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
@@ -386,7 +386,7 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||
{
|
||||
var (success, message) = await _identityService.RegisterUserAsync(request);
|
||||
@@ -407,7 +407,7 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("verify-email")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest request)
|
||||
{
|
||||
var (success, message) = await _identityService.VerifyEmailAsync(request.Token);
|
||||
@@ -428,7 +428,7 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("resend-verification")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public async Task<IActionResult> ResendVerification([FromBody] ResendVerificationRequest request)
|
||||
{
|
||||
var (success, message) = await _identityService.ResendVerificationEmailAsync(request.Email);
|
||||
@@ -437,7 +437,7 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("forgot-password")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
|
||||
{
|
||||
var (success, message) = await _identityService.ForgotPasswordAsync(request.Email);
|
||||
@@ -452,7 +452,7 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("reset-password")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
|
||||
{
|
||||
var (success, message) = await _identityService.ResetPasswordAsync(request.Token, request.NewPassword);
|
||||
@@ -474,7 +474,7 @@ public class AuthController : ControllerBase
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("change-password")]
|
||||
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
|
||||
[EnableRateLimiting("AuthPolicy")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
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 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;
|
||||
_notificationService = notificationService;
|
||||
_prefService = prefService;
|
||||
_frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
|
||||
}
|
||||
|
||||
[HttpPost("send")]
|
||||
@@ -38,6 +46,9 @@ public class ChatController : ControllerBase
|
||||
var sender = await _context.Users.FindAsync(msg.SenderID);
|
||||
|
||||
if (receiver != null && !string.IsNullOrEmpty(receiver.Email))
|
||||
{
|
||||
// Solo enviar correo si la preferencia "mensajes" está habilitada
|
||||
if (await _prefService.IsEnabledAsync(receiver.UserID, NotificationCategory.Mensajes))
|
||||
{
|
||||
// LÓGICA DE NOMBRE DE REMITENTE
|
||||
string senderDisplayName;
|
||||
@@ -54,11 +65,17 @@ public class ChatController : ControllerBase
|
||||
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);
|
||||
msg.AdID,
|
||||
unsubscribeUrl); // Se incluye URL de baja en el footer
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -74,8 +91,12 @@ public class ChatController : ControllerBase
|
||||
public async Task<IActionResult> GetInbox(int userId)
|
||||
{
|
||||
// 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
|
||||
.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)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -119,7 +140,8 @@ public class ChatController : ControllerBase
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
namespace MotoresArgentinosV2.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class OperacionesLegacyController : ControllerBase
|
||||
{
|
||||
private readonly IOperacionesLegacyService _operacionesService;
|
||||
private readonly ILogger<OperacionesLegacyController> _logger;
|
||||
|
||||
public OperacionesLegacyController(IOperacionesLegacyService operacionesService, ILogger<OperacionesLegacyController> logger)
|
||||
{
|
||||
_operacionesService = operacionesService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene los medios de pago disponibles
|
||||
/// </summary>
|
||||
[HttpGet("medios-pago")]
|
||||
public async Task<ActionResult<List<MedioDePago>>> GetMediosDePago()
|
||||
{
|
||||
try
|
||||
{
|
||||
var medios = await _operacionesService.ObtenerMediosDePagoAsync();
|
||||
return Ok(medios);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener medios de pago");
|
||||
return StatusCode(500, "Ocurrió un error interno al obtener medios de pago");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Busca una operación por su número de operación
|
||||
/// </summary>
|
||||
[HttpGet("{noperacion}")]
|
||||
public async Task<ActionResult<List<Operacion>>> GetOperacion(string noperacion)
|
||||
{
|
||||
try
|
||||
{
|
||||
var operaciones = await _operacionesService.ObtenerOperacionesPorNumeroAsync(noperacion);
|
||||
|
||||
if (operaciones == null || !operaciones.Any())
|
||||
{
|
||||
return NotFound($"No se encontraron operaciones con el número {noperacion}");
|
||||
}
|
||||
|
||||
return Ok(operaciones);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener operación {Noperacion}", noperacion);
|
||||
return StatusCode(500, "Ocurrió un error interno al buscar la operación");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene operaciones realizadas en un rango de fechas
|
||||
/// </summary>
|
||||
[HttpGet("buscar")]
|
||||
public async Task<ActionResult<List<Operacion>>> GetOperacionesPorFecha([FromQuery] DateTime fechaInicio, [FromQuery] DateTime fechaFin)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (fechaInicio > fechaFin)
|
||||
{
|
||||
return BadRequest("La fecha de inicio no puede ser mayor a la fecha de fin.");
|
||||
}
|
||||
|
||||
var operaciones = await _operacionesService.ObtenerOperacionesPorFechasAsync(fechaInicio, fechaFin);
|
||||
return Ok(operaciones);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al buscar operaciones por fecha");
|
||||
return StatusCode(500, "Ocurrió un error interno al buscar operaciones.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registra una nueva operación de pago
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> CrearOperacion([FromBody] Operacion operacion)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
// Validar campos mínimos necesarios si es necesario
|
||||
if (string.IsNullOrEmpty(operacion.Noperacion))
|
||||
{
|
||||
return BadRequest("El número de operación es obligatorio.");
|
||||
}
|
||||
|
||||
var resultado = await _operacionesService.InsertarOperacionAsync(operacion);
|
||||
|
||||
if (resultado)
|
||||
{
|
||||
return CreatedAtAction(nameof(GetOperacion), new { noperacion = operacion.Noperacion }, operacion);
|
||||
}
|
||||
|
||||
return BadRequest("No se pudo registrar la operación.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al crear operación {Noperacion}", operacion.Noperacion);
|
||||
return StatusCode(500, "Ocurrió un error interno al registrar la operación.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using System.Security.Claims;
|
||||
@@ -14,10 +15,14 @@ namespace MotoresArgentinosV2.API.Controllers;
|
||||
public class ProfileController : ControllerBase
|
||||
{
|
||||
private readonly MotoresV2DbContext _context;
|
||||
private readonly INotificationPreferenceService _notifPrefService;
|
||||
|
||||
public ProfileController(MotoresV2DbContext context)
|
||||
public ProfileController(
|
||||
MotoresV2DbContext context,
|
||||
INotificationPreferenceService notifPrefService)
|
||||
{
|
||||
_context = context;
|
||||
_notifPrefService = notifPrefService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -71,4 +76,43 @@ public class ProfileController : ControllerBase
|
||||
|
||||
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();
|
||||
|
||||
// 🔒 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 =>
|
||||
{
|
||||
options.AddPolicy("AllowSpecificOrigin",
|
||||
@@ -98,29 +99,27 @@ builder.Services.Configure<HostOptions>(options =>
|
||||
builder.Services.AddDbContext<InternetDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("Internet")));
|
||||
|
||||
builder.Services.AddDbContext<EldiaDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("eldia")));
|
||||
|
||||
builder.Services.AddDbContext<MotoresV2DbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("MotoresV2"),
|
||||
sqlOptions => sqlOptions.EnableRetryOnFailure()));
|
||||
|
||||
// SERVICIOS
|
||||
builder.Services.AddScoped<IAvisosLegacyService, AvisosLegacyService>();
|
||||
builder.Services.AddScoped<IOperacionesLegacyService, OperacionesLegacyService>();
|
||||
builder.Services.AddScoped<IUsuariosLegacyService, UsuariosLegacyService>();
|
||||
builder.Services.AddScoped<IPasswordService, PasswordService>();
|
||||
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||
builder.Services.AddScoped<ILegacyPaymentService, LegacyPaymentService>();
|
||||
builder.Services.AddScoped<IPaymentService, MercadoPagoService>();
|
||||
builder.Services.AddScoped<IAdSyncService, AdSyncService>();
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<IAdSyncService, AdSyncService>();
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
builder.Services.AddScoped<INotificationPreferenceService, NotificationPreferenceService>();
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("SmtpSettings"));
|
||||
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
|
||||
builder.Services.AddScoped<IImageStorageService, ImageStorageService>();
|
||||
builder.Services.AddHostedService<AdExpirationService>();
|
||||
builder.Services.AddHostedService<TokenCleanupService>();
|
||||
builder.Services.AddHostedService<SitemapGeneratorService>();
|
||||
|
||||
// 🔒 JWT AUTH
|
||||
var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key Missing");
|
||||
@@ -165,14 +164,16 @@ app.UseMiddleware<MotoresArgentinosV2.API.Middleware.ExceptionHandlingMiddleware
|
||||
// USAR EL MIDDLEWARE DE HEADERS
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
// 🔒 HEADERS DE SEGURIDAD MIDDLEWARE
|
||||
// 🔒 HEADERS DE SEGURIDAD & PNA FIX MIDDLEWARE
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
// --- 1. SEGURIDAD EXISTENTE (HARDENING) ---
|
||||
context.Response.Headers.Append("X-Frame-Options", "DENY");
|
||||
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
||||
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("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()");
|
||||
|
||||
string csp = "default-src 'self'; " +
|
||||
"img-src 'self' data: https: blob:; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
@@ -180,11 +181,35 @@ app.Use(async (context, next) =>
|
||||
"connect-src 'self' https: ws: wss:; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self' https://developers-ventasonline.payway.com.ar; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none';";
|
||||
context.Response.Headers.Append("Content-Security-Policy", csp);
|
||||
|
||||
context.Response.Headers.Remove("Server");
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -203,6 +228,16 @@ app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
|
||||
// 🔒 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.UseRateLimiter();
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ public class CreateAdRequestDto
|
||||
public string Currency { get; set; } = "ARS";
|
||||
|
||||
[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")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
@@ -95,6 +95,15 @@ public class CreateAdRequestDto
|
||||
[JsonPropertyName("displayContactInfo")]
|
||||
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 ---
|
||||
|
||||
[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
|
||||
}
|
||||
|
||||
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 int UserID { get; set; }
|
||||
@@ -125,6 +146,11 @@ public class Ad
|
||||
public string? ContactPhone { get; set; }
|
||||
public string? ContactEmail { get; set; }
|
||||
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 int StatusID { get; set; }
|
||||
@@ -216,3 +242,60 @@ public class AdViewLog
|
||||
public string IPAddress { get; set; } = string.Empty;
|
||||
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
|
||||
{
|
||||
Task SendChatNotificationEmailAsync(string toEmail, string fromUser, string message, int adId);
|
||||
Task SendAdStatusChangedEmailAsync(string toEmail, string adTitle, string status, string? reason = null);
|
||||
// Categoría: "mensajes"
|
||||
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 SendExpirationWarningEmailAsync(string toEmail, string userName, string adTitle, DateTime expirationDate);
|
||||
Task SendAdExpiredEmailAsync(string toEmail, string userName, string adTitle);
|
||||
Task SendWeeklyPerformanceEmailAsync(string toEmail, string userName, string adTitle, int views, int favorites);
|
||||
Task SendPaymentReminderEmailAsync(string toEmail, string userName, string adTitle, string link);
|
||||
|
||||
// Categoría: "sistema"
|
||||
Task SendExpirationWarningEmailAsync(string toEmail, string userName, string adTitle, DateTime expirationDate, string? unsubscribeUrl = null);
|
||||
|
||||
// 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 SendUnreadMessagesReminderEmailAsync(string toEmail, string userName, int unreadCount);
|
||||
|
||||
// Categoría: "mensajes"
|
||||
Task SendUnreadMessagesReminderEmailAsync(string toEmail, string userName, int unreadCount, string? unsubscribeUrl = null);
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
namespace MotoresArgentinosV2.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interfaz para servicios que interactúan con stored procedures legacy
|
||||
/// relacionados con operaciones de pago
|
||||
/// </summary>
|
||||
public interface IOperacionesLegacyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Ejecuta el SP sp_inserta_operaciones para registrar una nueva operación
|
||||
/// </summary>
|
||||
/// <param name="operacion">Datos de la operación a registrar</param>
|
||||
/// <returns>True si se insertó correctamente</returns>
|
||||
Task<bool> InsertarOperacionAsync(Operacion operacion);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene operaciones por número de operación
|
||||
/// </summary>
|
||||
/// <param name="noperacion">Número de operación a buscar</param>
|
||||
/// <returns>Lista de operaciones encontradas</returns>
|
||||
Task<List<Operacion>> ObtenerOperacionesPorNumeroAsync(string noperacion);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene operaciones en un rango de fechas
|
||||
/// </summary>
|
||||
/// <param name="fechaInicio">Fecha inicial</param>
|
||||
/// <param name="fechaFin">Fecha final</param>
|
||||
/// <returns>Lista de operaciones en el rango</returns>
|
||||
Task<List<Operacion>> ObtenerOperacionesPorFechasAsync(DateTime fechaInicio, DateTime fechaFin);
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene todos los medios de pago disponibles
|
||||
/// </summary>
|
||||
/// <returns>Lista de medios de pago</returns>
|
||||
Task<List<MedioDePago>> ObtenerMediosDePagoAsync();
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MotoresArgentinosV2.Core.DTOs;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Contexto de Entity Framework unificado para la base de datos 'eldia' (Legacy)
|
||||
/// Contiene las tablas de avisos web, operaciones, medios de pago y lógica de usuarios legacy.
|
||||
/// </summary>
|
||||
public class EldiaDbContext : DbContext
|
||||
{
|
||||
public EldiaDbContext(DbContextOptions<EldiaDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
// Tablas de la base 'autos' (ahora en eldia)
|
||||
public DbSet<Operacion> Operaciones { get; set; }
|
||||
public DbSet<MedioDePago> MediosDePago { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// --- Configuración de tablas ex-Autos ---
|
||||
|
||||
modelBuilder.Entity<Operacion>(entity =>
|
||||
{
|
||||
entity.ToTable("operaciones");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Fecha).HasColumnName("fecha");
|
||||
entity.Property(e => e.Motivo).HasColumnName("motivo").HasMaxLength(50);
|
||||
entity.Property(e => e.Moneda).HasColumnName("moneda").HasMaxLength(50);
|
||||
entity.Property(e => e.Direccionentrega).HasColumnName("direccionentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Validaciondomicilio).HasColumnName("validaciondomicilio").HasMaxLength(50);
|
||||
entity.Property(e => e.Codigopedido).HasColumnName("codigopedido").HasMaxLength(50);
|
||||
entity.Property(e => e.Nombreentrega).HasColumnName("nombreentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Fechahora).HasColumnName("fechahora").HasMaxLength(50);
|
||||
entity.Property(e => e.Telefonocomprador).HasColumnName("telefonocomprador").HasMaxLength(50);
|
||||
entity.Property(e => e.Barrioentrega).HasColumnName("barrioentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Codautorizacion).HasColumnName("codautorizacion").HasMaxLength(50);
|
||||
entity.Property(e => e.Paisentrega).HasColumnName("paisentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Cuotas).HasColumnName("cuotas").HasMaxLength(50);
|
||||
entity.Property(e => e.Validafechanac).HasColumnName("validafechanac").HasMaxLength(50);
|
||||
entity.Property(e => e.Validanrodoc).HasColumnName("validanrodoc").HasMaxLength(50);
|
||||
entity.Property(e => e.Titular).HasColumnName("titular").HasMaxLength(50);
|
||||
entity.Property(e => e.Pedido).HasColumnName("pedido").HasMaxLength(50);
|
||||
entity.Property(e => e.Zipentrega).HasColumnName("zipentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Monto).HasColumnName("monto").HasMaxLength(50);
|
||||
entity.Property(e => e.Tarjeta).HasColumnName("tarjeta").HasMaxLength(50);
|
||||
entity.Property(e => e.Fechaentrega).HasColumnName("fechaentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Emailcomprador).HasColumnName("emailcomprador").HasMaxLength(50);
|
||||
entity.Property(e => e.Validanropuerta).HasColumnName("validanropuerta").HasMaxLength(50);
|
||||
entity.Property(e => e.Ciudadentrega).HasColumnName("ciudadentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Validatipodoc).HasColumnName("validatipodoc").HasMaxLength(50);
|
||||
entity.Property(e => e.Noperacion).HasColumnName("noperacion").HasMaxLength(50);
|
||||
entity.Property(e => e.Estadoentrega).HasColumnName("estadoentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Resultado).HasColumnName("resultado").HasMaxLength(50);
|
||||
entity.Property(e => e.Mensajeentrega).HasColumnName("mensajeentrega").HasMaxLength(50);
|
||||
entity.Property(e => e.Precioneto).HasColumnName("precioneto");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MedioDePago>(entity =>
|
||||
{
|
||||
entity.ToTable("mediodepago");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Mediodepago).HasColumnName("mediodepago").HasMaxLength(20);
|
||||
});
|
||||
|
||||
// --- Configuración de DTOs Keyless de ex-Internet ---
|
||||
|
||||
modelBuilder.Entity<DatosAvisoDto>(e =>
|
||||
{
|
||||
e.HasNoKey();
|
||||
e.ToView(null);
|
||||
|
||||
e.Property(p => p.ImporteSiniva).HasColumnType("decimal(18,2)");
|
||||
e.Property(p => p.ImporteTotsiniva).HasColumnType("decimal(18,2)");
|
||||
e.Property(p => p.PorcentajeCombinado).HasColumnType("decimal(18,2)");
|
||||
e.Property(p => p.Centimetros).HasColumnType("decimal(18,2)");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ public class MotoresV2DbContext : DbContext
|
||||
public DbSet<PaymentMethod> PaymentMethods { get; set; }
|
||||
public DbSet<RefreshToken> RefreshTokens { 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)
|
||||
{
|
||||
@@ -67,8 +69,31 @@ public class MotoresV2DbContext : DbContext
|
||||
modelBuilder.Entity<AdFeature>().ToTable("AdFeatures");
|
||||
modelBuilder.Entity<TransactionRecord>().ToTable("Transactions");
|
||||
|
||||
// Configuración de AdViewLog
|
||||
modelBuilder.Entity<AdViewLog>().ToTable("AdViewLogs");
|
||||
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 CleanupOldRefreshTokensAsync();
|
||||
await CleanupAdViewLogsAsync();
|
||||
await CleanupUnsubscribeTokensAsync();
|
||||
|
||||
// 3. Marketing y Retención
|
||||
await ProcessWeeklyStatsAsync();
|
||||
@@ -80,12 +81,36 @@ public class AdExpirationService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupUnsubscribeTokensAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
|
||||
|
||||
// Borramos tokens que ya expiraron o que ya fueron usados
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var deletedCount = await context.UnsubscribeTokens
|
||||
.Where(t => t.ExpiresAt <= now || t.IsUsed)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (deletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Mantenimiento: Se eliminaron {Count} tokens de baja expirados o usados.", deletedCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckExpiredAdsAsync()
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -97,8 +122,8 @@ public class AdExpirationService : BackgroundService
|
||||
a.StatusID == (int)AdStatusEnum.Active &&
|
||||
// Regla 2: Publicado hace más de 30 días
|
||||
a.PublishedAt.HasValue && a.PublishedAt.Value < cutoffDate &&
|
||||
// --- CAMBIO AQUÍ: Excluimos avisos de administradores ---
|
||||
a.User != null && a.User.UserType != 3
|
||||
// Aplica a todos los usuarios, incluyendo administradores
|
||||
a.User != null
|
||||
)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -106,10 +131,17 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
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}";
|
||||
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
|
||||
@@ -118,7 +150,7 @@ public class AdExpirationService : BackgroundService
|
||||
Entity = "Ad",
|
||||
EntityID = ad.AdID,
|
||||
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();
|
||||
@@ -131,6 +163,9 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
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 warningThreshold = DateTime.UtcNow.AddDays(-25);
|
||||
|
||||
@@ -141,8 +176,8 @@ public class AdExpirationService : BackgroundService
|
||||
a.StatusID == (int)AdStatusEnum.Active &&
|
||||
a.PublishedAt.HasValue && a.PublishedAt.Value <= warningThreshold &&
|
||||
!a.ExpirationWarningSent &&
|
||||
// --- CAMBIO AQUÍ: Excluimos avisos de administradores ---
|
||||
a.User != null && a.User.UserType != 3
|
||||
// Aplica a todos los usuarios, incluyendo administradores
|
||||
a.User != null
|
||||
)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -155,7 +190,17 @@ public class AdExpirationService : BackgroundService
|
||||
|
||||
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;
|
||||
}
|
||||
catch { /* Log error pero continuar */ }
|
||||
@@ -171,6 +216,9 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
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 sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
|
||||
|
||||
@@ -190,15 +238,28 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
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 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(
|
||||
ad.User.Email,
|
||||
ad.User.FirstName ?? "Usuario",
|
||||
title,
|
||||
ad.ViewsCounter,
|
||||
favCount
|
||||
favCount,
|
||||
unsubscribeUrl
|
||||
);
|
||||
|
||||
ad.LastPerformanceEmailSentAt = DateTime.UtcNow;
|
||||
@@ -214,9 +275,9 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
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"] ?? "http://localhost:5173";
|
||||
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddHours(-24);
|
||||
|
||||
@@ -236,10 +297,19 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
||||
|
||||
// Respetamos la preferencia de la categoría "marketing" (carrito abandonado)
|
||||
if (await prefService.IsEnabledAsync(ad.User.UserID, NotificationCategory.Marketing))
|
||||
{
|
||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||
var 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;
|
||||
}
|
||||
@@ -254,6 +324,9 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
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";
|
||||
|
||||
// Buscar usuarios que tengan mensajes no leídos viejos (> 4 horas)
|
||||
// y que no hayan sido notificados en las últimas 24 horas.
|
||||
@@ -282,7 +355,16 @@ public class AdExpirationService : BackgroundService
|
||||
// Contar total no leídos
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ public class AvisosLegacyService : IAvisosLegacyService
|
||||
{
|
||||
new SqlParameter("@tipo", dto.Tipo),
|
||||
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("@nro_doc", dto.NroDoc),
|
||||
new SqlParameter("@razon", dto.Razon),
|
||||
@@ -166,7 +166,7 @@ public class AvisosLegacyService : IAvisosLegacyService
|
||||
new SqlParameter("@codigopostal", dto.CodigoPostal),
|
||||
new SqlParameter("@telefono", dto.Telefono),
|
||||
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_iva2", dto.PorcentajeIva2),
|
||||
new SqlParameter("@porcentaje_percepcion", dto.PorcentajePercepcion),
|
||||
|
||||
@@ -80,7 +80,7 @@ public class IdentityService : IIdentityService
|
||||
await _v2Context.SaveChangesAsync();
|
||||
|
||||
// 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 emailBody = $@"
|
||||
@@ -187,7 +187,7 @@ public class IdentityService : IIdentityService
|
||||
await _v2Context.SaveChangesAsync();
|
||||
|
||||
// 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 emailBody = $@"
|
||||
@@ -241,7 +241,7 @@ public class IdentityService : IIdentityService
|
||||
|
||||
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 emailBody = $@"
|
||||
@@ -279,6 +279,8 @@ public class IdentityService : IIdentityService
|
||||
user.PasswordResetTokenExpiresAt = null;
|
||||
user.PasswordSalt = null;
|
||||
user.MigrationStatus = 1;
|
||||
user.IsEmailVerified = true;
|
||||
user.VerificationToken = null;
|
||||
|
||||
await _v2Context.SaveChangesAsync();
|
||||
return (true, "Tu contraseña ha sido actualizada correctamente.");
|
||||
@@ -371,7 +373,7 @@ public class IdentityService : IIdentityService
|
||||
await _v2Context.SaveChangesAsync();
|
||||
|
||||
// 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 body = $@"
|
||||
|
||||
@@ -42,10 +42,10 @@ public class ImageStorageService : IImageStorageService
|
||||
throw new Exception("Formato de archivo no permitido. Solo JPG, PNG y WEBP.");
|
||||
}
|
||||
|
||||
// 2. Validación de Tamaño (Max 3MB)
|
||||
if (file.Length > 3 * 1024 * 1024)
|
||||
// 2. Validación de Tamaño (Max 10MB)
|
||||
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)
|
||||
|
||||
@@ -98,7 +98,7 @@ public class LegacyPaymentService : ILegacyPaymentService
|
||||
var p = new DynamicParameters();
|
||||
p.Add("@tipo", ad.VehicleTypeID == 1 ? "A" : "M");
|
||||
p.Add("@nro_operacion", tx.TransactionID);
|
||||
p.Add("@id_cliente", ad.UserID);
|
||||
p.Add("@id_cliente", 0);
|
||||
p.Add("@tipodoc", 96);
|
||||
p.Add("@nro_doc", "0");
|
||||
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("@telefono", ad.ContactPhone ?? "");
|
||||
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_iva2", 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;
|
||||
_logger = logger;
|
||||
// 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 $@"
|
||||
<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);'>
|
||||
@@ -34,12 +54,20 @@ public class NotificationService : INotificationService
|
||||
</div>
|
||||
<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>
|
||||
{unsubscribeBlock}
|
||||
</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 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>
|
||||
</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 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>")}
|
||||
<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)
|
||||
{
|
||||
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 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));
|
||||
}
|
||||
|
||||
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 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>
|
||||
</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 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>
|
||||
</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";
|
||||
|
||||
@@ -158,10 +210,15 @@ public class NotificationService : INotificationService
|
||||
<![endif]-->
|
||||
</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 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>
|
||||
</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 content = $@"
|
||||
@@ -191,10 +252,16 @@ public class NotificationService : INotificationService
|
||||
</div>
|
||||
<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));
|
||||
}
|
||||
|
||||
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 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>
|
||||
</div>";
|
||||
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Mensajes Pendientes", content));
|
||||
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Mensajes Pendientes", content, unsubscribeUrl));
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MotoresArgentinosV2.Core.Entities;
|
||||
using MotoresArgentinosV2.Core.Interfaces;
|
||||
using MotoresArgentinosV2.Infrastructure.Data;
|
||||
|
||||
namespace MotoresArgentinosV2.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementación del servicio para interactuar con datos legacy de operaciones
|
||||
/// Utiliza EldiaDbContext para acceder a tablas y ejecutar SPs de la DB 'eldia' (ex base de datos 'autos')
|
||||
/// </summary>
|
||||
public class OperacionesLegacyService : IOperacionesLegacyService
|
||||
{
|
||||
private readonly EldiaDbContext _context;
|
||||
private readonly ILogger<OperacionesLegacyService> _logger;
|
||||
|
||||
public OperacionesLegacyService(EldiaDbContext context, ILogger<OperacionesLegacyService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ejecuta el SP sp_inserta_operaciones para registrar un pago
|
||||
/// </summary>
|
||||
public async Task<bool> InsertarOperacionAsync(Operacion operacion)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Ejecutando sp_inserta_operaciones para operación: {Noperacion}", operacion.Noperacion);
|
||||
|
||||
// Preparar parámetros asegurando manejo de nulos
|
||||
var parameters = new[]
|
||||
{
|
||||
new SqlParameter("@fecha", operacion.Fecha ?? (object)DBNull.Value),
|
||||
new SqlParameter("@Motivo", operacion.Motivo ?? (object)DBNull.Value),
|
||||
new SqlParameter("@Moneda", operacion.Moneda ?? (object)DBNull.Value),
|
||||
new SqlParameter("@Direccionentrega", operacion.Direccionentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@Validaciondomicilio", operacion.Validaciondomicilio ?? (object)DBNull.Value),
|
||||
new SqlParameter("@codigopedido", operacion.Codigopedido ?? (object)DBNull.Value),
|
||||
new SqlParameter("@nombreentrega", operacion.Nombreentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@fechahora", operacion.Fechahora ?? (object)DBNull.Value),
|
||||
new SqlParameter("@telefonocomprador", operacion.Telefonocomprador ?? (object)DBNull.Value),
|
||||
new SqlParameter("@barrioentrega", operacion.Barrioentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@codautorizacion", operacion.Codautorizacion ?? (object)DBNull.Value),
|
||||
new SqlParameter("@paisentrega", operacion.Paisentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@cuotas", operacion.Cuotas ?? (object)DBNull.Value),
|
||||
new SqlParameter("@validafechanac", operacion.Validafechanac ?? (object)DBNull.Value),
|
||||
new SqlParameter("@validanrodoc", operacion.Validanrodoc ?? (object)DBNull.Value),
|
||||
new SqlParameter("@titular", operacion.Titular ?? (object)DBNull.Value),
|
||||
new SqlParameter("@pedido", operacion.Pedido ?? (object)DBNull.Value),
|
||||
new SqlParameter("@zipentrega", operacion.Zipentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@monto", operacion.Monto ?? (object)DBNull.Value),
|
||||
new SqlParameter("@tarjeta", operacion.Tarjeta ?? (object)DBNull.Value),
|
||||
new SqlParameter("@fechaentrega", operacion.Fechaentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@emailcomprador", operacion.Emailcomprador ?? (object)DBNull.Value),
|
||||
new SqlParameter("@validanropuerta", operacion.Validanropuerta ?? (object)DBNull.Value),
|
||||
new SqlParameter("@ciudadentrega", operacion.Ciudadentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@validatipodoc", operacion.Validatipodoc ?? (object)DBNull.Value),
|
||||
new SqlParameter("@noperacion", operacion.Noperacion ?? (object)DBNull.Value),
|
||||
new SqlParameter("@estadoentrega", operacion.Estadoentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@resultado", operacion.Resultado ?? (object)DBNull.Value),
|
||||
new SqlParameter("@mensajeentrega", operacion.Mensajeentrega ?? (object)DBNull.Value),
|
||||
new SqlParameter("@precio", operacion.Precioneto ?? 0) // El SP espera int
|
||||
};
|
||||
|
||||
await _context.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_inserta_operaciones @fecha, @Motivo, @Moneda, @Direccionentrega, " +
|
||||
"@Validaciondomicilio, @codigopedido, @nombreentrega, @fechahora, @telefonocomprador, " +
|
||||
"@barrioentrega, @codautorizacion, @paisentrega, @cuotas, @validafechanac, @validanrodoc, " +
|
||||
"@titular, @pedido, @zipentrega, @monto, @tarjeta, @fechaentrega, @emailcomprador, " +
|
||||
"@validanropuerta, @ciudadentrega, @validatipodoc, @noperacion, @estadoentrega, " +
|
||||
"@resultado, @mensajeentrega, @precio",
|
||||
parameters);
|
||||
|
||||
_logger.LogInformation("Operación registrada correctamente: {Noperacion}", operacion.Noperacion);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al insertar operación: {Noperacion}", operacion.Noperacion);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Operacion>> ObtenerOperacionesPorNumeroAsync(string noperacion)
|
||||
{
|
||||
return await _context.Operaciones
|
||||
.AsNoTracking()
|
||||
.Where(o => o.Noperacion == noperacion)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Operacion>> ObtenerOperacionesPorFechasAsync(DateTime fechaInicio, DateTime fechaFin)
|
||||
{
|
||||
return await _context.Operaciones
|
||||
.AsNoTracking()
|
||||
.Where(o => o.Fecha >= fechaInicio && o.Fecha <= fechaFin)
|
||||
.OrderByDescending(o => o.Fecha)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<MedioDePago>> ObtenerMediosDePagoAsync()
|
||||
{
|
||||
return await _context.MediosDePago
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -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.Role, user.UserType == 3 ? "Admin" : "User")
|
||||
}),
|
||||
Expires = DateTime.UtcNow.AddMinutes(15),
|
||||
Expires = DateTime.UtcNow.AddMinutes(60),
|
||||
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
|
||||
Issuer = _config["Jwt:Issuer"],
|
||||
Audience = _config["Jwt:Audience"]
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XTD4SD01DV"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-XTD4SD01DV');
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -5,6 +5,11 @@ server {
|
||||
# Seguridad: Limitar tamaño de subida para prevenir DoS
|
||||
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 / {
|
||||
root /usr/share/nginx/html;
|
||||
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 { AuthProvider, useAuth } from './context/AuthContext';
|
||||
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 }) {
|
||||
const { user, loading } = useAuth();
|
||||
@@ -239,7 +243,7 @@ function Navbar() {
|
||||
)}
|
||||
|
||||
{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">
|
||||
<LoginModal
|
||||
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>Registro DNDA Nº: RL-2024-70042723-APN-DNDA#MJ - Propietario: Publiéxito S.A.</p>
|
||||
<p>Director: Leonardo Mario Forclaz - 46 N 423 - La Plata - Pcia. de Bs. As.</p>
|
||||
<div 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>
|
||||
</footer>
|
||||
@@ -307,6 +316,10 @@ function MainLayout() {
|
||||
<Route path="/perfil" element={<PerfilPage />} />
|
||||
<Route path="/seguridad" element={<SeguridadPage />} />
|
||||
<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={
|
||||
<AdminGuard>
|
||||
<AdminPage />
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function ChatModal({ isOpen, onClose, adId, adTitle, sellerId, cu
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center p-4 md:p-6 bg-black/80 backdrop-blur-sm animate-fade-in">
|
||||
<div className="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">
|
||||
|
||||
{/* 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">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-xl"
|
||||
onClick={onCancel}
|
||||
></div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,7 @@ export default function PremiumGallery({
|
||||
|
||||
{/* MINIATURAS (THUMBNAILS) */}
|
||||
{photos.length > 1 && (
|
||||
<div className="flex gap-3 md:gap-4 overflow-x-auto pt-4 pb-4 px-1 scrollbar-hide no-scrollbar items-center justify-center">
|
||||
<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) => (
|
||||
<button
|
||||
key={idx}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const STATUS_CONFIG: Record<number, { label: string; color: string; bg: s
|
||||
icon: '📝'
|
||||
},
|
||||
[AD_STATUSES.DELETED]: {
|
||||
label: 'Eliminar',
|
||||
label: 'Eliminado',
|
||||
color: 'text-white',
|
||||
bg: 'bg-red-700/90',
|
||||
border: 'border-red-500/50',
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { AuthService, type UserSession } from '../services/auth.service';
|
||||
import { ChatService } from '../services/chat.service';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { AuthService, type UserSession } from "../services/auth.service";
|
||||
import { ChatService } from "../services/chat.service";
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserSession | null;
|
||||
@@ -19,7 +26,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
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();
|
||||
if (currentUser) {
|
||||
try {
|
||||
@@ -29,64 +48,105 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUnreadCount(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Verificar sesión al cargar la app (Solo una vez)
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
// 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(); // <--- 5. LLAMAR AL CARGAR LA APP
|
||||
} else {
|
||||
setUser(null);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
} catch (error) {
|
||||
setUser(null);
|
||||
setUnreadCount(0);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
}, [user, logout, fetchUnreadCount]);
|
||||
|
||||
const login = (userData: UserSession) => {
|
||||
setUser(userData);
|
||||
localStorage.setItem('userProfile', JSON.stringify(userData));
|
||||
localStorage.setItem("userProfile", JSON.stringify(userData));
|
||||
fetchUnreadCount();
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
AuthService.logout();
|
||||
setUser(null);
|
||||
setUnreadCount(0);
|
||||
localStorage.removeItem('userProfile');
|
||||
const refreshSession = async () => {
|
||||
await verifySession();
|
||||
};
|
||||
|
||||
const refreshSession = async () => {
|
||||
const sessionUser = await AuthService.checkSession();
|
||||
setUser(sessionUser);
|
||||
if (sessionUser) {
|
||||
await fetchUnreadCount();
|
||||
// 1. Carga Inicial
|
||||
useEffect(() => {
|
||||
verifySession();
|
||||
}, [verifySession]);
|
||||
|
||||
// 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 (
|
||||
<AuthContext.Provider value={{ user, loading, unreadCount, login, logout, refreshSession, fetchUnreadCount }}>
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
unreadCount,
|
||||
login,
|
||||
logout,
|
||||
refreshSession,
|
||||
fetchUnreadCount,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook personalizado para usar el contexto fácilmente
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { AdminService } from '../services/admin.service';
|
||||
import { AdsV2Service } from '../services/ads.v2.service';
|
||||
import ModerationModal from '../components/ModerationModal';
|
||||
import UserModal from '../components/UserModal';
|
||||
import { parseUTCDate, getImageUrl } from '../utils/app.utils';
|
||||
import { STATUS_CONFIG } from '../constants/adStatuses';
|
||||
import { STATUS_CONFIG, AD_STATUSES } from '../constants/adStatuses';
|
||||
import AdDetailsModal from '../components/AdDetailsModal';
|
||||
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() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('stats');
|
||||
@@ -21,6 +23,69 @@ export default function AdminPage() {
|
||||
const [selectedAd, setSelectedAd] = useState<any>(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
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
const [userPage, setUserPage] = useState(1);
|
||||
@@ -114,6 +179,13 @@ export default function AdminPage() {
|
||||
page: adsFilters.page
|
||||
});
|
||||
break;
|
||||
case 'trash':
|
||||
res = await AdminService.getAllAds({
|
||||
q: adsFilters.q,
|
||||
statusId: 9, // Forzamos 9 para papelera
|
||||
page: adsFilters.page
|
||||
});
|
||||
break;
|
||||
}
|
||||
setData(res);
|
||||
} 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'}`}
|
||||
>
|
||||
<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 className={`transition-transform duration-300 ${isMobileMenuOpen ? 'rotate-180 text-blue-400' : 'text-gray-500'}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{isMobileMenuOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-[#12141a] border border-white/10 rounded-2xl overflow-hidden z-[100] shadow-2xl animate-scale-up">
|
||||
{(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit'] as TabType[]).map(tab => (
|
||||
{(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit', 'trash'] as TabType[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => handleTabChange(tab)}
|
||||
className={`w-full text-left px-6 py-4 text-[10px] font-black uppercase tracking-widest transition-all border-b border-white/5 last:border-0 ${activeTab === tab ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-white/5'}`}
|
||||
>
|
||||
{tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : '📋 Auditoría'}
|
||||
{tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : tab === 'audit' ? '📋 Auditoría' : '🗑️ Papelera'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -179,13 +251,13 @@ export default function AdminPage() {
|
||||
|
||||
{/* Menú tradicional para Escritorio */}
|
||||
<div className="hidden md:flex bg-white/5 p-1.5 rounded-2xl border border-white/5 backdrop-blur-xl">
|
||||
{(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit'] as TabType[]).map(tab => (
|
||||
{(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit', 'trash'] as TabType[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => handleTabChange(tab)}
|
||||
className={`px-5 md:px-6 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === tab ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20' : 'text-gray-500 hover:text-white'}`}
|
||||
>
|
||||
{tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : '📋 Auditoría'}
|
||||
{tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : tab === 'audit' ? '📋 Auditoría' : '🗑️ Papelera'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -276,8 +348,10 @@ export default function AdminPage() {
|
||||
onChange={e => setAdsFilters({ ...adsFilters, statusId: e.target.value })}
|
||||
className="w-full h-full bg-white/5 border border-white/10 rounded-xl md:rounded-2xl px-4 py-3 md:py-0 text-sm text-white outline-none focus:border-blue-500 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-gray-900">Todos los Estados</option>
|
||||
{Object.entries(STATUS_CONFIG).map(([id, config]) => (
|
||||
<option value="" className="bg-gray-900">Activos y Otros</option>
|
||||
{Object.entries(STATUS_CONFIG)
|
||||
.filter(([id]) => id !== "9") // Excluimos eliminados de la lista de Avisos
|
||||
.map(([id, config]) => (
|
||||
<option key={id} value={id} className="bg-gray-900">{config.label}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -292,7 +366,7 @@ export default function AdminPage() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<thead className="bg-white/5">
|
||||
<tr>
|
||||
@@ -303,10 +377,9 @@ export default function AdminPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{data.ads.map((ad: any) => {
|
||||
const statusConfig = STATUS_CONFIG[ad.statusID] || { label: 'Desc.', bg: 'bg-gray-500', color: 'text-white' };
|
||||
{data.ads.map((ad: any, index: number) => {
|
||||
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">
|
||||
<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="" />
|
||||
@@ -330,9 +403,12 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<span className={`px-3 py-1.5 rounded-lg text-[9px] font-black uppercase tracking-tighter border ${statusConfig.bg} ${statusConfig.color} ${statusConfig.border}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<div className="w-40 relative">
|
||||
<StatusDropdown
|
||||
currentStatus={ad.statusID}
|
||||
onChange={(newStatus) => initiateStatusChange(ad.adID, newStatus)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 text-right">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
@@ -367,11 +443,10 @@ export default function AdminPage() {
|
||||
|
||||
{/* Lista de Avisos (Móvil / Cards) */}
|
||||
<div className="md:hidden space-y-4">
|
||||
{data.ads.map((ad: any) => {
|
||||
const statusConfig = STATUS_CONFIG[ad.statusID] || { label: 'Desc.', bg: 'bg-gray-500', color: 'text-white' };
|
||||
{data.ads.map((ad: any, index: number) => {
|
||||
return (
|
||||
<div key={ad.adID} className="glass p-5 rounded-3xl border border-white/5 space-y-4 shadow-xl">
|
||||
<div className="flex gap-4">
|
||||
<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 items-start">
|
||||
<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 flex-wrap items-center gap-2 mb-1">
|
||||
@@ -382,9 +457,12 @@ export default function AdminPage() {
|
||||
</span>
|
||||
)}
|
||||
</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}`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<div className="mt-2 w-full max-w-[200px]">
|
||||
<StatusDropdown
|
||||
currentStatus={ad.statusID}
|
||||
onChange={(newStatus) => initiateStatusChange(ad.adID, newStatus)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -457,6 +535,152 @@ export default function AdminPage() {
|
||||
</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 */}
|
||||
{activeTab === 'moderation' && Array.isArray(data) && (
|
||||
<div className="space-y-6">
|
||||
@@ -965,6 +1189,21 @@ export default function AdminPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -987,3 +1226,86 @@ function DashboardMiniCard({ label, value, icon, color = 'blue' }: { label: stri
|
||||
</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 { useSearchParams, Link } from 'react-router-dom';
|
||||
import { AdsV2Service, type AdListingDto } from '../services/ads.v2.service';
|
||||
import { getImageUrl, formatCurrency } from '../utils/app.utils';
|
||||
import SearchableSelect from '../components/SearchableSelect';
|
||||
import AdStatusBadge from '../components/AdStatusBadge';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams, Link } from "react-router-dom";
|
||||
import { AdsV2Service, type AdListingDto } from "../services/ads.v2.service";
|
||||
import { getImageUrl, formatCurrency } from "../utils/app.utils";
|
||||
import SearchableSelect from "../components/SearchableSelect";
|
||||
import AdStatusBadge from "../components/AdStatusBadge";
|
||||
import {
|
||||
AUTO_SEGMENTS,
|
||||
MOTO_SEGMENTS,
|
||||
AUTO_TRANSMISSIONS,
|
||||
MOTO_TRANSMISSIONS,
|
||||
FUEL_TYPES,
|
||||
VEHICLE_CONDITIONS
|
||||
} from '../constants/vehicleOptions';
|
||||
VEHICLE_CONDITIONS,
|
||||
} from "../constants/vehicleOptions";
|
||||
|
||||
export default function ExplorarPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -19,25 +19,29 @@ export default function ExplorarPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [minPrice, setMinPrice] = useState(searchParams.get('minPrice') || '');
|
||||
const [maxPrice, setMaxPrice] = useState(searchParams.get('maxPrice') || '');
|
||||
const [currencyFilter, setCurrencyFilter] = useState(searchParams.get('currency') || '');
|
||||
const [minYear, setMinYear] = useState(searchParams.get('minYear') || '');
|
||||
const [maxYear, setMaxYear] = useState(searchParams.get('maxYear') || '');
|
||||
const [brandId, setBrandId] = useState(searchParams.get('brandId') || '');
|
||||
const [modelId, setModelId] = useState(searchParams.get('modelId') || '');
|
||||
const [fuel, setFuel] = useState(searchParams.get('fuel') || '');
|
||||
const [transmission, setTransmission] = useState(searchParams.get('transmission') || '');
|
||||
const [minPrice, setMinPrice] = useState(searchParams.get("minPrice") || "");
|
||||
const [maxPrice, setMaxPrice] = useState(searchParams.get("maxPrice") || "");
|
||||
const [currencyFilter, setCurrencyFilter] = useState(
|
||||
searchParams.get("currency") || "",
|
||||
);
|
||||
const [minYear, setMinYear] = useState(searchParams.get("minYear") || "");
|
||||
const [maxYear, setMaxYear] = useState(searchParams.get("maxYear") || "");
|
||||
const [brandId, setBrandId] = useState(searchParams.get("brandId") || "");
|
||||
const [modelId, setModelId] = useState(searchParams.get("modelId") || "");
|
||||
const [fuel, setFuel] = useState(searchParams.get("fuel") || "");
|
||||
const [transmission, setTransmission] = useState(
|
||||
searchParams.get("transmission") || "",
|
||||
);
|
||||
|
||||
const [brands, setBrands] = useState<{ id: number, name: string }[]>([]);
|
||||
const [models, setModels] = useState<{ id: number, name: string }[]>([]);
|
||||
const [brands, setBrands] = useState<{ id: number; name: string }[]>([]);
|
||||
const [models, setModels] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
const q = searchParams.get('q') || '';
|
||||
const c = searchParams.get('c') || 'ALL';
|
||||
const q = searchParams.get("q") || "";
|
||||
const c = searchParams.get("c") || "ALL";
|
||||
|
||||
useEffect(() => {
|
||||
if (c !== 'ALL') {
|
||||
const typeId = c === 'EAUTOS' ? 1 : 2;
|
||||
if (c !== "ALL") {
|
||||
const typeId = c === "EAUTOS" ? 1 : 2;
|
||||
AdsV2Service.getBrands(typeId).then(setBrands);
|
||||
} else {
|
||||
setBrands([]);
|
||||
@@ -61,7 +65,7 @@ export default function ExplorarPage() {
|
||||
try {
|
||||
const data = await AdsV2Service.getAll({
|
||||
q,
|
||||
c: c === 'ALL' ? undefined : c,
|
||||
c: c === "ALL" ? undefined : c,
|
||||
minPrice: minPrice ? Number(minPrice) : undefined,
|
||||
maxPrice: maxPrice ? Number(maxPrice) : undefined,
|
||||
currency: currencyFilter || undefined,
|
||||
@@ -70,7 +74,13 @@ export default function ExplorarPage() {
|
||||
brandId: brandId ? Number(brandId) : undefined,
|
||||
modelId: modelId ? Number(modelId) : 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);
|
||||
} catch (err) {
|
||||
@@ -86,32 +96,47 @@ export default function ExplorarPage() {
|
||||
|
||||
const applyFilters = () => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (minPrice) newParams.set('minPrice', minPrice); else newParams.delete('minPrice');
|
||||
if (maxPrice) newParams.set('maxPrice', maxPrice); else newParams.delete('maxPrice');
|
||||
if (currencyFilter) newParams.set('currency', currencyFilter); else newParams.delete('currency');
|
||||
if (minYear) newParams.set('minYear', minYear); else newParams.delete('minYear');
|
||||
if (maxYear) newParams.set('maxYear', maxYear); else newParams.delete('maxYear');
|
||||
if (brandId) newParams.set('brandId', brandId); else newParams.delete('brandId');
|
||||
if (modelId) newParams.set('modelId', modelId); else newParams.delete('modelId');
|
||||
if (fuel) newParams.set('fuel', fuel); else newParams.delete('fuel');
|
||||
if (transmission) newParams.set('transmission', transmission); else newParams.delete('transmission');
|
||||
if (minPrice) newParams.set("minPrice", minPrice);
|
||||
else newParams.delete("minPrice");
|
||||
if (maxPrice) newParams.set("maxPrice", maxPrice);
|
||||
else newParams.delete("maxPrice");
|
||||
if (currencyFilter) newParams.set("currency", currencyFilter);
|
||||
else newParams.delete("currency");
|
||||
if (minYear) newParams.set("minYear", minYear);
|
||||
else newParams.delete("minYear");
|
||||
if (maxYear) newParams.set("maxYear", maxYear);
|
||||
else newParams.delete("maxYear");
|
||||
if (brandId) newParams.set("brandId", brandId);
|
||||
else newParams.delete("brandId");
|
||||
if (modelId) newParams.set("modelId", modelId);
|
||||
else newParams.delete("modelId");
|
||||
if (fuel) newParams.set("fuel", fuel);
|
||||
else newParams.delete("fuel");
|
||||
if (transmission) newParams.set("transmission", transmission);
|
||||
else newParams.delete("transmission");
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setMinPrice(''); setMaxPrice(''); setMinYear(''); setMaxYear('');
|
||||
setCurrencyFilter('');
|
||||
setBrandId(''); setModelId(''); setFuel(''); setTransmission('');
|
||||
setMinPrice("");
|
||||
setMaxPrice("");
|
||||
setMinYear("");
|
||||
setMaxYear("");
|
||||
setCurrencyFilter("");
|
||||
setBrandId("");
|
||||
setModelId("");
|
||||
setFuel("");
|
||||
setTransmission("");
|
||||
const newParams = new URLSearchParams();
|
||||
if (q) newParams.set('q', q);
|
||||
if (c !== 'ALL') newParams.set('c', c);
|
||||
if (q) newParams.set("q", q);
|
||||
if (c !== "ALL") newParams.set("c", c);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const handleCategoryFilter = (cat: string) => {
|
||||
const newParams = new URLSearchParams();
|
||||
if (q) newParams.set('q', q);
|
||||
if (cat !== 'ALL') newParams.set('c', cat);
|
||||
if (q) newParams.set("q", q);
|
||||
if (cat !== "ALL") newParams.set("c", cat);
|
||||
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">
|
||||
<button
|
||||
onClick={() => setShowMobileFilters(true)}
|
||||
className={`md:hidden fixed bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 z-[110] bg-blue-600 text-white px-6 md:px-8 py-3 md:py-4 rounded-xl md:rounded-2xl font-black uppercase tracking-widest shadow-2xl shadow-blue-600/40 border border-white/20 active:scale-95 transition-all flex items-center gap-2 md:gap-3 text-sm ${showMobileFilters ? 'opacity-0 pointer-events-none translate-y-20' : 'opacity-100 translate-y-0'}`}
|
||||
className={`md:hidden fixed bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 z-[110] bg-blue-600 text-white px-6 md:px-8 py-3 md:py-4 rounded-xl md:rounded-2xl font-black uppercase tracking-widest shadow-2xl shadow-blue-600/40 border border-white/20 active:scale-95 transition-all flex items-center gap-2 md:gap-3 text-sm ${showMobileFilters ? "opacity-0 pointer-events-none translate-y-20" : "opacity-100 translate-y-0"}`}
|
||||
>
|
||||
<span>🔍 FILTRAR</span>
|
||||
</button>
|
||||
|
||||
{/* Sidebar Filters - NATURAL FLOW (NO STICKY, NO SCROLL INTERNO) */}
|
||||
<aside className={`
|
||||
<aside
|
||||
className={`
|
||||
fixed inset-0 z-[105] bg-black/80 backdrop-blur-xl transition-all duration-500 overflow-y-auto md:overflow-visible
|
||||
md:relative md:inset-auto md:bg-transparent md:backdrop-blur-none md:z-0 md:w-80 md:flex flex-col md:flex-shrink-0
|
||||
${showMobileFilters ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none translate-y-10 md:opacity-100 md:pointer-events-auto md:translate-y-0'}
|
||||
`}>
|
||||
<div className="
|
||||
${showMobileFilters ? "opacity-100 pointer-events-auto translate-y-0" : "opacity-0 pointer-events-none translate-y-10 md:opacity-100 md:pointer-events-auto md:translate-y-0"}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className="
|
||||
glass p-6 rounded-[2rem] border border-white/5 shadow-2xl
|
||||
h-fit m-6 mt-28 md:m-0
|
||||
">
|
||||
"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6 border-b border-white/5 pb-4">
|
||||
<h3 className="text-xl font-black tracking-tighter uppercase">FILTROS</h3>
|
||||
<h3 className="text-xl font-black tracking-tighter uppercase">
|
||||
FILTROS
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
@@ -155,22 +186,32 @@ export default function ExplorarPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Categoría */}
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Tipo de Vehículo</label>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Tipo de Vehículo
|
||||
</label>
|
||||
<select
|
||||
value={c}
|
||||
onChange={(e) => handleCategoryFilter(e.target.value)}
|
||||
className="w-full bg-blue-600/10 border border-blue-500/30 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none font-bold uppercase tracking-wide cursor-pointer hover:bg-blue-600/20"
|
||||
>
|
||||
<option value="ALL" className="bg-gray-900">Todos</option>
|
||||
<option value="EAUTOS" className="bg-gray-900">Automóviles</option>
|
||||
<option value="EMOTOS" className="bg-gray-900">Motos</option>
|
||||
<option value="ALL" className="bg-gray-900">
|
||||
Todos
|
||||
</option>
|
||||
<option value="EAUTOS" className="bg-gray-900">
|
||||
Automóviles
|
||||
</option>
|
||||
<option value="EMOTOS" className="bg-gray-900">
|
||||
Motos
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{c !== 'ALL' && (
|
||||
{c !== "ALL" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Marca</label>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Marca
|
||||
</label>
|
||||
<SearchableSelect
|
||||
options={brands}
|
||||
value={brandId}
|
||||
@@ -181,15 +222,25 @@ export default function ExplorarPage() {
|
||||
|
||||
{brandId && (
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<option value="" className="bg-gray-900 text-gray-500">Todos los modelos</option>
|
||||
{models.map(m => (
|
||||
<option key={m.id} value={m.id} className="bg-gray-900 text-white">{m.name}</option>
|
||||
<option value="" className="bg-gray-900 text-gray-500">
|
||||
Todos los modelos
|
||||
</option>
|
||||
{models.map((m) => (
|
||||
<option
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
className="bg-gray-900 text-white"
|
||||
>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -198,85 +249,231 @@ export default function ExplorarPage() {
|
||||
)}
|
||||
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<option value="" className="bg-gray-900 text-gray-500">Indistinto</option>
|
||||
<option value="ARS" className="bg-gray-900 text-white">Pesos (ARS)</option>
|
||||
<option value="USD" className="bg-gray-900 text-white">Dólares (USD)</option>
|
||||
<option value="" className="bg-gray-900 text-gray-500">
|
||||
Indistinto
|
||||
</option>
|
||||
<option value="ARS" className="bg-gray-900 text-white">
|
||||
Pesos (ARS)
|
||||
</option>
|
||||
<option value="USD" className="bg-gray-900 text-white">
|
||||
Dólares (USD)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Precio Máximo</label>
|
||||
<input placeholder="Ej: 25000" type="number" value={maxPrice} onChange={e => setMaxPrice(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all" />
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Precio Máximo
|
||||
</label>
|
||||
<input
|
||||
placeholder="Ej: 25000"
|
||||
type="number"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Desde Año</label>
|
||||
<input placeholder="Ej: 2018" type="number" value={minYear} onChange={e => setMinYear(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500" />
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Desde Año
|
||||
</label>
|
||||
<input
|
||||
placeholder="Ej: 2018"
|
||||
type="number"
|
||||
value={minYear}
|
||||
onChange={(e) => setMinYear(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Combustible</label>
|
||||
<select value={fuel} onChange={e => setFuel(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none">
|
||||
<option value="" className="bg-gray-900 text-gray-500">Todos</option>
|
||||
{FUEL_TYPES.map(f => (<option key={f} value={f} className="bg-gray-900 text-white">{f}</option>))}
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Combustible
|
||||
</label>
|
||||
<select
|
||||
value={fuel}
|
||||
onChange={(e) => setFuel(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-900 text-gray-500">
|
||||
Todos
|
||||
</option>
|
||||
{FUEL_TYPES.map((f) => (
|
||||
<option
|
||||
key={f}
|
||||
value={f}
|
||||
className="bg-gray-900 text-white"
|
||||
>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Transmisión</label>
|
||||
<select value={transmission} onChange={e => setTransmission(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none">
|
||||
<option value="" className="bg-gray-900 text-gray-500">Todas</option>
|
||||
{(c === 'EMOTOS' ? MOTO_TRANSMISSIONS : AUTO_TRANSMISSIONS).map(t => (
|
||||
<option key={t} value={t} className="bg-gray-900 text-white">{t}</option>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Transmisión
|
||||
</label>
|
||||
<select
|
||||
value={transmission}
|
||||
onChange={(e) => setTransmission(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 transition-all appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-900 text-gray-500">
|
||||
Todas
|
||||
</option>
|
||||
{(c === "EMOTOS"
|
||||
? MOTO_TRANSMISSIONS
|
||||
: AUTO_TRANSMISSIONS
|
||||
).map((t) => (
|
||||
<option
|
||||
key={t}
|
||||
value={t}
|
||||
className="bg-gray-900 text-white"
|
||||
>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Color</label>
|
||||
<input placeholder="Ej: Blanco" type="text" value={searchParams.get('color') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('color', e.target.value); else p.delete('color'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500" />
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
placeholder="Ej: Blanco"
|
||||
type="text"
|
||||
value={searchParams.get("color") || ""}
|
||||
onChange={(e) => {
|
||||
const p = new URLSearchParams(searchParams);
|
||||
if (e.target.value) p.set("color", e.target.value);
|
||||
else p.delete("color");
|
||||
setSearchParams(p);
|
||||
}}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Ubicación</label>
|
||||
<input placeholder="Ej: Buenos Aires" type="text" value={searchParams.get('location') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('location', e.target.value); else p.delete('location'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500" />
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Ubicación
|
||||
</label>
|
||||
<input
|
||||
placeholder="Ej: Buenos Aires"
|
||||
type="text"
|
||||
value={searchParams.get("location") || ""}
|
||||
onChange={(e) => {
|
||||
const p = new URLSearchParams(searchParams);
|
||||
if (e.target.value) p.set("location", e.target.value);
|
||||
else p.delete("location");
|
||||
setSearchParams(p);
|
||||
}}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className={`grid ${c === 'EMOTOS' ? 'grid-cols-1' : 'grid-cols-2'} gap-2`}>
|
||||
<div
|
||||
className={`grid ${c === "EMOTOS" ? "grid-cols-1" : "grid-cols-2"} gap-2`}
|
||||
>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Estado</label>
|
||||
<select value={searchParams.get('condition') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('condition', e.target.value); else p.delete('condition'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 appearance-none cursor-pointer">
|
||||
<option value="" className="bg-gray-900">Todos</option>
|
||||
{VEHICLE_CONDITIONS.map(o => <option key={o} value={o} className="bg-gray-900">{o}</option>)}
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Estado
|
||||
</label>
|
||||
<select
|
||||
value={searchParams.get("condition") || ""}
|
||||
onChange={(e) => {
|
||||
const p = new URLSearchParams(searchParams);
|
||||
if (e.target.value) p.set("condition", e.target.value);
|
||||
else p.delete("condition");
|
||||
setSearchParams(p);
|
||||
}}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-gray-900">
|
||||
Todos
|
||||
</option>
|
||||
{VEHICLE_CONDITIONS.map((o) => (
|
||||
<option key={o} value={o} className="bg-gray-900">
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Segmento</label>
|
||||
<select value={searchParams.get('segment') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('segment', e.target.value); else p.delete('segment'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 appearance-none cursor-pointer">
|
||||
<option value="" className="bg-gray-900">Todos</option>
|
||||
{(c === 'EMOTOS' ? MOTO_SEGMENTS : AUTO_SEGMENTS).map(o => (
|
||||
<option key={o} value={o} className="bg-gray-900">{o}</option>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Segmento
|
||||
</label>
|
||||
<select
|
||||
value={searchParams.get("segment") || ""}
|
||||
onChange={(e) => {
|
||||
const p = new URLSearchParams(searchParams);
|
||||
if (e.target.value) p.set("segment", e.target.value);
|
||||
else p.delete("segment");
|
||||
setSearchParams(p);
|
||||
}}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-gray-900">
|
||||
Todos
|
||||
</option>
|
||||
{(c === "EMOTOS" ? MOTO_SEGMENTS : AUTO_SEGMENTS).map((o) => (
|
||||
<option key={o} value={o} className="bg-gray-900">
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c !== 'EMOTOS' && (
|
||||
{c !== "EMOTOS" && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Puertas</label>
|
||||
<input placeholder="Ej: 4" type="number" value={searchParams.get('doorCount') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('doorCount', e.target.value); else p.delete('doorCount'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500" />
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Puertas
|
||||
</label>
|
||||
<input
|
||||
placeholder="Ej: 4"
|
||||
type="number"
|
||||
value={searchParams.get("doorCount") || ""}
|
||||
onChange={(e) => {
|
||||
const p = new URLSearchParams(searchParams);
|
||||
if (e.target.value) p.set("doorCount", e.target.value);
|
||||
else p.delete("doorCount");
|
||||
setSearchParams(p);
|
||||
}}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">Dirección</label>
|
||||
<input placeholder="Ej: Hidráulica" type="text" value={searchParams.get('steering') || ''} onChange={e => { const p = new URLSearchParams(searchParams); if (e.target.value) p.set('steering', e.target.value); else p.delete('steering'); setSearchParams(p); }} className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500" />
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-black mb-2 block">
|
||||
Dirección
|
||||
</label>
|
||||
<input
|
||||
placeholder="Ej: Hidráulica"
|
||||
type="text"
|
||||
value={searchParams.get("steering") || ""}
|
||||
onChange={(e) => {
|
||||
const p = new URLSearchParams(searchParams);
|
||||
if (e.target.value) p.set("steering", e.target.value);
|
||||
else p.delete("steering");
|
||||
setSearchParams(p);
|
||||
}}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={applyFilters} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95 mb-4 mt-6">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95 mb-4 mt-6"
|
||||
>
|
||||
Aplicar Filtros
|
||||
</button>
|
||||
</div>
|
||||
@@ -285,10 +482,25 @@ export default function ExplorarPage() {
|
||||
<div className="w-full md:flex-1 md:min-w-0">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 md:mb-8 gap-4 md:gap-6">
|
||||
<div className="flex-1 w-full">
|
||||
<h2 className="text-3xl md:text-4xl font-black tracking-tighter uppercase mb-2 md:mb-0">Explorar</h2>
|
||||
<h2 className="text-3xl md:text-4xl font-black tracking-tighter uppercase mb-2 md:mb-0">
|
||||
Explorar
|
||||
</h2>
|
||||
<div className="mt-3 md:mt-4 relative max-w-xl group">
|
||||
<input type="text" placeholder="Buscar por marca, modelo o versión..." value={q} onChange={e => { const newParams = new URLSearchParams(searchParams); if (e.target.value) newParams.set('q', e.target.value); else newParams.delete('q'); setSearchParams(newParams); }} className="w-full bg-white/5 border border-white/10 rounded-xl md:rounded-2xl px-10 md:px-12 py-3 md:py-4 text-sm text-white outline-none focus:border-blue-500 transition-all focus:bg-white/10" />
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-blue-500 transition-colors">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por marca, modelo o versión..."
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (e.target.value) newParams.set("q", e.target.value);
|
||||
else newParams.delete("q");
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl md:rounded-2xl px-10 md:px-12 py-3 md:py-4 text-sm text-white outline-none focus:border-blue-500 transition-all focus:bg-white/10"
|
||||
/>
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 group-focus-within:text-blue-500 transition-colors">
|
||||
🔍
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-bold bg-white/5 border border-white/10 px-6 py-3 rounded-full text-gray-400 uppercase tracking-widest self-end md:self-center whitespace-nowrap">
|
||||
@@ -297,22 +509,45 @@ export default function ExplorarPage() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-20"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div></div>
|
||||
<div className="flex justify-center p-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="glass p-12 rounded-[2.5rem] border border-red-500/20 text-center"><p className="text-red-400 font-bold">{error}</p></div>
|
||||
<div className="glass p-12 rounded-[2.5rem] border border-red-500/20 text-center">
|
||||
<p className="text-red-400 font-bold">{error}</p>
|
||||
</div>
|
||||
) : listings.length === 0 ? (
|
||||
<div className="glass p-20 rounded-[2.5rem] text-center border-dashed border-2 border-white/10">
|
||||
<span className="text-6xl mb-6 block">🔍</span>
|
||||
<h3 className="text-2xl font-bold text-gray-400 uppercase tracking-tighter">Sin coincidencias</h3>
|
||||
<p className="text-gray-600 max-w-xs mx-auto mt-2 italic">No encontramos vehículos que coincidan con los filtros seleccionados.</p>
|
||||
<button onClick={clearFilters} className="mt-8 text-blue-400 font-black uppercase text-[10px] tracking-widest">Ver todos los avisos</button>
|
||||
<h3 className="text-2xl font-bold text-gray-400 uppercase tracking-tighter">
|
||||
Sin coincidencias
|
||||
</h3>
|
||||
<p className="text-gray-600 max-w-xs mx-auto mt-2 italic">
|
||||
No encontramos vehículos que coincidan con los filtros
|
||||
seleccionados.
|
||||
</p>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-8 text-blue-400 font-black uppercase text-[10px] tracking-widest"
|
||||
>
|
||||
Ver todos los avisos
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-8">
|
||||
{listings.map(car => (
|
||||
<Link to={`/vehiculo/${car.id}`} key={car.id} className="glass-card rounded-2xl md:rounded-[2rem] overflow-hidden group animate-fade-in-up flex flex-col">
|
||||
{listings.map((car) => (
|
||||
<Link
|
||||
to={`/vehiculo/${car.id}`}
|
||||
key={car.id}
|
||||
className="glass-card rounded-2xl md:rounded-[2rem] overflow-hidden group animate-fade-in-up flex flex-col"
|
||||
>
|
||||
<div className="aspect-[4/3] overflow-hidden relative bg-[#07090d] flex items-center justify-center border-b border-white/5">
|
||||
<img src={getImageUrl(car.image)} className="max-w-full max-h-full object-contain group-hover:scale-110 transition-transform duration-700" alt={`${car.brandName} ${car.versionName}`} loading="lazy" />
|
||||
<img
|
||||
src={getImageUrl(car.image)}
|
||||
className="max-w-full max-h-full object-contain group-hover:scale-110 transition-transform duration-700"
|
||||
alt={`${car.brandName} ${car.versionName}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* --- BLOQUE PARA EL BADGE --- */}
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
@@ -331,8 +566,12 @@ export default function ExplorarPage() {
|
||||
</h3>
|
||||
<div className="flex justify-between items-center mt-auto">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest mb-1">{car.year} • {car.km.toLocaleString()} KM</span>
|
||||
<span className="text-white font-black text-2xl tracking-tighter">{formatCurrency(car.price, car.currency)}</span>
|
||||
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest mb-1">
|
||||
{car.year} • {car.km.toLocaleString()} KM
|
||||
</span>
|
||||
<span className="text-white font-black text-2xl tracking-tighter">
|
||||
{formatCurrency(car.price, car.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,6 +580,6 @@ export default function ExplorarPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { AdsV2Service, type AdListingDto } from '../services/ads.v2.service';
|
||||
import { getImageUrl, formatCurrency } from '../utils/app.utils';
|
||||
import AdStatusBadge from '../components/AdStatusBadge';
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AdsV2Service, type AdListingDto } from "../services/ads.v2.service";
|
||||
import { getImageUrl, formatCurrency } from "../utils/app.utils";
|
||||
import AdStatusBadge from "../components/AdStatusBadge";
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState('ALL');
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState("ALL");
|
||||
const [featuredAds, setFeaturedAds] = useState<AdListingDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -19,10 +19,10 @@ export default function HomePage() {
|
||||
// Cargar destacados
|
||||
useEffect(() => {
|
||||
AdsV2Service.getAll({ isFeatured: true })
|
||||
.then(data => {
|
||||
.then((data) => {
|
||||
setFeaturedAds(data.slice(0, 3));
|
||||
})
|
||||
.catch(err => console.error("Error cargando destacados:", err))
|
||||
.catch((err) => console.error("Error cargando destacados:", err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -45,7 +45,10 @@ export default function HomePage() {
|
||||
// --- LÓGICA PARA CERRAR SUGERENCIAS AL HACER CLIC FUERA ---
|
||||
useEffect(() => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -57,7 +60,7 @@ export default function HomePage() {
|
||||
const handleSearch = (searchTerm: string = query) => {
|
||||
setShowSuggestions(false);
|
||||
// Si la categoría es 'ALL', no enviamos el parámetro 'c'
|
||||
const categoryParam = category === 'ALL' ? '' : `&c=${category}`;
|
||||
const categoryParam = category === "ALL" ? "" : `&c=${category}`;
|
||||
navigate(`/explorar?q=${searchTerm}${categoryParam}`);
|
||||
};
|
||||
|
||||
@@ -87,7 +90,8 @@ export default function HomePage() {
|
||||
ENCONTRÁ TU <span className="text-gradient">PRÓXIMO</span> VEHÍCULO
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base md:text-xl text-gray-400 mb-6 md:mb-10 max-w-2xl mx-auto font-light px-2">
|
||||
La 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>
|
||||
|
||||
{/* --- CONTENEDOR DEL BUSCADOR CON ref y onFocus --- */}
|
||||
@@ -95,20 +99,20 @@ export default function HomePage() {
|
||||
{/* Botones de categoría arriba del buscador */}
|
||||
<div className="flex gap-2 mb-3 justify-center">
|
||||
<button
|
||||
onClick={() => setCategory('ALL')}
|
||||
className={`px-4 md:px-6 py-2 rounded-xl font-bold text-xs md:text-sm uppercase tracking-widest transition-all ${category === 'ALL' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/40' : 'glass text-gray-400 hover:text-white'}`}
|
||||
onClick={() => setCategory("ALL")}
|
||||
className={`px-4 md:px-6 py-2 rounded-xl font-bold text-xs md:text-sm uppercase tracking-widest transition-all ${category === "ALL" ? "bg-blue-600 text-white shadow-lg shadow-blue-600/40" : "glass text-gray-400 hover:text-white"}`}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCategory('EAUTOS')}
|
||||
className={`px-4 md:px-6 py-2 rounded-xl font-bold text-xs md:text-sm uppercase tracking-widest transition-all ${category === 'EAUTOS' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/40' : 'glass text-gray-400 hover:text-white'}`}
|
||||
onClick={() => setCategory("EAUTOS")}
|
||||
className={`px-4 md:px-6 py-2 rounded-xl font-bold text-xs md:text-sm uppercase tracking-widest transition-all ${category === "EAUTOS" ? "bg-blue-600 text-white shadow-lg shadow-blue-600/40" : "glass text-gray-400 hover:text-white"}`}
|
||||
>
|
||||
🚗 Automóviles
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCategory('EMOTOS')}
|
||||
className={`px-4 md:px-6 py-2 rounded-xl font-bold text-xs md:text-sm uppercase tracking-widest transition-all ${category === 'EMOTOS' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/40' : 'glass text-gray-400 hover:text-white'}`}
|
||||
onClick={() => setCategory("EMOTOS")}
|
||||
className={`px-4 md:px-6 py-2 rounded-xl font-bold text-xs md:text-sm uppercase tracking-widest transition-all ${category === "EMOTOS" ? "bg-blue-600 text-white shadow-lg shadow-blue-600/40" : "glass text-gray-400 hover:text-white"}`}
|
||||
>
|
||||
🏍️ Motos
|
||||
</button>
|
||||
@@ -121,7 +125,7 @@ export default function HomePage() {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
@@ -156,10 +160,19 @@ export default function HomePage() {
|
||||
<section className="container mx-auto px-4 md:px-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-6 md:mb-10 gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-2">Avisos <span className="text-gradient">Destacados</span></h2>
|
||||
<p className="text-gray-400 text-base md:text-lg italic">Las mejores ofertas seleccionadas para vos.</p>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-2">
|
||||
Avisos <span className="text-gradient">Destacados</span>
|
||||
</h2>
|
||||
<p className="text-gray-400 text-base md:text-lg italic">
|
||||
Las mejores ofertas seleccionadas para vos.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/explorar" className="text-blue-400 hover:text-white transition text-sm md:text-base">Ver todos →</Link>
|
||||
<Link
|
||||
to="/explorar"
|
||||
className="text-blue-400 hover:text-white transition text-sm md:text-base"
|
||||
>
|
||||
Ver todos →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -168,12 +181,18 @@ export default function HomePage() {
|
||||
</div>
|
||||
) : featuredAds.length === 0 ? (
|
||||
<div className="text-center p-10 glass rounded-3xl border border-white/5">
|
||||
<p className="text-gray-500 text-xl font-bold uppercase tracking-widest">No hay avisos destacados por el momento.</p>
|
||||
<p className="text-gray-500 text-xl font-bold uppercase tracking-widest">
|
||||
No hay avisos destacados por el momento.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-8">
|
||||
{featuredAds.map(car => (
|
||||
<Link to={`/vehiculo/${car.id}`} key={car.id} className="glass-card rounded-2xl md:rounded-3xl overflow-hidden group">
|
||||
{featuredAds.map((car) => (
|
||||
<Link
|
||||
to={`/vehiculo/${car.id}`}
|
||||
key={car.id}
|
||||
className="glass-card rounded-2xl md:rounded-3xl overflow-hidden group"
|
||||
>
|
||||
<div className="aspect-[4/3] overflow-hidden relative bg-[#07090d] flex items-center justify-center border-b border-white/5">
|
||||
<img
|
||||
src={getImageUrl(car.image)}
|
||||
@@ -184,10 +203,14 @@ export default function HomePage() {
|
||||
<AdStatusBadge statusId={car.statusId || 4} />
|
||||
</div>
|
||||
{car.isFeatured && (
|
||||
<div className="absolute top-4 right-4 bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full uppercase tracking-widest shadow-lg shadow-blue-600/40">DESTACADO</div>
|
||||
<div className="absolute top-4 right-4 bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full uppercase tracking-widest shadow-lg shadow-blue-600/40">
|
||||
DESTACADO
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-4 right-4 bg-black/60 backdrop-blur-md text-white px-4 py-2 rounded-xl border border-white/10">
|
||||
<span className="text-xl font-bold">{formatCurrency(car.price, car.currency)}</span>
|
||||
<span className="text-xl font-bold">
|
||||
{formatCurrency(car.price, car.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
@@ -197,8 +220,12 @@ export default function HomePage() {
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[10px] text-gray-400 font-black tracking-widest uppercase">
|
||||
<span className="bg-gray-800/80 px-3 py-1.5 rounded-lg border border-white/5">{car.year}</span>
|
||||
<span className="bg-gray-800/80 px-3 py-1.5 rounded-lg border border-white/5">{car.km.toLocaleString()} KM</span>
|
||||
<span className="bg-gray-800/80 px-3 py-1.5 rounded-lg border border-white/5">
|
||||
{car.year}
|
||||
</span>
|
||||
<span className="bg-gray-800/80 px-3 py-1.5 rounded-lg border border-white/5">
|
||||
{car.km.toLocaleString()} KM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AdsV2Service, type AdListingDto } from '../services/ads.v2.service';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ChatService, type ChatMessage } from '../services/chat.service';
|
||||
import ChatModal from '../components/ChatModal';
|
||||
import { getImageUrl, parseUTCDate } from '../utils/app.utils';
|
||||
import { AD_STATUSES, STATUS_CONFIG } from '../constants/adStatuses';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AdsV2Service, type AdListingDto } from "../services/ads.v2.service";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { ChatService, type ChatMessage } from "../services/chat.service";
|
||||
import ChatModal from "../components/ChatModal";
|
||||
import LoginModal from "../components/LoginModal";
|
||||
import { formatCurrency, getImageUrl, parseUTCDate } from "../utils/app.utils";
|
||||
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() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('avisos');
|
||||
const [activeTab, setActiveTab] = useState<TabType>("avisos");
|
||||
const [avisos, setAvisos] = useState<AdListingDto[]>([]);
|
||||
const [favoritos, setFavoritos] = useState<AdListingDto[]>([]);
|
||||
const [mensajes, setMensajes] = useState<ChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { user, fetchUnreadCount } = useAuth();
|
||||
const { 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<{
|
||||
isOpen: boolean;
|
||||
@@ -30,21 +36,21 @@ export default function MisAvisosPage() {
|
||||
isDanger: boolean;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
title: "",
|
||||
message: "",
|
||||
adId: null,
|
||||
newStatus: null,
|
||||
isDanger: false
|
||||
isDanger: false,
|
||||
});
|
||||
|
||||
// Función para forzar chequeo manual desde Gestión
|
||||
const handleVerifyPayment = async (adId: number) => {
|
||||
try {
|
||||
const res = await AdsV2Service.checkPaymentStatus(adId);
|
||||
if (res.status === 'approved') {
|
||||
if (res.status === "approved") {
|
||||
alert("¡Pago confirmado! El aviso pasará a moderación.");
|
||||
cargarAvisos(user!.id);
|
||||
} else if (res.status === 'rejected') {
|
||||
} else if (res.status === "rejected") {
|
||||
alert("El pago fue rechazado. Puedes intentar pagar nuevamente.");
|
||||
cargarAvisos(user!.id); // Debería volver a estado Draft/1
|
||||
} else {
|
||||
@@ -58,42 +64,47 @@ export default function MisAvisosPage() {
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
cargarMensajes(user.id);
|
||||
if (activeTab === 'avisos') cargarAvisos(user.id);
|
||||
else if (activeTab === 'favoritos') cargarFavoritos(user.id);
|
||||
if (activeTab === "avisos") cargarAvisos(user.id);
|
||||
else if (activeTab === "favoritos") cargarFavoritos(user.id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, activeTab]);
|
||||
|
||||
const initiateStatusChange = (adId: number, newStatus: number) => {
|
||||
let title = 'Cambiar Estado';
|
||||
let message = '¿Estás seguro de realizar esta acción?';
|
||||
let title = "Cambiar Estado";
|
||||
let message = "¿Estás seguro de realizar esta acción?";
|
||||
let isDanger = false;
|
||||
|
||||
// 1. ELIMINAR
|
||||
if (newStatus === AD_STATUSES.DELETED) {
|
||||
title = '¿Eliminar Aviso?';
|
||||
message = 'Esta acción eliminará el aviso permanentemente. No se puede deshacer.\n\n¿Estás seguro de continuar?';
|
||||
title = "¿Eliminar Aviso?";
|
||||
message =
|
||||
"Esta acción eliminará el aviso permanentemente. No se puede deshacer.\n\n¿Estás seguro de continuar?";
|
||||
isDanger = true;
|
||||
}
|
||||
// 2. PAUSAR
|
||||
else if (newStatus === AD_STATUSES.PAUSED) {
|
||||
title = 'Pausar Publicación';
|
||||
message = 'Al pausar el aviso:\n\n• Dejará de ser visible en los listados.\n• Los usuarios NO podrán contactarte.\n\nPodrás reactivarlo cuando quieras, dentro de la vigencia de publicación.';
|
||||
title = "Pausar Publicación";
|
||||
message =
|
||||
"Al pausar el aviso:\n\n• Dejará de ser visible en los listados.\n• Los usuarios NO podrán contactarte.\n\nPodrás reactivarlo cuando quieras, dentro de la vigencia de publicación.";
|
||||
}
|
||||
// 3. VENDIDO
|
||||
else if (newStatus === AD_STATUSES.SOLD) {
|
||||
title = '¡Felicitaciones!';
|
||||
message = 'Al marcar como VENDIDO:\n\n• Se deshabilitarán nuevas consultas.\n• El aviso mostrará la etiqueta "Vendido" al público.\n\n¿Confirmas que ya vendiste el vehículo?';
|
||||
title = "¡Felicitaciones!";
|
||||
message =
|
||||
'Al marcar como VENDIDO:\n\n• Se deshabilitarán nuevas consultas.\n• El aviso mostrará la etiqueta "Vendido" al público.\n\n¿Confirmas que ya vendiste el vehículo?';
|
||||
}
|
||||
// 4. RESERVADO
|
||||
else if (newStatus === AD_STATUSES.RESERVED) {
|
||||
title = 'Reservar Vehículo';
|
||||
message = 'Al reservar el aviso:\n\n• Se indicará a los interesados que el vehículo está reservado.\n• Se bloquearán nuevos contactos hasta que lo actives o vendas.\n\n¿Deseas continuar?';
|
||||
title = "Reservar Vehículo";
|
||||
message =
|
||||
"Al reservar el aviso:\n\n• Se indicará a los interesados que el vehículo está reservado.\n• Se bloquearán nuevos contactos hasta que lo actives o vendas.\n\n¿Deseas continuar?";
|
||||
}
|
||||
// 5. ACTIVAR (Desde Pausado/Reservado)
|
||||
else if (newStatus === AD_STATUSES.ACTIVE) {
|
||||
title = 'Reactivar Aviso';
|
||||
message = 'El aviso volverá a estar visible para todos y recibirás consultas nuevamente.';
|
||||
title = "Reactivar Aviso";
|
||||
message =
|
||||
"El aviso volverá a estar visible para todos y recibirás consultas nuevamente.";
|
||||
}
|
||||
|
||||
setModalConfig({
|
||||
@@ -102,7 +113,7 @@ export default function MisAvisosPage() {
|
||||
message,
|
||||
adId,
|
||||
newStatus,
|
||||
isDanger
|
||||
isDanger,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -116,7 +127,7 @@ export default function MisAvisosPage() {
|
||||
await AdsV2Service.changeStatus(adId, newStatus);
|
||||
if (user) cargarAvisos(user.id);
|
||||
} 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
|
||||
const openChatForAd = async (adId: number, adTitle: string) => {
|
||||
if (!user) return;
|
||||
const relatedMsg = mensajes.find(m => m.adID === adId);
|
||||
const relatedMsg = mensajes.find((m) => m.adID === adId);
|
||||
|
||||
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
|
||||
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) {
|
||||
// Optimización visual: actualiza la UI localmente de inmediato
|
||||
setMensajes(prev => prev.map(m =>
|
||||
unreadMessages.some(um => um.messageID === m.messageID) ? { ...m, isRead: true } : m
|
||||
));
|
||||
setMensajes((prev) =>
|
||||
prev.map((m) =>
|
||||
unreadMessages.some((um) => um.messageID === m.messageID)
|
||||
? { ...m, isRead: true }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Crea un array de promesas para todas las llamadas a la API
|
||||
const markAsReadPromises = unreadMessages.map(m =>
|
||||
m.messageID ? ChatService.markAsRead(m.messageID) : Promise.resolve()
|
||||
const markAsReadPromises = unreadMessages.map((m) =>
|
||||
m.messageID
|
||||
? ChatService.markAsRead(m.messageID)
|
||||
: Promise.resolve(),
|
||||
);
|
||||
|
||||
// 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
|
||||
await fetchUnreadCount();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error al marcar mensajes como leídos:", error);
|
||||
// Opcional: podrías revertir el estado local si la API falla
|
||||
@@ -215,82 +236,134 @@ export default function MisAvisosPage() {
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-24 text-center animate-fade-in-up">
|
||||
<div className="glass p-12 rounded-[3rem] max-w-2xl mx-auto border border-white/5 shadow-2xl">
|
||||
<div className="bg-[#111318]/90 backdrop-blur-3xl p-12 rounded-[3rem] max-w-2xl mx-auto border border-white/5 shadow-2xl">
|
||||
<span className="text-7xl mb-8 block">🔒</span>
|
||||
<h2 className="text-5xl font-black mb-4 uppercase tracking-tighter">Á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">
|
||||
Para gestionar tus publicaciones, primero debes iniciar sesión.
|
||||
Para ver y gestionar tus avisos, primero debes registrarte o iniciar sesión.
|
||||
</p>
|
||||
<Link to="/publicar" className="bg-blue-600 hover:bg-blue-500 text-white px-12 py-5 rounded-[2rem] font-bold uppercase tracking-widest transition-all inline-block shadow-lg shadow-blue-600/20">
|
||||
Identificarse
|
||||
</Link>
|
||||
<div 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 text-left">
|
||||
<LoginModal
|
||||
initialMode="REGISTER"
|
||||
onSuccess={(u) => {
|
||||
login(u);
|
||||
setShowLoginModal(false);
|
||||
}}
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalVisitas = avisos.reduce((acc, curr) => acc + (curr.viewsCounter || 0), 0);
|
||||
const avisosActivos = avisos.filter(a => a.statusId === 4).length;
|
||||
const totalVisitas = avisos.reduce(
|
||||
(acc, curr) => acc + (curr.viewsCounter || 0),
|
||||
0,
|
||||
);
|
||||
const avisosActivos = avisos.filter((a) => a.statusId === 4).length;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-12 animate-fade-in-up min-h-screen">
|
||||
|
||||
<header className="flex flex-col md:flex-row justify-between items-start md:items-end mb-16 gap-8">
|
||||
<div>
|
||||
<h2 className="text-5xl font-black tracking-tighter uppercase mb-4">Mis <span className="text-blue-500">Avisos</span></h2>
|
||||
<h2 className="text-5xl font-black tracking-tighter uppercase mb-4">
|
||||
Mis <span className="text-blue-500">Avisos</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-14 h-14 bg-gradient-to-tr from-blue-600 to-cyan-400 rounded-2xl flex items-center justify-center text-white text-xl font-black shadow-xl shadow-blue-600/20">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white text-xl font-black block leading-none">{user.firstName} {user.lastName}</span>
|
||||
<span className="text-gray-500 text-[10px] uppercase font-black tracking-[0.3em]">{user.email}</span>
|
||||
<span className="text-white text-xl font-black block leading-none">
|
||||
{user.firstName} {user.lastName}
|
||||
</span>
|
||||
<span className="text-gray-500 text-[10px] uppercase font-black tracking-[0.3em]">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full md:w-auto bg-white/5 p-1 rounded-xl md:rounded-2xl border border-white/5 backdrop-blur-xl overflow-x-auto no-scrollbar gap-0.5 md:gap-0">
|
||||
{(['avisos', 'favoritos', 'mensajes'] as TabType[]).map(tab => (
|
||||
{(["avisos", "favoritos", "mensajes"] as TabType[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 md:flex-none px-2.5 md:px-8 py-2 md:py-3 rounded-lg md:rounded-xl text-[9px] md:text-[10px] font-black uppercase tracking-widest transition-all whitespace-nowrap ${activeTab === tab ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20' : 'text-gray-500 hover:text-white'}`}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<MetricCard label="Visualizaciones" value={totalVisitas} icon="👁️" />
|
||||
<MetricCard
|
||||
label="Visualizaciones"
|
||||
value={totalVisitas}
|
||||
icon="👁️"
|
||||
/>
|
||||
<MetricCard label="Activos" value={avisosActivos} icon="✅" />
|
||||
<MetricCard label="Favoritos" value={favoritos.length} icon="⭐" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-24">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'avisos' && (
|
||||
{activeTab === "avisos" && (
|
||||
<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">
|
||||
<span className="text-4xl md:text-5xl mb-6 block">📂</span>
|
||||
<h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">No tienes avisos</h3>
|
||||
<Link to="/publicar" className="mt-8 text-blue-400 font-black uppercase text-xs tracking-widest inline-block border-b border-blue-400 pb-1">Crear mi primer aviso</Link>
|
||||
<h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">
|
||||
No tienes avisos
|
||||
</h3>
|
||||
<Link
|
||||
to="/publicar"
|
||||
className="mt-8 text-blue-400 font-black uppercase text-xs tracking-widest inline-block border-b border-blue-400 pb-1"
|
||||
>
|
||||
Crear mi primer aviso
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
avisos.filter(a => a.statusId !== 9).map((av, index) => {
|
||||
const hasMessages = mensajes.some(m => m.adID === av.id);
|
||||
const hasUnread = mensajes.some(m => m.adID === av.id && !m.isRead && m.receiverID === user.id);
|
||||
avisos
|
||||
.filter((a) => a.statusId !== 9)
|
||||
.map((av, index) => {
|
||||
const hasMessages = mensajes.some(
|
||||
(m) => m.adID === av.id,
|
||||
);
|
||||
const hasUnread = mensajes.some(
|
||||
(m) =>
|
||||
m.adID === av.id &&
|
||||
!m.isRead &&
|
||||
m.receiverID === user.id,
|
||||
);
|
||||
|
||||
return (
|
||||
// 'relative z-index' dinámico
|
||||
@@ -301,11 +374,16 @@ export default function MisAvisosPage() {
|
||||
className="glass p-6 rounded-[2.5rem] flex flex-col md:flex-row items-center gap-8 border border-white/5 hover:border-blue-500/20 transition-all relative"
|
||||
style={{ zIndex: 50 - index }}
|
||||
>
|
||||
|
||||
<div className="w-full md:w-64 h-40 bg-gray-900 rounded-3xl overflow-hidden relative flex-shrink-0 shadow-xl">
|
||||
<img src={getImageUrl(av.image)} className="w-full h-full object-cover" alt={`${av.brandName} ${av.versionName}`} />
|
||||
<img
|
||||
src={getImageUrl(av.image)}
|
||||
className="w-full h-full object-cover"
|
||||
alt={`${av.brandName} ${av.versionName}`}
|
||||
/>
|
||||
<div className="absolute top-3 left-3 bg-black/60 backdrop-blur-md px-2 py-1 rounded-lg border border-white/10">
|
||||
<span className="text-[9px] font-bold text-white">#{av.id}</span>
|
||||
<span className="text-[9px] font-bold text-white">
|
||||
#{av.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -314,27 +392,38 @@ export default function MisAvisosPage() {
|
||||
<h3 className="text-2xl font-black text-white uppercase tracking-tighter truncate max-w-md">
|
||||
{av.brandName} {av.versionName}
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-[9px] text-blue-300 font-black uppercase tracking-widest">
|
||||
⭐ Destacado
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-auto flex flex-col gap-3 min-w-[180px]">
|
||||
|
||||
{/* CASO 1: BORRADOR (1) -> Botón de Pagar */}
|
||||
{av.statusId === AD_STATUSES.DRAFT && (
|
||||
<Link
|
||||
@@ -349,7 +438,9 @@ export default function MisAvisosPage() {
|
||||
{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>
|
||||
<span className="block text-[10px] font-black uppercase tracking-widest">
|
||||
⏳ Pago Pendiente
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleVerifyPayment(av.id)}
|
||||
@@ -363,8 +454,12 @@ export default function MisAvisosPage() {
|
||||
{/* 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>
|
||||
<span className="block text-[10px] font-black uppercase tracking-widest">
|
||||
⏳ En Revisión
|
||||
</span>
|
||||
<span className="text-[8px] opacity-70">
|
||||
No editable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -372,7 +467,9 @@ export default function MisAvisosPage() {
|
||||
{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>
|
||||
<span className="block text-[10px] font-black uppercase tracking-widest">
|
||||
⛔ Finalizado
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
to={`/publicar?edit=${av.id}`}
|
||||
@@ -387,13 +484,39 @@ export default function MisAvisosPage() {
|
||||
{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.EXPIRED &&
|
||||
av.statusId !== AD_STATUSES.REJECTED && (
|
||||
<StatusDropdown
|
||||
currentStatus={av.statusId || AD_STATUSES.ACTIVE}
|
||||
onChange={(newStatus) => initiateStatusChange(av.id, newStatus)}
|
||||
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
|
||||
@@ -405,7 +528,12 @@ export default function MisAvisosPage() {
|
||||
|
||||
{hasMessages && (
|
||||
<button
|
||||
onClick={() => openChatForAd(av.id, `${av.brandName} ${av.versionName}`)}
|
||||
onClick={() =>
|
||||
openChatForAd(
|
||||
av.id,
|
||||
`${av.brandName} ${av.versionName}`,
|
||||
)
|
||||
}
|
||||
className="relative bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white border border-white/5 px-4 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all"
|
||||
>
|
||||
💬 Mensajes
|
||||
@@ -423,25 +551,53 @@ export default function MisAvisosPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'favoritos' && (
|
||||
{activeTab === "favoritos" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{favoritos.length === 0 ? (
|
||||
<div className="col-span-full glass p-12 md:p-24 rounded-3xl md:rounded-[3rem] text-center border-dashed border-2 border-white/10">
|
||||
<span className="text-4xl md:text-5xl mb-6 block">⭐</span>
|
||||
<h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">No tienes favoritos</h3>
|
||||
<Link to="/explorar" className="mt-8 text-blue-400 font-black uppercase text-xs tracking-widest inline-block border-b border-blue-400 pb-1">Explorar vehículos</Link>
|
||||
<h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">
|
||||
No tienes favoritos
|
||||
</h3>
|
||||
<Link
|
||||
to="/explorar"
|
||||
className="mt-8 text-blue-400 font-black uppercase text-xs tracking-widest inline-block border-b border-blue-400 pb-1"
|
||||
>
|
||||
Explorar vehículos
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
favoritos.map((fav) => (
|
||||
<div key={fav.id} className="glass rounded-[2rem] overflow-hidden border border-white/5 flex flex-col group hover:border-blue-500/30 transition-all">
|
||||
<div
|
||||
key={fav.id}
|
||||
className="glass rounded-[2rem] overflow-hidden border border-white/5 flex flex-col group hover:border-blue-500/30 transition-all"
|
||||
>
|
||||
<div className="relative h-48">
|
||||
<img src={getImageUrl(fav.image)} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" alt={`${fav.brandName} ${fav.versionName}`} />
|
||||
<button onClick={() => handleRemoveFavorite(fav.id)} className="absolute top-4 right-4 w-10 h-10 bg-black/50 backdrop-blur-md rounded-xl flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-all shadow-xl">×</button>
|
||||
<img
|
||||
src={getImageUrl(fav.image)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
alt={`${fav.brandName} ${fav.versionName}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRemoveFavorite(fav.id)}
|
||||
className="absolute top-4 right-4 w-10 h-10 bg-black/50 backdrop-blur-md rounded-xl flex items-center justify-center text-red-500 hover:bg-red-500 hover:text-white transition-all shadow-xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-black text-white uppercase tracking-tighter mb-1 truncate">{fav.brandName} {fav.versionName}</h3>
|
||||
<p className="text-blue-400 font-extrabold text-xl mb-4">{fav.currency} {fav.price.toLocaleString()}</p>
|
||||
<Link to={`/vehiculo/${fav.id}`} className="block w-full bg-blue-600/10 hover:bg-blue-600 text-blue-400 hover:text-white p-3 rounded-xl text-[10px] font-black uppercase tracking-widest text-center transition-all border border-blue-600/20">Ver Detalle</Link>
|
||||
<h3 className="text-lg font-black text-white uppercase tracking-tighter mb-1 truncate">
|
||||
{fav.brandName} {fav.versionName}
|
||||
</h3>
|
||||
<p className="text-blue-400 font-extrabold text-xl mb-4">
|
||||
{fav.currency} {fav.price.toLocaleString()}
|
||||
</p>
|
||||
<Link
|
||||
to={`/vehiculo/${fav.id}`}
|
||||
className="block w-full bg-blue-600/10 hover:bg-blue-600 text-blue-400 hover:text-white p-3 rounded-xl text-[10px] font-black uppercase tracking-widest text-center transition-all border border-blue-600/20"
|
||||
>
|
||||
Ver Detalle
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -449,49 +605,74 @@ export default function MisAvisosPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'mensajes' && (
|
||||
{activeTab === "mensajes" && (
|
||||
<div className="space-y-4">
|
||||
{mensajes.length === 0 ? (
|
||||
<div className="glass p-12 md:p-24 rounded-3xl md:rounded-[3rem] text-center border-dashed border-2 border-white/10">
|
||||
<span className="text-4xl md:text-5xl mb-6 block">💬</span>
|
||||
<h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">No tienes mensajes</h3>
|
||||
<p className="text-gray-600 mt-2 max-w-sm mx-auto italic text-lg">Los moderadores te contactarán por aquí si es necesario.</p>
|
||||
<h3 className="text-xl md:text-3xl font-bold text-gray-500 uppercase tracking-tighter">
|
||||
No tienes mensajes
|
||||
</h3>
|
||||
<p className="text-gray-600 mt-2 max-w-sm mx-auto italic text-lg">
|
||||
Los moderadores te contactarán por aquí si es necesario.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.values(mensajes.reduce((acc: any, curr) => {
|
||||
Object.values(
|
||||
mensajes.reduce((acc: any, curr) => {
|
||||
const key = curr.adID;
|
||||
if (!acc[key]) acc[key] = { msg: curr, count: 0, unread: false };
|
||||
if (!acc[key])
|
||||
acc[key] = { msg: curr, count: 0, unread: false };
|
||||
acc[key].count++;
|
||||
if (!curr.isRead && curr.receiverID === user.id) acc[key].unread = true;
|
||||
if (new Date(curr.sentAt!) > new Date(acc[key].msg.sentAt!)) acc[key].msg = curr;
|
||||
if (!curr.isRead && curr.receiverID === user.id)
|
||||
acc[key].unread = true;
|
||||
if (
|
||||
new Date(curr.sentAt!) > new Date(acc[key].msg.sentAt!)
|
||||
)
|
||||
acc[key].msg = curr;
|
||||
return acc;
|
||||
}, {})).map((item: any) => {
|
||||
const aviso = avisos.find(a => a.id === item.msg.adID);
|
||||
const tituloAviso = aviso ? `${aviso.brandName} ${aviso.versionName}` : `Aviso #${item.msg.adID}`;
|
||||
}, {}),
|
||||
).map((item: any) => {
|
||||
const aviso = avisos.find((a) => a.id === item.msg.adID);
|
||||
const tituloAviso = aviso
|
||||
? `${aviso.brandName} ${aviso.versionName}`
|
||||
: `Aviso #${item.msg.adID}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.msg.adID}
|
||||
onClick={() => openChatForAd(item.msg.adID, tituloAviso)}
|
||||
onClick={() =>
|
||||
openChatForAd(item.msg.adID, tituloAviso)
|
||||
}
|
||||
className="glass p-6 rounded-2xl flex items-center gap-6 border border-white/5 hover:border-blue-500/30 transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-blue-600/20 rounded-full flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">🛡️</div>
|
||||
<div className="w-16 h-16 bg-blue-600/20 rounded-full flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">
|
||||
🛡️
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h4 className="font-black uppercase tracking-tighter text-white">
|
||||
{tituloAviso}
|
||||
</h4>
|
||||
<span className="text-[10px] text-gray-500 font-bold uppercase">{parseUTCDate(item.msg.sentAt!).toLocaleDateString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span>
|
||||
<span className="text-[10px] text-gray-500 font-bold uppercase">
|
||||
{parseUTCDate(
|
||||
item.msg.sentAt!,
|
||||
).toLocaleDateString("es-AR", {
|
||||
timeZone: "America/Argentina/Buenos_Aires",
|
||||
hour12: false,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 line-clamp-1">
|
||||
{item.msg.senderID === user.id ? 'Tú: ' : ''}{item.msg.messageText}
|
||||
{item.msg.senderID === user.id ? "Tú: " : ""}
|
||||
{item.msg.messageText}
|
||||
</p>
|
||||
</div>
|
||||
{item.unread && (
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full shadow-lg shadow-red-500/50"></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
@@ -518,24 +699,34 @@ export default function MisAvisosPage() {
|
||||
onConfirm={confirmStatusChange}
|
||||
onCancel={() => setModalConfig({ ...modalConfig, isOpen: false })}
|
||||
isDanger={modalConfig.isDanger}
|
||||
confirmText={modalConfig.newStatus === AD_STATUSES.SOLD ? "¡Sí, vendido!" : "Confirmar"}
|
||||
confirmText={
|
||||
modalConfig.newStatus === AD_STATUSES.SOLD
|
||||
? "¡Sí, vendido!"
|
||||
: "Confirmar"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 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: '❓'
|
||||
label: "Desconocido",
|
||||
color: "text-gray-400",
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-500/20",
|
||||
icon: "❓",
|
||||
};
|
||||
|
||||
const ALLOWED_STATUSES = [
|
||||
@@ -543,12 +734,15 @@ function StatusDropdown({ currentStatus, onChange }: { currentStatus: number, on
|
||||
AD_STATUSES.PAUSED,
|
||||
AD_STATUSES.RESERVED,
|
||||
AD_STATUSES.SOLD,
|
||||
AD_STATUSES.DELETED
|
||||
AD_STATUSES.DELETED,
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -564,7 +758,9 @@ function StatusDropdown({ currentStatus, onChange }: { currentStatus: number, on
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<span className="text-xs">▼</span>
|
||||
</button>
|
||||
@@ -580,8 +776,11 @@ function StatusDropdown({ currentStatus, onChange }: { currentStatus: number, on
|
||||
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'}`}
|
||||
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}
|
||||
@@ -594,13 +793,27 @@ function StatusDropdown({ currentStatus, onChange }: { currentStatus: number, on
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, icon }: { label: string, value: any, icon: string }) {
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: any;
|
||||
icon: string;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
<span className="text-2xl md:text-3xl font-black text-white tracking-tighter block leading-none mb-1">{value.toLocaleString()}</span>
|
||||
<span className="text-[9px] md:text-[10px] font-black uppercase tracking-widest text-gray-500 block leading-tight">{label}</span>
|
||||
<span className="text-2xl md:text-3xl font-black text-white tracking-tighter block leading-none mb-1">
|
||||
{value.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[9px] md:text-[10px] font-black uppercase tracking-widest text-gray-500 block leading-tight">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useState, useEffect } from "react";
|
||||
import { ProfileService } from "../services/profile.service";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { AuthService } from "../services/auth.service";
|
||||
import {
|
||||
NotificationPreferencesService,
|
||||
type NotificationPreferences,
|
||||
} from "../services/notification-preferences.service";
|
||||
|
||||
export default function PerfilPage() {
|
||||
const { user, refreshSession } = useAuth();
|
||||
@@ -19,10 +23,46 @@ export default function PerfilPage() {
|
||||
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(() => {
|
||||
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 () => {
|
||||
try {
|
||||
const data = await ProfileService.getProfile();
|
||||
@@ -97,7 +137,7 @@ export default function PerfilPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
{/* Formulario de datos personales */}
|
||||
<div className="lg:col-span-2">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -189,6 +229,118 @@ export default function PerfilPage() {
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,42 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'; // Importar useNavigate
|
||||
import { AvisosService } from '../services/avisos.service';
|
||||
import { AdsV2Service } from '../services/ads.v2.service';
|
||||
import { AuthService, type UserSession } from '../services/auth.service';
|
||||
import type { DatosAvisoDto } from '../types/aviso.types';
|
||||
import FormularioAviso from '../components/FormularioAviso';
|
||||
import LoginModal from '../components/LoginModal';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { AvisosService } from "../services/avisos.service";
|
||||
import { AdsV2Service } from "../services/ads.v2.service";
|
||||
|
||||
import type { DatosAvisoDto } from "../types/aviso.types";
|
||||
import FormularioAviso from "../components/FormularioAviso";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import LoginModal from "../components/LoginModal";
|
||||
|
||||
const TAREAS_DISPONIBLES = [
|
||||
{ id: 'EAUTOS', label: 'Automóviles', icon: '🚗', description: 'Venta de Autos, Camionetas y Utilitarios' },
|
||||
{ id: 'EMOTOS', label: 'Motos', icon: '🏍️', description: 'Venta de Motos, Cuatriciclos y Náutica' },
|
||||
{
|
||||
id: "EAUTOS",
|
||||
label: "Automóviles",
|
||||
icon: "🚗",
|
||||
description: "Venta de Autos, Camionetas y Utilitarios",
|
||||
},
|
||||
{
|
||||
id: "EMOTOS",
|
||||
label: "Motos",
|
||||
icon: "🏍️",
|
||||
description: "Venta de Motos, Cuatriciclos y Náutica",
|
||||
},
|
||||
];
|
||||
|
||||
export default function PublicarAvisoPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate(); // Hook de navegación
|
||||
const editId = searchParams.get('edit');
|
||||
const editId = searchParams.get("edit");
|
||||
|
||||
const [categorySelection, setCategorySelection] = useState<string>('');
|
||||
const [categorySelection, setCategorySelection] = useState<string>("");
|
||||
const [tarifas, setTarifas] = useState<DatosAvisoDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [planSeleccionado, setPlanSeleccionado] = useState<DatosAvisoDto | null>(null);
|
||||
const [planSeleccionado, setPlanSeleccionado] =
|
||||
useState<DatosAvisoDto | null>(null);
|
||||
const [fixedCategory, setFixedCategory] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<UserSession | null>(AuthService.getCurrentUser());
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const { user, login } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (editId) {
|
||||
@@ -31,32 +44,57 @@ export default function PublicarAvisoPage() {
|
||||
}
|
||||
}, [editId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (planSeleccionado) {
|
||||
window.scrollTo({ top: 0, behavior: "instant" });
|
||||
}
|
||||
}, [planSeleccionado]);
|
||||
|
||||
const cargarAvisoParaEdicion = async (id: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ad = await AdsV2Service.getById(id);
|
||||
|
||||
// Determinamos la categoría para cargar las tarifas correspondientes
|
||||
const categoryCode = ad.vehicleTypeID === 1 ? 'EAUTOS' : 'EMOTOS';
|
||||
|
||||
const categoryCode = ad.vehicleTypeID === 1 ? "EAUTOS" : "EMOTOS";
|
||||
setCategorySelection(categoryCode);
|
||||
|
||||
// 🟢 FIX: Bloquear el cambio de categoría
|
||||
// Bloquear el cambio de categoría
|
||||
setFixedCategory(categoryCode);
|
||||
|
||||
// 🟢 FIX: NO seleccionamos plan automáticamente.
|
||||
// Dejamos que el usuario elija el plan en las tarjetas.
|
||||
// (Eliminamos todo el bloque de setPlanSeleccionado)
|
||||
|
||||
/* BLOQUE ELIMINADO:
|
||||
const tarifasData = await AvisosService.obtenerConfiguracion('EMOTORES', ad.isFeatured ? 1 : 0);
|
||||
const tarifaReal = tarifasData[0];
|
||||
if (!tarifaReal) throw new Error("Tarifa no encontrada");
|
||||
setPlanSeleccionado({ ... });
|
||||
/**
|
||||
* 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) {
|
||||
console.error(err);
|
||||
console.error("Error cargando aviso para edición:", err);
|
||||
setError("Error al cargar el aviso.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -71,8 +109,8 @@ export default function PublicarAvisoPage() {
|
||||
setError(null);
|
||||
try {
|
||||
const [simple, destacado] = await Promise.all([
|
||||
AvisosService.obtenerConfiguracion('EMOTORES', 0),
|
||||
AvisosService.obtenerConfiguracion('EMOTORES', 1)
|
||||
AvisosService.obtenerConfiguracion("EMOTORES", 0),
|
||||
AvisosService.obtenerConfiguracion("EMOTORES", 1),
|
||||
]);
|
||||
|
||||
const planes = [...simple, ...destacado];
|
||||
@@ -90,41 +128,75 @@ export default function PublicarAvisoPage() {
|
||||
}, [categorySelection]);
|
||||
|
||||
const handleSelectPlan = (plan: DatosAvisoDto) => {
|
||||
const vehicleTypeId = categorySelection === 'EAUTOS' ? 1 : 2;
|
||||
const nombrePlanAmigable = plan.paquete === 1 ? 'PLAN DESTACADO' : 'PLAN ESTÁNDAR';
|
||||
const vehicleTypeId = categorySelection === "EAUTOS" ? 1 : 2;
|
||||
const nombrePlanAmigable =
|
||||
plan.paquete === 1 ? "PLAN DESTACADO" : "PLAN ESTÁNDAR";
|
||||
|
||||
setPlanSeleccionado({
|
||||
...plan,
|
||||
idRubro: vehicleTypeId,
|
||||
nomavi: nombrePlanAmigable
|
||||
nomavi: nombrePlanAmigable,
|
||||
});
|
||||
};
|
||||
|
||||
// Manejador centralizado de éxito
|
||||
const handleSuccess = (adId: number, isAdminAction: boolean = false) => {
|
||||
const status = isAdminAction ? 'admin_created' : 'approved';
|
||||
const status = isAdminAction ? "admin_created" : "approved";
|
||||
navigate(`/pago-confirmado?status=${status}&adId=${adId}`);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20 min-h-[60vh]">
|
||||
<LoginModal onSuccess={(u) => setUser(u)} onClose={() => navigate('/')} />
|
||||
<div className="container mx-auto px-6 py-24 text-center animate-fade-in-up">
|
||||
<div className="bg-[#111318]/90 backdrop-blur-3xl p-12 rounded-[3rem] max-w-2xl mx-auto border border-white/5 shadow-2xl">
|
||||
<span className="text-7xl mb-8 block">🔒</span>
|
||||
<h2 className="text-5xl font-black mb-4 uppercase tracking-tighter">
|
||||
Registrate o Inicia Sesión
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-10 text-lg italic">
|
||||
Para publicar tus avisos, primero debes registrarte o iniciar sesión.
|
||||
</p>
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => setShowLoginModal(true)}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white px-12 py-5 rounded-[2rem] font-bold uppercase tracking-widest transition-all inline-block shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
Registrarse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLoginModal && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-xl animate-fade-in p-4">
|
||||
<div className="relative w-full max-w-md">
|
||||
<LoginModal
|
||||
initialMode="REGISTER"
|
||||
onSuccess={(u) => {
|
||||
login(u);
|
||||
setShowLoginModal(false);
|
||||
}}
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ELIMINADO: Bloque if (publicacionExitosa) { return ... }
|
||||
|
||||
if (planSeleccionado) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto py-8 px-6">
|
||||
<header className="flex justify-between items-center mb-10">
|
||||
<button onClick={() => setPlanSeleccionado(null)} className="text-gray-500 hover:text-white uppercase text-[10px] font-black tracking-widest flex items-center gap-2 transition-colors">
|
||||
<button
|
||||
onClick={() => setPlanSeleccionado(null)}
|
||||
className="text-gray-500 hover:text-white uppercase text-[10px] font-black tracking-widest flex items-center gap-2 transition-colors"
|
||||
>
|
||||
← Volver a Planes
|
||||
</button>
|
||||
<div className="glass px-4 py-2 rounded-xl text-xs border border-white/5">
|
||||
Publicando como: <span className="text-blue-400 font-bold">{user.username}</span>
|
||||
Publicando como:{" "}
|
||||
<span className="text-blue-400 font-bold">{user.username}</span>
|
||||
</div>
|
||||
</header>
|
||||
<FormularioAviso
|
||||
@@ -140,13 +212,17 @@ export default function PublicarAvisoPage() {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 md:px-6 py-8 md:py-12">
|
||||
<header className="mb-8 md:mb-16 text-center md:text-left">
|
||||
<h2 className="text-3xl md:text-6xl font-black tracking-tighter uppercase mb-2">Comienza a <span className="text-gradient">Vender</span></h2>
|
||||
<p className="text-gray-500 text-sm md:text-lg italic">Selecciona una categoría para ver los planes de publicación.</p>
|
||||
<h2 className="text-3xl md:text-6xl font-black tracking-tighter uppercase mb-2">
|
||||
Comienza a <span className="text-gradient">Vender</span>
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm md:text-lg italic">
|
||||
Selecciona una categoría para ver los planes de publicación.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* SECCIÓN DE CATEGORÍA */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8 mb-10 md:mb-20 text-white">
|
||||
{TAREAS_DISPONIBLES.map(t => {
|
||||
{TAREAS_DISPONIBLES.map((t) => {
|
||||
// Lógica de bloqueo
|
||||
const isDisabled = fixedCategory && fixedCategory !== t.id;
|
||||
|
||||
@@ -157,16 +233,24 @@ export default function PublicarAvisoPage() {
|
||||
disabled={!!isDisabled} // Deshabilitar botón
|
||||
className={`
|
||||
glass-card p-6 md:p-10 rounded-[2rem] md:rounded-[2.5rem] flex items-center justify-between group transition-all text-left
|
||||
${categorySelection === t.id ? 'border-blue-500 scale-[1.02] shadow-2xl shadow-blue-600/10 bg-white/5' : 'hover:bg-white/5'}
|
||||
${isDisabled ? 'opacity-30 cursor-not-allowed grayscale' : 'cursor-pointer'}
|
||||
${categorySelection === t.id ? "border-blue-500 scale-[1.02] shadow-2xl shadow-blue-600/10 bg-white/5" : "hover:bg-white/5"}
|
||||
${isDisabled ? "opacity-30 cursor-not-allowed grayscale" : "cursor-pointer"}
|
||||
`}
|
||||
>
|
||||
<div>
|
||||
<span className="text-[10px] md:text-xs font-black uppercase tracking-widest text-blue-400 mb-1 md:mb-2 block">Categoría</span>
|
||||
<h3 className="text-2xl md:text-4xl font-bold mb-1 md:mb-2 uppercase tracking-tight">{t.label}</h3>
|
||||
<p className="text-gray-500 font-light text-xs md:text-sm">{t.description}</p>
|
||||
<span className="text-[10px] md:text-xs font-black uppercase tracking-widest text-blue-400 mb-1 md:mb-2 block">
|
||||
Categoría
|
||||
</span>
|
||||
<h3 className="text-2xl md:text-4xl font-bold mb-1 md:mb-2 uppercase tracking-tight">
|
||||
{t.label}
|
||||
</h3>
|
||||
<p className="text-gray-500 font-light text-xs md:text-sm">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-4xl md:text-6xl group-hover:scale-110 transition-transform duration-300">{t.icon}</span>
|
||||
<span className="text-4xl md:text-6xl group-hover:scale-110 transition-transform duration-300">
|
||||
{t.icon}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -177,25 +261,36 @@ export default function PublicarAvisoPage() {
|
||||
<section className="animate-fade-in-up">
|
||||
<div className="flex justify-between items-end mb-10 border-b border-white/5 pb-4">
|
||||
<div>
|
||||
<h3 className="text-3xl font-black uppercase tracking-tighter">Planes <span className="text-blue-400">Disponibles</span></h3>
|
||||
<p className="text-gray-500 text-xs mt-1 uppercase tracking-widest">Para {categorySelection === 'EAUTOS' ? 'Automóviles' : 'Motos'}</p>
|
||||
<h3 className="text-3xl font-black uppercase tracking-tighter">
|
||||
Planes <span className="text-blue-400">Disponibles</span>
|
||||
</h3>
|
||||
<p className="text-gray-500 text-xs mt-1 uppercase tracking-widest">
|
||||
Para {categorySelection === "EAUTOS" ? "Automóviles" : "Motos"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-gray-600 text-[10px] font-bold uppercase tracking-widest">Precios finales (IVA Incluido)</span>
|
||||
<span className="text-gray-600 text-[10px] font-bold uppercase tracking-widest">
|
||||
Precios finales (IVA Incluido)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 text-red-400 p-6 rounded-[2rem] border border-red-500/20 mb-10 text-center">
|
||||
<p className="font-bold uppercase tracking-widest text-xs mb-2">Error de Conexión</p>
|
||||
<p className="font-bold uppercase tracking-widest text-xs mb-2">
|
||||
Error de Conexión
|
||||
</p>
|
||||
<p className="italic text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-20"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div></div>
|
||||
<div className="flex justify-center p-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{tarifas.map((tarifa, idx) => {
|
||||
const precioRaw = tarifa.importeTotsiniva > 0
|
||||
const precioRaw =
|
||||
tarifa.importeTotsiniva > 0
|
||||
? tarifa.importeTotsiniva * 1.105
|
||||
: tarifa.importeSiniva * 1.105;
|
||||
const precioFinal = Math.round(precioRaw);
|
||||
@@ -206,14 +301,20 @@ export default function PublicarAvisoPage() {
|
||||
: "Presencia esencial. Tu aviso aparecerá en el listado general de búsqueda.";
|
||||
|
||||
return (
|
||||
<div key={idx} onClick={() => handleSelectPlan(tarifa)}
|
||||
className={`glass-card p-8 rounded-[2.5rem] flex flex-col group cursor-pointer relative overflow-hidden transition-all hover:-translate-y-2 hover:shadow-2xl ${esDestacado ? 'border-blue-500/30 hover:border-blue-500 hover:shadow-blue-900/20' : 'hover:border-white/30'}`}>
|
||||
|
||||
<div className={`absolute top-0 right-0 text-white text-[9px] font-black px-6 py-2 rounded-bl-3xl uppercase tracking-widest shadow-lg ${esDestacado ? 'bg-gradient-to-bl from-blue-600 to-cyan-500 animate-glow' : 'bg-white/10 text-gray-400'}`}>
|
||||
{esDestacado ? 'RECOMENDADO' : 'BÁSICO'}
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => handleSelectPlan(tarifa)}
|
||||
className={`glass-card p-8 rounded-[2.5rem] flex flex-col group cursor-pointer relative overflow-hidden transition-all hover:-translate-y-2 hover:shadow-2xl ${esDestacado ? "border-blue-500/30 hover:border-blue-500 hover:shadow-blue-900/20" : "hover:border-white/30"}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0 right-0 text-white text-[9px] font-black px-6 py-2 rounded-bl-3xl uppercase tracking-widest shadow-lg ${esDestacado ? "bg-gradient-to-bl from-blue-600 to-cyan-500 animate-glow" : "bg-white/10 text-gray-400"}`}
|
||||
>
|
||||
{esDestacado ? "RECOMENDADO" : "BÁSICO"}
|
||||
</div>
|
||||
|
||||
<h4 className={`text-3xl font-black uppercase tracking-tighter mb-4 mt-4 ${esDestacado ? 'text-blue-400' : 'text-white'}`}>
|
||||
<h4
|
||||
className={`text-3xl font-black uppercase tracking-tighter mb-4 mt-4 ${esDestacado ? "text-blue-400" : "text-white"}`}
|
||||
>
|
||||
{tituloPlan}
|
||||
</h4>
|
||||
|
||||
@@ -224,33 +325,61 @@ export default function PublicarAvisoPage() {
|
||||
|
||||
<ul className="space-y-3">
|
||||
<li className="flex justify-between text-xs text-gray-300 items-center">
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">Plataforma</span>
|
||||
<span className="font-bold bg-white/5 px-2 py-1 rounded text-[10px]">SOLO WEB</span>
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">
|
||||
Plataforma
|
||||
</span>
|
||||
<span className="font-bold bg-white/5 px-2 py-1 rounded text-[10px]">
|
||||
SOLO WEB
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between text-xs text-gray-300 items-center">
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">Duración</span>
|
||||
<span className="font-bold">{tarifa.cantidadDias} Días</span>
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">
|
||||
Duración
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{tarifa.cantidadDias} Días
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between text-xs text-gray-300 items-center">
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">Fotos</span>
|
||||
<span className="font-bold text-green-400">Hasta 5</span>
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">
|
||||
Fotos
|
||||
</span>
|
||||
<span className="font-bold text-green-400">
|
||||
Hasta 5
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between text-xs text-gray-300 items-center">
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">Visibilidad</span>
|
||||
<span className={`font-bold ${esDestacado ? 'text-blue-400' : 'text-gray-300'}`}>{esDestacado ? 'ALTA ⭐' : 'NORMAL'}</span>
|
||||
<span className="text-gray-500 uppercase tracking-wider font-bold text-[10px]">
|
||||
Visibilidad
|
||||
</span>
|
||||
<span
|
||||
className={`font-bold ${esDestacado ? "text-blue-400" : "text-gray-300"}`}
|
||||
>
|
||||
{esDestacado ? "ALTA ⭐" : "NORMAL"}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-6 border-t border-white/5">
|
||||
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest block mb-1">Precio Final</span>
|
||||
<span className="text-gray-500 text-[10px] font-black uppercase tracking-widest block mb-1">
|
||||
Precio Final
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-black tracking-tighter text-white">
|
||||
${precioFinal.toLocaleString('es-AR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
||||
$
|
||||
{precioFinal.toLocaleString("es-AR", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-gray-500">
|
||||
ARS
|
||||
</span>
|
||||
<span className="text-xs font-bold text-gray-500">ARS</span>
|
||||
</div>
|
||||
<button className={`w-full mt-6 text-white py-4 rounded-xl font-bold uppercase text-xs tracking-widest transition-all shadow-lg ${esDestacado ? 'bg-blue-600 hover:bg-blue-500 shadow-blue-600/20' : 'bg-white/5 hover:bg-white/10'}`}>
|
||||
<button
|
||||
className={`w-full mt-6 text-white py-4 rounded-xl font-bold uppercase text-xs tracking-widest transition-all shadow-lg ${esDestacado ? "bg-blue-600 hover:bg-blue-500 shadow-blue-600/20" : "bg-white/5 hover:bg-white/10"}`}
|
||||
>
|
||||
Seleccionar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { AdsV2Service } from '../services/ads.v2.service';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import ChatModal from '../components/ChatModal';
|
||||
import { FaWhatsapp, FaMapMarkerAlt, FaInfoCircle, FaShareAlt } from 'react-icons/fa';
|
||||
import { AD_STATUSES } from '../constants/adStatuses';
|
||||
import AdStatusBadge from '../components/AdStatusBadge';
|
||||
import PremiumGallery from '../components/PremiumGallery';
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { AdsV2Service } from "../services/ads.v2.service";
|
||||
import { AuthService } from "../services/auth.service";
|
||||
import ChatModal from "../components/ChatModal";
|
||||
import {
|
||||
FaWhatsapp,
|
||||
FaMapMarkerAlt,
|
||||
FaInfoCircle,
|
||||
FaShareAlt,
|
||||
} from "react-icons/fa";
|
||||
import { AD_STATUSES } from "../constants/adStatuses";
|
||||
import AdStatusBadge from "../components/AdStatusBadge";
|
||||
import PremiumGallery from "../components/PremiumGallery";
|
||||
|
||||
export default function VehiculoDetailPage() {
|
||||
const { id } = useParams();
|
||||
@@ -43,7 +48,9 @@ export default function VehiculoDetailPage() {
|
||||
}, [id, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { viewRegistered.current = false; };
|
||||
return () => {
|
||||
viewRegistered.current = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const handleFavoriteToggle = async () => {
|
||||
@@ -52,52 +59,102 @@ export default function VehiculoDetailPage() {
|
||||
if (isFavorite) await AdsV2Service.removeFavorite(user.id, Number(id));
|
||||
else await AdsV2Service.addFavorite(user.id, Number(id)!);
|
||||
setIsFavorite(!isFavorite);
|
||||
} catch (err) { console.error(err); }
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const getWhatsAppLink = (phone: string, title: string) => {
|
||||
if (!phone) return '#';
|
||||
let number = phone.replace(/[^\d]/g, '');
|
||||
if (number.startsWith('0')) number = number.substring(1);
|
||||
if (!number.startsWith('54')) number = `549${number}`;
|
||||
if (!phone) return "#";
|
||||
let number = phone.replace(/[^\d]/g, "");
|
||||
if (number.startsWith("0")) number = number.substring(1);
|
||||
if (!number.startsWith("54")) number = `549${number}`;
|
||||
const message = `Hola, vi tu aviso "${title}" en Motores Argentinos y me interesa.`;
|
||||
return `https://wa.me/${number}?text=${encodeURIComponent(message)}`;
|
||||
};
|
||||
|
||||
const handleShare = (platform: 'wa' | 'fb' | 'copy') => {
|
||||
const handleShare = (platform: "wa" | "fb" | "copy") => {
|
||||
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!`;
|
||||
switch (platform) {
|
||||
case 'wa': window.open(`https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`, '_blank'); break;
|
||||
case 'fb': window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, '_blank'); break;
|
||||
case 'copy': navigator.clipboard.writeText(url); alert('Enlace copiado al portapapeles'); break;
|
||||
case "wa":
|
||||
window.open(
|
||||
`https://wa.me/?text=${encodeURIComponent(text + " " + url)}`,
|
||||
"_blank",
|
||||
);
|
||||
break;
|
||||
case "fb":
|
||||
window.open(
|
||||
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||
"_blank",
|
||||
);
|
||||
break;
|
||||
case "copy":
|
||||
navigator.clipboard.writeText(url);
|
||||
alert("Enlace copiado al portapapeles");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-40 gap-6">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
|
||||
<span className="text-gray-500 font-black uppercase tracking-widest text-xs animate-pulse">Cargando...</span>
|
||||
<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 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 (
|
||||
<div className="container mx-auto px-4 md:px-6 py-6 md:py-12 animate-fade-in-up">
|
||||
<nav className="flex gap-2 text-xs font-bold uppercase tracking-widest text-gray-500 mb-6 md:mb-8 items-center overflow-x-auto whitespace-nowrap">
|
||||
<Link to="/" className="hover:text-white transition-colors">Inicio</Link> /
|
||||
<Link to="/explorar" className="hover:text-white transition-colors">Explorar</Link> /
|
||||
<span className="text-blue-400 truncate">{vehicle.brand?.name} {vehicle.versionName || 'Detalle'}</span>
|
||||
<Link to="/" className="hover:text-white transition-colors">
|
||||
Inicio
|
||||
</Link>{" "}
|
||||
/
|
||||
<Link to="/explorar" className="hover:text-white transition-colors">
|
||||
Explorar
|
||||
</Link>{" "}
|
||||
/
|
||||
<span className="text-blue-400 truncate">
|
||||
{vehicle.brand?.name} {vehicle.versionName || "Detalle"}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-12 relative items-start">
|
||||
|
||||
{/* COLUMNA IZQUIERDA: Galería + Descripción (Desktop) */}
|
||||
<div className="lg:col-span-2 space-y-8 md:space-y-12 order-1 lg:order-1">
|
||||
<PremiumGallery
|
||||
@@ -106,8 +163,21 @@ export default function VehiculoDetailPage() {
|
||||
isFavorite={isFavorite}
|
||||
onFavoriteToggle={handleFavoriteToggle}
|
||||
statusBadge={<AdStatusBadge statusId={vehicle.statusID} />}
|
||||
featuredBadge={vehicle.isFeatured && <span className="bg-gradient-to-r from-blue-600 to-cyan-500 text-white px-3 md:px-4 py-1 md:py-1.5 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-widest shadow-lg animate-glow flex items-center gap-1">⭐ DESTACADO</span>}
|
||||
locationBadge={vehicle.location && <span className="bg-black/60 backdrop-blur-md text-white px-3 md:px-4 py-1 md:py-1.5 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-widest border border-white/10 flex items-center gap-1.5"><FaMapMarkerAlt className="text-blue-500" /> {vehicle.location}</span>}
|
||||
featuredBadge={
|
||||
vehicle.isFeatured && (
|
||||
<span className="bg-gradient-to-r from-blue-600 to-cyan-500 text-white px-3 md:px-4 py-1 md:py-1.5 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-widest shadow-lg animate-glow flex items-center gap-1">
|
||||
⭐ DESTACADO
|
||||
</span>
|
||||
)
|
||||
}
|
||||
locationBadge={
|
||||
vehicle.location && (
|
||||
<span className="bg-black/60 backdrop-blur-md text-white px-3 md:px-4 py-1 md:py-1.5 rounded-full text-[9px] md:text-[10px] font-black uppercase tracking-widest border border-white/10 flex items-center gap-1.5">
|
||||
<FaMapMarkerAlt className="text-blue-500" />{" "}
|
||||
{vehicle.location}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* BLOQUE 3: Información General y Técnica (Acomodado debajo de la galería) */}
|
||||
@@ -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">
|
||||
<span className="text-2xl">📝</span>
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-black uppercase tracking-tighter text-white">Descripción del <span className="text-blue-500">Vendedor</span></h3>
|
||||
<h3 className="text-2xl md:text-3xl font-black uppercase tracking-tighter text-white">
|
||||
Descripción del{" "}
|
||||
<span className="text-blue-500">Vendedor</span>
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-300 leading-relaxed font-light whitespace-pre-wrap text-base md:text-lg">
|
||||
{vehicle.description}
|
||||
@@ -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">
|
||||
<FaInfoCircle />
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-black uppercase tracking-tighter text-white">Información <span className="text-blue-500">Técnica</span></h3>
|
||||
<h3 className="text-2xl md:text-3xl font-black uppercase tracking-tighter text-white">
|
||||
Información <span className="text-blue-500">Técnica</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 md:gap-8">
|
||||
<TechnicalItem label="Kilómetros" value={`${vehicle.km?.toLocaleString()} KM`} icon="🏎️" />
|
||||
<TechnicalItem label="Combustible" value={vehicle.fuelType} icon="⛽" />
|
||||
<TechnicalItem label="Transmisión" value={vehicle.transmission} icon="⚙️" />
|
||||
<TechnicalItem
|
||||
label="Kilómetros"
|
||||
value={`${vehicle.km?.toLocaleString()} KM`}
|
||||
icon="🏎️"
|
||||
/>
|
||||
<TechnicalItem
|
||||
label="Combustible"
|
||||
value={vehicle.fuelType}
|
||||
icon="⛽"
|
||||
/>
|
||||
<TechnicalItem
|
||||
label="Transmisión"
|
||||
value={vehicle.transmission}
|
||||
icon="⚙️"
|
||||
/>
|
||||
<TechnicalItem label="Color" value={vehicle.color} icon="🎨" />
|
||||
<TechnicalItem label="Segmento" value={vehicle.segment} icon="🚗" />
|
||||
{vehicle.condition && <TechnicalItem label="Estado" value={vehicle.condition} icon="✨" />}
|
||||
{vehicle.doorCount && <TechnicalItem label="Puertas" value={vehicle.doorCount} icon="🚪" />}
|
||||
{vehicle.engineSize && <TechnicalItem label="Motor" value={vehicle.engineSize} icon="⚡" />}
|
||||
<TechnicalItem
|
||||
label="Segmento"
|
||||
value={vehicle.segment}
|
||||
icon="🚗"
|
||||
/>
|
||||
{vehicle.condition && (
|
||||
<TechnicalItem
|
||||
label="Estado"
|
||||
value={vehicle.condition}
|
||||
icon="✨"
|
||||
/>
|
||||
)}
|
||||
{vehicle.doorCount && (
|
||||
<TechnicalItem
|
||||
label="Puertas"
|
||||
value={vehicle.doorCount}
|
||||
icon="🚪"
|
||||
/>
|
||||
)}
|
||||
{vehicle.engineSize && (
|
||||
<TechnicalItem
|
||||
label="Motor"
|
||||
value={vehicle.engineSize}
|
||||
icon="⚡"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,7 +267,9 @@ export default function VehiculoDetailPage() {
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-black tracking-tighter uppercase leading-tight mb-4 text-white">
|
||||
<span className="text-blue-500 mr-2">{vehicle.brand?.name}</span>
|
||||
<span className="text-blue-500 mr-2">
|
||||
{vehicle.brand?.name}
|
||||
</span>
|
||||
{vehicle.versionName}
|
||||
</h1>
|
||||
|
||||
@@ -169,57 +280,99 @@ export default function VehiculoDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-white/5 to-transparent rounded-2xl md:rounded-3xl p-6 md:p-8 mb-8 border border-white/10 shadow-inner group">
|
||||
<span className="text-gray-500 text-[10px] font-black tracking-widest uppercase block mb-1 opacity-60">Precio</span>
|
||||
<span className="text-gray-500 text-[10px] font-black tracking-widest uppercase block mb-1 opacity-60">
|
||||
Precio
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-blue-400 text-3xl md:text-5xl font-black tracking-tighter">{vehicle.currency} {vehicle.price?.toLocaleString()}</span>
|
||||
<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>
|
||||
|
||||
{isContactable ? (
|
||||
<div className="space-y-4">
|
||||
<a href={getWhatsAppLink(vehicle.contactPhone, `${vehicle.brand?.name} ${vehicle.versionName}`)} target="_blank" rel="noopener noreferrer"
|
||||
className="w-full glass border border-green-500/30 hover:bg-green-600 text-white py-5 rounded-2xl font-black uppercase tracking-widest transition-all shadow-lg shadow-green-600/20 flex items-center justify-center gap-3 group hover:border-green-500/50">
|
||||
{/* BOTÓN WHATSAPP: Usamos la nueva variable canShowWhatsApp */}
|
||||
{canShowWhatsApp && (
|
||||
<a
|
||||
href={getWhatsAppLink(
|
||||
vehicle.contactPhone,
|
||||
`${vehicle.brand?.name} ${vehicle.versionName}`,
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full glass border border-green-500/30 hover:bg-green-600 text-white py-5 rounded-2xl font-black uppercase tracking-widest transition-all shadow-lg shadow-green-600/20 flex items-center justify-center gap-3 group hover:border-green-500/50"
|
||||
>
|
||||
<FaWhatsapp className="text-3xl group-hover:scale-110 transition-transform text-green-400 group-hover:text-white" />
|
||||
<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">
|
||||
<span className="text-blue-400">📞</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 className="bg-white/5 border border-white/10 rounded-2xl p-6 text-center">
|
||||
<div className="text-3xl mb-3">
|
||||
{isOwnerAdmin ? 'ℹ️' :
|
||||
vehicle.statusID === AD_STATUSES.MODERATION_PENDING ? '⏳' :
|
||||
vehicle.statusID === AD_STATUSES.PAYMENT_PENDING ? '💳' :
|
||||
vehicle.statusID === AD_STATUSES.SOLD ? '🤝' : '🔒'}
|
||||
{isOwnerAdmin
|
||||
? "ℹ️"
|
||||
: vehicle.statusID === AD_STATUSES.MODERATION_PENDING
|
||||
? "⏳"
|
||||
: vehicle.statusID === AD_STATUSES.PAYMENT_PENDING
|
||||
? "💳"
|
||||
: vehicle.statusID === AD_STATUSES.SOLD
|
||||
? "🤝"
|
||||
: "🔒"}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white uppercase tracking-tight mb-2">
|
||||
{isOwnerAdmin ? 'Contacto en descripción' :
|
||||
vehicle.statusID === AD_STATUSES.MODERATION_PENDING ? 'Aviso en Revisión' :
|
||||
vehicle.statusID === AD_STATUSES.PAYMENT_PENDING ? 'Pago Pendiente' :
|
||||
vehicle.statusID === AD_STATUSES.SOLD ? 'Vehículo Vendido' : 'No disponible'}
|
||||
{isOwnerAdmin
|
||||
? "Contacto en descripción"
|
||||
: vehicle.statusID === AD_STATUSES.MODERATION_PENDING
|
||||
? "Aviso en Revisión"
|
||||
: vehicle.statusID === AD_STATUSES.PAYMENT_PENDING
|
||||
? "Pago Pendiente"
|
||||
: vehicle.statusID === AD_STATUSES.SOLD
|
||||
? "Vehículo Vendido"
|
||||
: "No disponible"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 leading-relaxed">
|
||||
{isOwnerAdmin
|
||||
? 'Revisa la descripción para contactar al vendedor.'
|
||||
? "Revisa la descripción para contactar al vendedor."
|
||||
: vehicle.statusID === AD_STATUSES.MODERATION_PENDING
|
||||
? 'Este aviso está siendo verificado por un moderador. Estará activo pronto.'
|
||||
? "Este aviso está siendo verificado por un moderador. Estará activo pronto."
|
||||
: vehicle.statusID === AD_STATUSES.PAYMENT_PENDING
|
||||
? 'El pago de este aviso aún no ha sido procesado completamente.'
|
||||
? "El pago de este aviso aún no ha sido procesado completamente."
|
||||
: vehicle.statusID === AD_STATUSES.SOLD
|
||||
? 'Este vehículo ya ha sido vendido a otro usuario.'
|
||||
: 'Este vehículo ya no se encuentra disponible para la venta.'}
|
||||
? "Este vehículo ya ha sido vendido a otro usuario."
|
||||
: "Revisa la descripción para contactar al vendedor."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => handleShare('copy')} className="w-full mt-6 py-4 rounded-xl border border-white/5 text-[9px] font-black uppercase tracking-[0.2em] text-gray-500 hover:text-white hover:bg-white/5 transition-all flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => handleShare("copy")}
|
||||
className="w-full mt-6 py-4 rounded-xl border border-white/5 text-[9px] font-black uppercase tracking-[0.2em] text-gray-500 hover:text-white hover:bg-white/5 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<FaShareAlt /> Compartir Aviso
|
||||
</button>
|
||||
</div>
|
||||
@@ -240,11 +393,22 @@ export default function VehiculoDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function TechnicalItem({ label, value, icon }: { label: string, value: string, icon: string }) {
|
||||
if ((value === undefined || value === null || value === '') || value === 'N/A') return null;
|
||||
function TechnicalItem({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: string;
|
||||
}) {
|
||||
if (value === undefined || value === null || value === "" || value === "N/A")
|
||||
return null;
|
||||
return (
|
||||
<div className="bg-white/5 p-4 rounded-xl md:rounded-2xl border border-white/5 hover:bg-white/10 transition-colors">
|
||||
<span className="text-gray-500 text-[9px] md:text-[10px] uppercase font-black tracking-widest block mb-1">{label}</span>
|
||||
<span className="text-gray-500 text-[9px] md:text-[10px] uppercase font-black tracking-widest block mb-1">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm md:text-base">{icon}</span>
|
||||
<span className="text-white font-bold text-xs md:text-sm">{value}</span>
|
||||
|
||||
@@ -4,56 +4,45 @@ const BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
withCredentials: true, // Importante para enviar Cookies
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor de Respuesta (Manejo de Errores y Refresh)
|
||||
// Interceptor de Respuesta
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Condición para intentar refresh:
|
||||
// 1. Es error 401 (Unauthorized)
|
||||
// 2. No hemos reintentado ya esta petición (_retry no es true)
|
||||
// 3. 🛑 IMPORTANTE: La URL que falló NO es la de refresh-token (evita bucle)
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.url.includes('/auth/refresh-token')
|
||||
!originalRequest.url.includes('/auth/refresh-token')&&
|
||||
!originalRequest.url.includes('/auth/login')
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Intentamos renovar el token
|
||||
await apiClient.post('/auth/refresh-token');
|
||||
|
||||
// Si el refresh funciona, reintentamos la petición original
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Si el refresh falla (401 o cualquier otro), no hay nada que hacer.
|
||||
// Forzamos logout en el cliente para limpiar basura
|
||||
localStorage.removeItem('session');
|
||||
localStorage.removeItem('userProfile');
|
||||
// SI FALLA EL REFRESH (Sesión caducada definitivamente)
|
||||
|
||||
// Opcional: Redirigir a login o recargar para limpiar estado
|
||||
// window.location.href = '/';
|
||||
// Limpiamos todo rastro de sesión local
|
||||
localStorage.removeItem('userProfile');
|
||||
localStorage.removeItem('session');
|
||||
|
||||
// Redirigir a home y recargar para limpiar estado de React
|
||||
// Esto asegura que visualmente se vea deslogueado
|
||||
window.location.href = '/';
|
||||
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(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;
|
||||
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.
|
||||
*/
|
||||
export const formatCurrency = (amount: number, currency: string = 'ARS') => {
|
||||
// Lógica para precio 0
|
||||
if (amount === 0) {
|
||||
return 'CONSULTAR';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
|
||||
@@ -5,38 +5,41 @@ services:
|
||||
dockerfile: Backend/Dockerfile.API
|
||||
container_name: motores-backend
|
||||
restart: always
|
||||
# Eliminamos ports para que NO sea accesible desde afuera, solo por motores-frontend
|
||||
env_file:
|
||||
- Backend/MotoresArgentinosV2.API/.env
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- ASPNETCORE_HTTP_PORTS=8080
|
||||
# Soportamos ambos: el dominio final y la IP de pruebas para CORS
|
||||
- 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__FrontendUrl=https://motoresargentinos.com,https://www.motoresargentinos.com,http://192.168.5.129:8086,http://localhost:5173,https://clasificados.eldia.com
|
||||
- AppSettings__BaseUrl=http://192.168.5.129:8086/api
|
||||
- AppSettings__SitemapOutputPath=/app/sitemap-output/sitemap.xml
|
||||
networks:
|
||||
- motores-network
|
||||
volumes:
|
||||
- /mnt/MotoresImg:/app/wwwroot/uploads
|
||||
- sitemap-data:/app/sitemap-output
|
||||
|
||||
motores-frontend:
|
||||
build:
|
||||
context: ./Frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Al usar Nginx como proxy, podemos usar rutas relativas desde el navegador
|
||||
- VITE_API_BASE_URL=/api
|
||||
- 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
|
||||
restart: always
|
||||
ports:
|
||||
- "8086:80" # Puerto libre detectado en el análisis de Portainer
|
||||
- "8086:80"
|
||||
depends_on:
|
||||
- motores-backend
|
||||
networks:
|
||||
- motores-network
|
||||
volumes:
|
||||
- sitemap-data:/usr/share/nginx/html/sitemap-data
|
||||
|
||||
volumes:
|
||||
sitemap-data:
|
||||
|
||||
networks:
|
||||
motores-network:
|
||||
|
||||
Reference in New Issue
Block a user