Sistema de Notificaciones y Baja One-Click
This commit is contained in:
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/
|
||||
@@ -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 ---
|
||||
@@ -160,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)
|
||||
{
|
||||
@@ -197,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." });
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
@@ -39,26 +47,35 @@ public class ChatController : ControllerBase
|
||||
|
||||
if (receiver != null && !string.IsNullOrEmpty(receiver.Email))
|
||||
{
|
||||
// LÓGICA DE NOMBRE DE REMITENTE
|
||||
string senderDisplayName;
|
||||
|
||||
if (sender != null && sender.UserType == 3) // 3 = ADMIN
|
||||
// Solo enviar correo si la preferencia "mensajes" está habilitada
|
||||
if (await _prefService.IsEnabledAsync(receiver.UserID, NotificationCategory.Mensajes))
|
||||
{
|
||||
// Caso: Moderador escribe a Usuario
|
||||
senderDisplayName = "Un moderador de Motores Argentinos";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Caso: Usuario responde a Moderador
|
||||
string name = sender?.UserName ?? "Un usuario";
|
||||
senderDisplayName = $"El usuario {name}";
|
||||
}
|
||||
// LÓGICA DE NOMBRE DE REMITENTE
|
||||
string senderDisplayName;
|
||||
|
||||
await _notificationService.SendChatNotificationEmailAsync(
|
||||
receiver.Email,
|
||||
senderDisplayName, // Pasamos el nombre formateado
|
||||
msg.MessageText,
|
||||
msg.AdID);
|
||||
if (sender != null && sender.UserType == 3) // 3 = ADMIN
|
||||
{
|
||||
// Caso: Moderador escribe a Usuario
|
||||
senderDisplayName = "Un moderador de Motores Argentinos";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Caso: Usuario responde a Moderador
|
||||
string name = sender?.UserName ?? "Un usuario";
|
||||
senderDisplayName = $"El usuario {name}";
|
||||
}
|
||||
|
||||
// Generamos el token de baja para la categoría "mensajes"
|
||||
var rawToken = await _prefService.GetOrCreateUnsubscribeTokenAsync(receiver.UserID, NotificationCategory.Mensajes);
|
||||
var unsubscribeUrl = $"{_frontendUrl}/baja/procesar?token={Uri.EscapeDataString(rawToken)}";
|
||||
|
||||
await _notificationService.SendChatNotificationEmailAsync(
|
||||
receiver.Email,
|
||||
senderDisplayName, // Pasamos el nombre formateado
|
||||
msg.MessageText,
|
||||
msg.AdID,
|
||||
unsubscribeUrl); // Se incluye URL de baja en el footer
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -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" ?? "https://clasificados.eldia.com").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",
|
||||
@@ -109,9 +110,10 @@ 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>();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -241,4 +241,61 @@ public class AdViewLog
|
||||
public int AdID { get; set; }
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 CheckExpiredAdsAsync()
|
||||
private async Task CleanupUnsubscribeTokensAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
|
||||
|
||||
// Borramos tokens que ya expiraron o que ya fueron usados
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var deletedCount = await context.UnsubscribeTokens
|
||||
.Where(t => t.ExpiresAt <= now || t.IsUsed)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (deletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Mantenimiento: Se eliminaron {Count} tokens de baja expirados o usados.", deletedCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckExpiredAdsAsync()
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
var prefService = scope.ServiceProvider.GetRequiredService<INotificationPreferenceService>();
|
||||
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
|
||||
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-30);
|
||||
|
||||
@@ -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);
|
||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||
// 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
|
||||
@@ -129,8 +161,11 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
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);
|
||||
|
||||
@@ -150,12 +185,22 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
||||
|
||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||
var expDate = ad.PublishedAt!.Value.AddDays(30);
|
||||
|
||||
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 */ }
|
||||
@@ -169,8 +214,11 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
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}";
|
||||
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;
|
||||
@@ -212,11 +273,11 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
|
||||
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
var frontendUrl = config["AppSettings:FrontendUrl"]?.Split(',')[0].Trim() ?? "http://localhost:5173";
|
||||
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 cutoff = DateTime.UtcNow.AddHours(-24);
|
||||
|
||||
@@ -236,10 +297,19 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
|
||||
|
||||
var title = $"{ad.Brand?.Name} {ad.VersionName}";
|
||||
var link = $"{frontendUrl}/publicar?edit={ad.AdID}";
|
||||
// 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;
|
||||
}
|
||||
@@ -252,10 +322,13 @@ public class AdExpirationService : BackgroundService
|
||||
{
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
|
||||
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
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)
|
||||
// Buscar usuarios que tengan mensajes no leídos viejos (> 4 horas)
|
||||
// y que no hayan sido notificados en las últimas 24 horas.
|
||||
|
||||
var messageThreshold = DateTime.UtcNow.AddHours(-4);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -18,8 +18,28 @@ public class NotificationService : INotificationService
|
||||
_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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 />
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user