Init Commit

This commit is contained in:
2026-01-29 13:43:44 -03:00
commit b9aa8478db
126 changed files with 20649 additions and 0 deletions

94
.gitignore vendored Normal file
View File

@@ -0,0 +1,94 @@
# ----------------------------------------------------------------------------
# ## General / Sistema Operativo y Editores ##
# ----------------------------------------------------------------------------
# Archivos de caché del sistema operativo
.DS_Store
Thumbs.db
# Archivos de configuración y caché de IDEs y editores
.idea/
.vscode/
*.suo
*.user
*.userosscache
*.sln.docstates
# Archivos de log
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Archivos de variables de entorno locales
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# ----------------------------------------------------------------------------
# ## Backend: .NET / C# (Carpeta ChatbotApi/) ##
# ----------------------------------------------------------------------------
# Carpetas de compilación y binarios. Se generan al compilar.
[Bb]in/
[Oo]bj/
# Configuración de desarrollo que puede contener secretos.
# Es mejor usar "User Secrets" en desarrollo para las claves.
appsettings.Development.json
# Archivos de publicación de Visual Studio
[Pp]roperties/[Pp]ublish[Pp]rofiles/
*.pubxml
*.publish.xml
# Directorio de paquetes de NuGet (formato antiguo)
[Pp]ackages/
# Archivos de caché de Visual Studio
.vs/
# Herramientas de .NET
.dotnet/
tools/
# Archivos generados por Entity Framework
# Nota: La carpeta 'Migrations' SÍ se suele incluir en el repositorio,
# pero si tienes algún archivo temporal o generado dentro, puedes añadirlo aquí.
# ----------------------------------------------------------------------------
# ## Frontend: React / Vite / Node.js ##
# ----------------------------------------------------------------------------
# Dependencias (se instalan con 'npm install' o 'yarn')
node_modules/
# Carpetas de build (se generan con 'npm run build')
dist/
build/
.next/
out/
# Archivos de caché de herramientas de frontend
.npm/
.vite/
.cache/
.eslintcache
# Reportes de cobertura de tests
coverage/
# Archivos de configuración de proxy de Create React App
.proxyrc.js
# Backups de DB
.bak
#Documentación
*.pdf
*.txt
#Directorio de Imagenes
Backend/MotoresArgentinosV2.API/wwwroot

31
Backend/Dockerfile.API Normal file
View File

@@ -0,0 +1,31 @@
# Etapa de construcción
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copiar archivos .sln y .csproj para restaurar dependencias
COPY MotoresArgentinosV2.sln ./
COPY Backend/MotoresArgentinosV2.API/*.csproj Backend/MotoresArgentinosV2.API/
COPY Backend/MotoresArgentinosV2.Core/*.csproj Backend/MotoresArgentinosV2.Core/
COPY Backend/MotoresArgentinosV2.Infrastructure/*.csproj Backend/MotoresArgentinosV2.Infrastructure/
COPY Backend/MotoresArgentinosV2.MigrationTool/*.csproj Backend/MotoresArgentinosV2.MigrationTool/
RUN dotnet restore
# Copiar el resto del código y construir
COPY . .
WORKDIR "/src/Backend/MotoresArgentinosV2.API"
RUN dotnet build "MotoresArgentinosV2.API.csproj" -c Release -o /app/build
# Publicar
FROM build AS publish
RUN dotnet publish "MotoresArgentinosV2.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Etapa final: Ejecución
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=publish /app/publish .
# Exponer el puerto configurado (ASP.NET 10 usa 8080 por defecto en contenedores)
EXPOSE 8080
ENTRYPOINT ["dotnet", "MotoresArgentinosV2.API.dll"]

View File

@@ -0,0 +1,526 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Infrastructure.Data;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using Microsoft.AspNetCore.Authorization;
using MotoresArgentinosV2.Core.DTOs;
namespace MotoresArgentinosV2.API.Controllers;
[Authorize(Roles = "Admin")]
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
private readonly MotoresV2DbContext _context;
private readonly IAdSyncService _syncService;
private readonly INotificationService _notificationService;
public AdminController(MotoresV2DbContext context, IAdSyncService syncService, INotificationService notificationService)
{
_context = context;
_syncService = syncService;
_notificationService = notificationService;
}
// --- MODERACIÓN ---
// GESTIÓN GLOBAL DE AVISOS (Buscador Admin)
[HttpGet("ads")]
public async Task<IActionResult> GetAllAds(
[FromQuery] string? q = null,
[FromQuery] int? statusId = null,
[FromQuery] int page = 1)
{
const int pageSize = 20;
var query = _context.Ads
.Include(a => a.Brand)
.Include(a => a.User)
.Include(a => a.Photos) // Para mostrar la miniatura
.AsNoTracking() // Optimización de lectura
.AsQueryable();
// Filtro por Texto (Marca, Modelo, Email Usuario, Nombre Usuario)
if (!string.IsNullOrEmpty(q))
{
query = query.Where(a =>
a.VersionName!.Contains(q) ||
a.Brand.Name.Contains(q) ||
a.User.Email.Contains(q) ||
a.User.UserName.Contains(q)
);
}
// Filtro por Estado
if (statusId.HasValue)
{
query = query.Where(a => a.StatusID == statusId.Value);
}
var total = await query.CountAsync();
var ads = await query
.OrderByDescending(a => a.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new
{
a.AdID,
BrandName = a.Brand != null ? a.Brand.Name : null,
VersionName = a.VersionName,
StatusID = a.StatusID,
CreatedAt = a.CreatedAt,
PublishedAt = a.PublishedAt,
ExpiresAt = a.ExpiresAt,
DeletedAt = a.DeletedAt,
Views = a.ViewsCounter,
LegacyID = a.LegacyAdID,
// Datos de la transacción APROBADA más reciente
PaidAmount = _context.Transactions
.Where(t => t.AdID == a.AdID && t.Status == "APPROVED")
.OrderByDescending(t => t.CreatedAt)
.Select(t => (decimal?)t.Amount) // Cast a nullable para detectar si no hubo pago
.FirstOrDefault(),
PaidDate = _context.Transactions
.Where(t => t.AdID == a.AdID && t.Status == "APPROVED")
.OrderByDescending(t => t.CreatedAt)
.Select(t => (DateTime?)t.CreatedAt)
.FirstOrDefault(),
UserEmail = a.User.Email,
UserName = a.User.UserName,
Thumbnail = a.Photos.Where(p => p.IsCover).Select(p => p.FilePath).FirstOrDefault()
?? a.Photos.Select(p => p.FilePath).FirstOrDefault()
})
.ToListAsync();
return Ok(new { ads, total, page, pageSize });
}
[HttpGet("ads/pending")]
public async Task<IActionResult> GetPendingAds()
{
var ads = await _context.Ads
.Include(a => a.User)
.Include(a => a.Photos)
.Where(a => a.StatusID == (int)AdStatusEnum.ModerationPending)
.OrderByDescending(a => a.CreatedAt)
.Select(a => new
{
a.AdID,
VersionName = a.Brand != null ? $"{a.Brand.Name} {a.VersionName}" : a.VersionName,
a.Price,
a.Currency,
a.CreatedAt,
UserID = a.UserID,
UserName = a.User.UserName,
Email = a.User.Email,
Thumbnail = a.Photos.Where(p => p.IsCover).Select(p => p.FilePath).FirstOrDefault() ?? a.Photos.Select(p => p.FilePath).FirstOrDefault()
})
.ToListAsync();
return Ok(ads);
}
[HttpPost("ads/{id}/approve")]
public async Task<IActionResult> ApproveAd(int id)
{
var ad = await _context.Ads.Include(a => a.User).Include(a => a.Brand).FirstOrDefaultAsync(a => a.AdID == id);
if (ad == null) return NotFound();
ad.StatusID = (int)AdStatusEnum.Active;
ad.PublishedAt = DateTime.UtcNow;
ad.ExpiresAt = DateTime.UtcNow.AddDays(30);
await _context.SaveChangesAsync();
// Audit Log
var adminId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
_context.AuditLogs.Add(new AuditLog
{
Action = "AD_APPROVED",
Entity = "Ad",
EntityID = id,
UserID = adminId,
Details = $"Aviso '{ad.Brand?.Name} {ad.VersionName}' aprobado por administrador."
});
await _context.SaveChangesAsync();
// Sincronizar a Legacy
try
{
await _syncService.SyncAdToLegacyAsync(id);
var adTitle = $"{ad.Brand?.Name} {ad.VersionName}";
await _notificationService.SendAdStatusChangedEmailAsync(ad.User?.Email ?? string.Empty, adTitle, "APROBADO");
}
catch (Exception)
{
// Logueamos pero no bloqueamos la aprobación en V2
}
return Ok(new { message = "Aviso aprobado y publicado." });
}
[HttpPost("ads/{id}/reject")]
public async Task<IActionResult> RejectAd(int id, [FromBody] string reason)
{
var ad = await _context.Ads.Include(a => a.User).Include(a => a.Brand).FirstOrDefaultAsync(a => a.AdID == id);
if (ad == null) return NotFound();
ad.StatusID = (int)AdStatusEnum.Rejected;
await _context.SaveChangesAsync();
// Audit Log
var adminId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
_context.AuditLogs.Add(new AuditLog
{
Action = "AD_REJECTED",
Entity = "Ad",
EntityID = id,
UserID = adminId,
Details = $"Aviso '{ad.Brand?.Name} {ad.VersionName}' rechazado. Motivo: {reason}"
});
await _context.SaveChangesAsync();
// Notificar rechazo
var adTitle = $"{ad.Brand?.Name} {ad.VersionName}";
await _notificationService.SendAdStatusChangedEmailAsync(ad.User?.Email ?? string.Empty, adTitle, "RECHAZADO", reason);
return Ok(new { message = "Aviso rechazado." });
}
// --- TRANSACCIONES ---
[HttpGet("transactions")]
public async Task<IActionResult> GetTransactions(
[FromQuery] string? status = null,
[FromQuery] string? userSearch = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] int page = 1)
{
const int pageSize = 20;
var query = from t in _context.Transactions
join a in _context.Ads on t.AdID equals a.AdID into adGroup
from ad in adGroup.DefaultIfEmpty() // Left Join con Ads
join u in _context.Users on ad.UserID equals u.UserID into userGroup
from user in userGroup.DefaultIfEmpty() // Left Join con Users
select new { t, ad, user };
// 1. Filtro por Estado
if (!string.IsNullOrEmpty(status))
{
query = query.Where(x => x.t.Status == status);
}
// 2. Filtro por Usuario (Búsqueda)
if (!string.IsNullOrEmpty(userSearch))
{
// Solo buscamos si el usuario existe (no es nulo)
query = query.Where(x =>
x.user != null &&
(x.user.Email.Contains(userSearch) || x.user.UserName.Contains(userSearch))
);
}
// 3. Filtro por Fechas
if (fromDate.HasValue)
{
query = query.Where(x => x.t.CreatedAt >= fromDate.Value);
}
if (toDate.HasValue)
{
var toDateEndOfDay = toDate.Value.Date.AddHours(23).AddMinutes(59).AddSeconds(59);
query = query.Where(x => x.t.CreatedAt <= toDateEndOfDay);
}
// 4. Paginación y Ejecución
var total = await query.CountAsync();
var txs = await query
.OrderByDescending(x => x.t.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.t.TransactionID,
x.t.OperationCode,
x.t.Amount,
x.t.Status,
x.t.CreatedAt,
x.t.UpdatedAt,
AdID = x.t.AdID,
// LÓGICA DE PRIORIDAD: Snapshot > Relación Viva > Default
UserID = (x.ad != null) ? x.ad.UserID : 0,
UserName = !string.IsNullOrEmpty(x.t.SnapshotUserName)
? x.t.SnapshotUserName
: ((x.user != null) ? x.user.UserName : "ELIMINADO"),
UserEmail = !string.IsNullOrEmpty(x.t.SnapshotUserEmail)
? x.t.SnapshotUserEmail
: ((x.user != null) ? x.user.Email : "-"),
AdTitle = !string.IsNullOrEmpty(x.t.SnapshotAdTitle)
? x.t.SnapshotAdTitle
: ((x.ad != null && x.ad.Brand != null) ? $"{x.ad.Brand.Name} {x.ad.VersionName}" : "Vehículo Desconocido/Eliminado")
})
.ToListAsync();
return Ok(new { transactions = txs, total, page, pageSize });
}
// --- USUARIOS ---
[HttpGet("users")]
public async Task<IActionResult> GetUsers([FromQuery] string? q = null, [FromQuery] int page = 1)
{
const int pageSize = 20;
var query = _context.Users.AsQueryable();
if (!string.IsNullOrEmpty(q))
{
query = query.Where(u => (u.Email ?? "").Contains(q) ||
(u.UserName ?? "").Contains(q) ||
(u.FirstName ?? "").Contains(q) ||
(u.LastName ?? "").Contains(q));
}
var total = await query.CountAsync();
var users = await query
.OrderByDescending(u => u.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(u => new
{
u.UserID,
u.UserName,
u.Email,
u.FirstName,
u.LastName,
u.UserType,
u.MigrationStatus,
u.IsBlocked,
u.CreatedAt
})
.ToListAsync();
return Ok(new { users, total, page, pageSize });
}
[HttpGet("users/{id}")]
public async Task<IActionResult> GetUserDetail(int id)
{
var user = await _context.Users
.Where(u => u.UserID == id)
.Select(u => new UserDetailDto
{
UserID = u.UserID,
UserName = u.UserName,
Email = u.Email,
FirstName = u.FirstName,
LastName = u.LastName,
PhoneNumber = u.PhoneNumber,
UserType = u.UserType,
IsBlocked = u.IsBlocked,
MigrationStatus = u.MigrationStatus,
IsEmailVerified = u.IsEmailVerified,
CreatedAt = u.CreatedAt
})
.FirstOrDefaultAsync();
if (user == null) return NotFound();
return Ok(user);
}
[HttpPut("users/{id}")]
public async Task<IActionResult> UpdateUser(int id, [FromBody] UserUpdateDto dto)
{
var user = await _context.Users.FindAsync(id);
if (user == null) return NotFound();
user.UserName = dto.UserName;
user.FirstName = dto.FirstName;
user.LastName = dto.LastName;
user.PhoneNumber = dto.PhoneNumber;
user.UserType = dto.UserType;
await _context.SaveChangesAsync();
// Audit Log
var adminId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
_context.AuditLogs.Add(new AuditLog
{
Action = "USER_UPDATED",
Entity = "User",
EntityID = id,
UserID = adminId,
Details = $"Usuario '{user.UserName}' actualizado por administrador."
});
await _context.SaveChangesAsync();
return Ok(new { message = "Usuario actualizado correctamente." });
}
[HttpGet("stats")]
public async Task<IActionResult> GetStats()
{
var stats = new
{
// 1. Usamos AuditLogs para contar TODAS las creaciones históricas,
// incluso si el aviso ya fue borrado físicamente de la tabla Ads.
TotalAds = await _context.AuditLogs.CountAsync(l => l.Action == "AD_CREATED"),
// 2. Avisos actualmente activos (Status = 4)
ActiveAds = await _context.Ads.CountAsync(a => a.StatusID == (int)AdStatusEnum.Active),
// 3. Pendientes de moderación (Status = 3)
PendingAds = await _context.Ads.CountAsync(a => a.StatusID == (int)AdStatusEnum.ModerationPending),
// 4. Total de Usuarios
TotalUsers = await _context.Users.CountAsync(),
// 5. Mensajes NO LEÍDOS (IsRead = false)
UnreadMessages = await _context.ChatMessages.CountAsync(m => !m.IsRead)
};
return Ok(stats);
}
[HttpGet("audit")]
public async Task<IActionResult> GetAuditLogs(
[FromQuery] string? actionType = null,
[FromQuery] string? entity = null,
[FromQuery] int? userId = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] int page = 1)
{
const int pageSize = 50;
var query = _context.AuditLogs.AsQueryable();
if (!string.IsNullOrEmpty(actionType))
query = query.Where(l => l.Action == actionType);
if (!string.IsNullOrEmpty(entity))
query = query.Where(l => l.Entity == entity);
if (userId.HasValue)
query = query.Where(l => l.UserID == userId.Value);
if (fromDate.HasValue)
query = query.Where(l => l.CreatedAt >= fromDate.Value);
if (toDate.HasValue)
query = query.Where(l => l.CreatedAt <= toDate.Value);
var total = await query.CountAsync();
var logs = await query
.OrderByDescending(l => l.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(l => new
{
l.AuditLogID,
l.Action,
l.Entity,
l.EntityID,
l.UserID,
l.Details,
l.CreatedAt,
UserName = l.UserID == 0 ? "Sistema" : _context.Users.Where(u => u.UserID == l.UserID).Select(u => u.UserName).FirstOrDefault() ?? "Desconocido"
})
.ToListAsync();
return Ok(new { logs, total, page, pageSize });
}
// BÚSQUEDA DE USUARIOS PARA ASIGNAR AVISO
[HttpGet("users/search")]
public async Task<IActionResult> SearchUsers([FromQuery] string q)
{
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))
.Take(10)
.Select(u => new { u.UserID, u.UserName, u.Email, u.FirstName, u.LastName, u.PhoneNumber, u.IsBlocked })
.ToListAsync();
return Ok(users);
}
// GESTIÓN DE BLOQUEO
[HttpPost("users/{id}/toggle-block")]
public async Task<IActionResult> ToggleBlockUser(int id)
{
var user = await _context.Users.FindAsync(id);
if (user == null) return NotFound();
// Evitar autobloqueo
var currentAdminId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
if (user.UserID == currentAdminId) return BadRequest("No puedes bloquearte a ti mismo.");
user.IsBlocked = !user.IsBlocked;
// 📝 AUDITORÍA
_context.AuditLogs.Add(new AuditLog
{
Action = user.IsBlocked ? "USER_BLOCKED" : "USER_UNBLOCKED",
Entity = "User",
EntityID = user.UserID,
UserID = currentAdminId,
Details = $"Usuario '{user.UserName}' {(user.IsBlocked ? "bloqueado" : "desbloqueado")} por administrador."
});
await _context.SaveChangesAsync();
return Ok(new { message = user.IsBlocked ? "Usuario bloqueado" : "Usuario desbloqueado", isBlocked = user.IsBlocked });
}
// REPUBLICAR AVISO VENCIDO
[HttpPost("ads/{id}/republish")]
public async Task<IActionResult> RepublishAd(int id)
{
var ad = await _context.Ads.Include(a => a.Brand).FirstOrDefaultAsync(a => a.AdID == id);
if (ad == null) return NotFound();
// Verificación de seguridad: solo se pueden republicar avisos vencidos.
if (ad.StatusID != (int)AdStatusEnum.Expired)
{
return BadRequest("Solo se pueden republicar avisos que se encuentren vencidos.");
}
// Actualizamos el estado y las fechas para un nuevo ciclo de 30 días.
ad.StatusID = (int)AdStatusEnum.Active;
ad.PublishedAt = DateTime.UtcNow; // La nueva fecha de publicación es ahora.
ad.ExpiresAt = DateTime.UtcNow.AddDays(30);
ad.ExpirationWarningSent = false; // Reseteamos la advertencia de expiración.
// Audit Log
var adminId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
_context.AuditLogs.Add(new AuditLog
{
Action = "AD_REPUBLISHED_BY_ADMIN",
Entity = "Ad",
EntityID = id,
UserID = adminId,
Details = $"Aviso ID {id} ('{ad.Brand?.Name} {ad.VersionName}') republicado por administrador."
});
await _context.SaveChangesAsync();
// Aquí podrías agregar una sincronización con el sistema legacy si fuera necesario.
// await _syncService.SyncAdToLegacyAsync(id);
return Ok(new { message = "Aviso republicado y activado por 30 días." });
}
}

View File

@@ -0,0 +1,816 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Infrastructure.Data;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Infrastructure.Services;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Core.DTOs;
using Microsoft.AspNetCore.Authorization;
namespace MotoresArgentinosV2.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AdsV2Controller : ControllerBase
{
private readonly MotoresV2DbContext _context;
private readonly IImageStorageService _imageService;
private readonly IIdentityService _identityService;
public AdsV2Controller(MotoresV2DbContext context, IImageStorageService imageService, IIdentityService identityService)
{
_context = context;
_imageService = imageService;
_identityService = identityService;
}
private int GetCurrentUserId()
{
return int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
}
private bool IsUserAdmin()
{
return User.IsInRole("Admin");
}
[HttpGet]
public async Task<IActionResult> GetAll(
[FromQuery] string? q,
[FromQuery] string? c,
[FromQuery] int? minPrice,
[FromQuery] int? maxPrice,
[FromQuery] string? currency,
[FromQuery] int? minYear,
[FromQuery] int? maxYear,
[FromQuery] int? userId,
[FromQuery] int? brandId,
[FromQuery] int? modelId,
[FromQuery] string? fuel,
[FromQuery] string? transmission,
[FromQuery] string? color,
[FromQuery] bool? isFeatured)
{
var query = _context.Ads
.Include(a => a.Photos)
.Include(a => a.Features)
.Include(a => a.Brand)
.Include(a => a.Model)
.AsQueryable();
if (isFeatured.HasValue) query = query.Where(a => a.IsFeatured == isFeatured.Value);
if (brandId.HasValue) query = query.Where(a => a.BrandID == brandId.Value);
if (modelId.HasValue) query = query.Where(a => a.ModelID == modelId.Value);
if (!string.IsNullOrEmpty(currency))
{
query = query.Where(a => a.Currency == currency);
}
if (minPrice.HasValue) query = query.Where(a => a.Price >= minPrice.Value);
if (maxPrice.HasValue) query = query.Where(a => a.Price <= maxPrice.Value);
if (!string.IsNullOrEmpty(fuel))
query = query.Where(a => a.FuelType == fuel || a.Features.Any(f => f.FeatureKey == "Combustible" && f.FeatureValue == fuel));
if (!string.IsNullOrEmpty(transmission))
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));
if (!string.IsNullOrEmpty(Request.Query["segment"]))
{
var segment = Request.Query["segment"].ToString();
query = query.Where(a => a.Segment == segment);
}
if (!string.IsNullOrEmpty(Request.Query["location"]))
{
var loc = Request.Query["location"].ToString();
query = query.Where(a => a.Location != null && a.Location.Contains(loc));
}
if (!string.IsNullOrEmpty(Request.Query["condition"]))
{
var cond = Request.Query["condition"].ToString();
query = query.Where(a => a.Condition == cond);
}
if (!string.IsNullOrEmpty(Request.Query["steering"]))
{
var st = Request.Query["steering"].ToString();
query = query.Where(a => a.Steering == st);
}
if (int.TryParse(Request.Query["doorCount"], out int dc))
{
query = query.Where(a => a.DoorCount == dc);
}
if (!userId.HasValue)
{
var publicStatuses = new[] {
(int)AdStatusEnum.Active,
(int)AdStatusEnum.Sold,
(int)AdStatusEnum.Reserved
};
query = query.Where(a => publicStatuses.Contains(a.StatusID));
}
else
{
query = query.Where(a => a.UserID == userId.Value);
}
// --- LÓGICA DE BÚSQUEDA POR PALABRAS ---
if (!string.IsNullOrEmpty(q))
{
// 1. Dividimos el término de búsqueda en palabras individuales.
var keywords = q.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
// 2. Por cada palabra, agregamos una condición 'Where'.
// Esto crea un AND implícito: el aviso debe coincidir con TODAS las palabras.
foreach (var keyword in keywords)
{
var lowerKeyword = keyword.ToLower();
query = query.Where(a =>
(a.Brand != null && a.Brand.Name.ToLower().Contains(lowerKeyword)) ||
(a.Model != null && a.Model.Name.ToLower().Contains(lowerKeyword)) ||
(a.VersionName != null && a.VersionName.ToLower().Contains(lowerKeyword))
);
}
}
if (!string.IsNullOrEmpty(c))
{
int typeId = c == "EAUTOS" ? 1 : (c == "EMOTOS" ? 2 : 0);
if (typeId > 0) query = query.Where(a => a.VehicleTypeID == typeId);
}
if (minPrice.HasValue) query = query.Where(a => a.Price >= minPrice.Value);
if (maxPrice.HasValue) query = query.Where(a => a.Price <= maxPrice.Value);
if (minYear.HasValue) query = query.Where(a => a.Year >= minYear.Value);
if (maxYear.HasValue) query = query.Where(a => a.Year <= maxYear.Value);
var results = await query.OrderByDescending(a => a.IsFeatured)
.ThenByDescending(a => a.CreatedAt)
.Select(a => new
{
id = a.AdID,
brandName = a.Brand != null ? a.Brand.Name : null,
versionName = a.VersionName,
price = a.Price,
currency = a.Currency,
year = a.Year,
km = a.KM,
image = a.Photos.OrderBy(p => p.SortOrder).Select(p => p.FilePath).FirstOrDefault(),
isFeatured = a.IsFeatured,
statusId = a.StatusID,
viewsCounter = a.ViewsCounter,
createdAt = a.CreatedAt,
location = a.Location,
fuelType = a.FuelType,
color = a.Color,
segment = a.Segment,
condition = a.Condition,
doorCount = a.DoorCount,
transmission = a.Transmission,
steering = a.Steering
})
.ToListAsync();
return Ok(results);
}
[HttpGet("search-suggestions")]
public async Task<IActionResult> GetSearchSuggestions([FromQuery] string term)
{
if (string.IsNullOrEmpty(term) || term.Length < 2)
{
return Ok(new List<string>());
}
// Buscar en Marcas
var brands = await _context.Brands
.Where(b => b.Name.Contains(term))
.Select(b => b.Name)
.ToListAsync();
// Buscar en Modelos
var models = await _context.Models
.Where(m => m.Name.Contains(term))
.Select(m => m.Name)
.ToListAsync();
// Combinar, eliminar duplicados y tomar los primeros 5
var suggestions = brands.Concat(models)
.Distinct()
.OrderBy(s => s)
.Take(5)
.ToList();
return Ok(suggestions);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var ad = await _context.Ads
.Include(a => a.Photos)
.Include(a => a.Features)
.Include(a => a.Brand)
.Include(a => a.Model)
.Include(a => a.User)
.FirstOrDefaultAsync(a => a.AdID == id);
if (ad == null) return NotFound();
// 1. SEGURIDAD Y CONTROL DE ACCESO
// Obtenemos datos del usuario actual
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
int currentUserId = int.TryParse(userIdStr, out int uid) ? uid : 0;
bool isAdmin = User.IsInRole("Admin");
bool isOwner = ad.UserID == currentUserId;
// Definimos qué estados son visibles para todo el mundo
var publicStatuses = new[] {
(int)AdStatusEnum.Active,
(int)AdStatusEnum.Reserved,
(int)AdStatusEnum.Sold
};
bool isPublic = publicStatuses.Contains(ad.StatusID);
// REGLA A: Si está ELIMINADO (9), nadie lo ve por URL pública (solo admin podría en dashboard)
if (ad.StatusID == (int)AdStatusEnum.Deleted && !isAdmin)
{
return NotFound();
}
// REGLA B: Si NO es público (ej: Vencido, Borrador, Pausado) y NO es dueño ni admin -> 404
if (!isPublic && !isOwner && !isAdmin)
{
return NotFound();
}
// 2. LÓGICA DE CONTEO
try
{
// REGLA: Solo contamos si NO es el dueño (isOwner ya calculado arriba)
if (!isOwner)
{
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0";
var cutoffDate = DateTime.UtcNow.AddHours(-24);
// Verificamos si esta IP ya vio este aviso en las últimas 24hs
var alreadyViewed = await _context.AdViewLogs
.AnyAsync(v => v.AdID == id && v.IPAddress == userIp && v.ViewDate > cutoffDate);
if (!alreadyViewed)
{
// A. Registrar Log
_context.AdViewLogs.Add(new AdViewLog
{
AdID = id,
IPAddress = userIp,
ViewDate = DateTime.UtcNow
});
// B. Incrementar Contador
await _context.Ads
.Where(a => a.AdID == id)
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ViewsCounter, a => a.ViewsCounter + 1));
await _context.SaveChangesAsync();
}
}
}
catch (Exception)
{
// Ignoramos errores de conteo para no bloquear la visualización
}
// 3. RESPUESTA
return Ok(new
{
adID = ad.AdID,
userID = ad.UserID,
ownerUserType = ad.User?.UserType,
vehicleTypeID = ad.VehicleTypeID,
brandID = ad.BrandID,
modelID = ad.ModelID,
versionName = ad.VersionName,
brandName = ad.Brand?.Name,
year = ad.Year,
km = ad.KM,
price = ad.Price,
currency = ad.Currency,
description = ad.Description,
isFeatured = ad.IsFeatured,
statusID = ad.StatusID,
createdAt = ad.CreatedAt,
publishedAt = ad.PublishedAt,
expiresAt = ad.ExpiresAt,
legacyAdID = ad.LegacyAdID,
viewsCounter = ad.ViewsCounter,
fuelType = ad.FuelType,
color = ad.Color,
segment = ad.Segment,
location = ad.Location,
condition = ad.Condition,
doorCount = ad.DoorCount,
transmission = ad.Transmission,
steering = ad.Steering,
contactPhone = ad.ContactPhone,
contactEmail = ad.ContactEmail,
displayContactInfo = ad.DisplayContactInfo,
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,
model = ad.Model != null ? new { id = ad.Model.ModelID, name = ad.Model.Name } : null
});
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateAdRequestDto request)
{
var currentUserId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
bool isAdmin = userRole == "Admin";
int finalUserId = currentUserId;
if (isAdmin)
{
if (request.TargetUserID.HasValue)
{
finalUserId = request.TargetUserID.Value;
}
else if (!string.IsNullOrEmpty(request.GhostUserEmail))
{
// Pasamos nombre y apellido por separado
var ghost = await _identityService.CreateGhostUserAsync(
request.GhostUserEmail,
request.GhostFirstName ?? "Usuario",
request.GhostLastName ?? "",
request.GhostUserPhone ?? ""
);
finalUserId = ghost.UserID;
}
}
// 🟢 LÓGICA DE MODELO DINÁMICO
int finalModelId = request.ModelID;
// Si el front manda 0 (modelo nuevo escrito a mano)
if (finalModelId == 0 && !string.IsNullOrEmpty(request.VersionName))
{
// Intentamos extraer el nombre del modelo desde el VersionName
var brand = await _context.Brands.FindAsync(request.BrandID);
string modelNameCandidate = request.VersionName;
// Si el nombre de versión empieza con la marca (ej: "Fiat 600"), quitamos la marca
if (brand != null && modelNameCandidate.StartsWith(brand.Name, StringComparison.OrdinalIgnoreCase))
{
modelNameCandidate = modelNameCandidate.Substring(brand.Name.Length).Trim();
}
// Si quedó vacío o es muy genérico, usar un default
if (string.IsNullOrWhiteSpace(modelNameCandidate)) modelNameCandidate = "Modelo Desconocido";
// Opcional: Tomar solo la primera palabra como nombre del modelo para agrupar mejor
// var firstWord = modelNameCandidate.Split(' ')[0];
// if (firstWord.Length > 2) modelNameCandidate = firstWord;
// Buscar si ya existe este modelo para esta marca
var existingModel = await _context.Models
.FirstOrDefaultAsync(m => m.BrandID == request.BrandID && m.Name == modelNameCandidate);
if (existingModel != null)
{
finalModelId = existingModel.ModelID;
}
else
{
// Crear Nuevo Modelo
var newModel = new Model
{
BrandID = request.BrandID,
Name = modelNameCandidate
};
_context.Models.Add(newModel);
await _context.SaveChangesAsync(); // Guardar para obtener ID
finalModelId = newModel.ModelID;
}
}
var ad = new Ad
{
UserID = finalUserId,
VehicleTypeID = request.VehicleTypeID,
BrandID = request.BrandID,
ModelID = finalModelId, // Usamos el ID resuelto
VersionName = request.VersionName,
Year = request.Year,
KM = request.KM,
Price = request.Price,
Currency = request.Currency,
Description = request.Description,
IsFeatured = request.IsFeatured,
FuelType = request.FuelType,
Color = request.Color,
Segment = request.Segment,
Location = request.Location,
Condition = request.Condition,
DoorCount = request.DoorCount,
Transmission = request.Transmission,
Steering = request.Steering,
ContactPhone = request.ContactPhone,
ContactEmail = request.ContactEmail,
DisplayContactInfo = request.DisplayContactInfo,
CreatedAt = DateTime.UtcNow
};
if (isAdmin)
{
ad.StatusID = (int)AdStatusEnum.Active;
ad.PublishedAt = DateTime.UtcNow;
ad.ExpiresAt = DateTime.UtcNow.AddDays(30);
}
else
{
ad.StatusID = (int)AdStatusEnum.Draft;
}
_context.Ads.Add(ad);
await _context.SaveChangesAsync();
// 📝 AUDITORÍA
var brandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
_context.AuditLogs.Add(new AuditLog
{
Action = "AD_CREATED",
Entity = "Ad",
EntityID = ad.AdID,
UserID = currentUserId,
Details = $"Aviso '{brandName} {ad.VersionName}' creado. Estado inicial: {ad.StatusID}"
});
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = ad.AdID }, ad);
}
[HttpGet("brands/{vehicleTypeId}")]
public async Task<IActionResult> GetBrands(int vehicleTypeId)
{
var brands = await _context.Set<Brand>()
.Where(b => b.VehicleTypeID == vehicleTypeId)
.Select(b => new { id = b.BrandID, name = b.Name })
.ToListAsync();
return Ok(brands);
}
[HttpGet("models/{brandId}")]
public async Task<IActionResult> GetModels(int brandId)
{
var models = await _context.Set<Model>()
.Where(m => m.BrandID == brandId)
.Select(m => new { id = m.ModelID, name = m.Name })
.ToListAsync();
return Ok(models);
}
[HttpGet("models/search")]
public async Task<IActionResult> SearchModels([FromQuery] int brandId, [FromQuery] string query)
{
if (string.IsNullOrEmpty(query) || query.Length < 2) return Ok(new List<object>());
var models = await _context.Models
.Where(m => m.BrandID == brandId && m.Name.Contains(query))
.OrderBy(m => m.Name)
.Take(20)
.Select(m => new { id = m.ModelID, name = m.Name })
.ToListAsync();
return Ok(models);
}
[HttpPost("{id}/upload-photos")]
public async Task<IActionResult> UploadPhotos(int id, [FromForm] IFormFileCollection files)
{
if (files == null || files.Count == 0) return BadRequest("No se recibieron archivos en la petición.");
var ad = await _context.Ads.Include(a => a.Photos).FirstOrDefaultAsync(a => a.AdID == id);
if (ad == null) return NotFound("Aviso no encontrado.");
// 🛡️ SEGURIDAD: Verificar propiedad o ser admin
var currentUserId = GetCurrentUserId();
if (ad.UserID != currentUserId && !IsUserAdmin()) return Forbid();
int currentCount = ad.Photos.Count;
int availableSlots = 5 - currentCount;
if (availableSlots <= 0)
{
return BadRequest($"Límite de 5 fotos alcanzado. Espacios disponibles: 0. Fotos actuales: {currentCount}");
}
var filesToProcess = files.Take(availableSlots).ToList();
var uploadedCount = 0;
var errors = new List<string>();
foreach (var file in filesToProcess)
{
if (file.Length > 0)
{
try
{
var relativePath = await _imageService.SaveAdImageAsync(id, file);
_context.AdPhotos.Add(new AdPhoto
{
AdID = id,
FilePath = relativePath,
IsCover = !_context.AdPhotos.Any(p => p.AdID == id) && uploadedCount == 0,
SortOrder = currentCount + uploadedCount
});
uploadedCount++;
}
catch (Exception ex)
{
errors.Add($"{file.FileName}: {ex.Message}");
}
}
}
if (uploadedCount > 0)
{
await _context.SaveChangesAsync();
return Ok(new
{
message = $"{uploadedCount} fotos procesadas.",
errors = errors.Any() ? errors : null
});
}
return BadRequest(new
{
message = "No se pudieron procesar las imágenes.",
details = errors
});
}
[HttpDelete("photos/{photoId}")]
public async Task<IActionResult> DeletePhoto(int photoId)
{
var photo = await _context.AdPhotos.Include(p => p.Ad).FirstOrDefaultAsync(p => p.PhotoID == photoId);
if (photo == null) return NotFound();
// 🛡️ SEGURIDAD: Verificar propiedad o ser admin
var currentUserId = GetCurrentUserId();
if (photo.Ad.UserID != currentUserId && !IsUserAdmin()) return Forbid();
// Eliminar del disco
_imageService.DeleteAdImage(photo.FilePath);
// Eliminar de la base de datos
_context.AdPhotos.Remove(photo);
await _context.SaveChangesAsync();
return NoContent();
}
[HttpPut("{id}")]
[Authorize]
public async Task<IActionResult> Update(int id, [FromBody] CreateAdRequestDto updatedAdDto)
{
var ad = await _context.Ads.FindAsync(id);
if (ad == null) return NotFound();
var userId = GetCurrentUserId();
if (ad.UserID != userId && !IsUserAdmin())
{
return Forbid();
}
// Si está en moderación, no se puede editar (salvo Admin)
if (!IsUserAdmin() && ad.StatusID == (int)AdStatusEnum.ModerationPending)
{
return BadRequest("No puedes editar un aviso que está en proceso de revisión.");
}
// --- Lógica de Modelo Dinámico (Si edita a un modelo nuevo) ---
int finalModelId = updatedAdDto.ModelID;
if (finalModelId == 0 && !string.IsNullOrEmpty(updatedAdDto.VersionName))
{
var brand = await _context.Brands.FindAsync(updatedAdDto.BrandID);
string modelNameCandidate = updatedAdDto.VersionName;
if (brand != null && modelNameCandidate.StartsWith(brand.Name, StringComparison.OrdinalIgnoreCase))
{
modelNameCandidate = modelNameCandidate.Substring(brand.Name.Length).Trim();
}
if (string.IsNullOrWhiteSpace(modelNameCandidate)) modelNameCandidate = "Modelo Desconocido";
var existingModel = await _context.Models
.FirstOrDefaultAsync(m => m.BrandID == updatedAdDto.BrandID && m.Name == modelNameCandidate);
if (existingModel != null)
{
finalModelId = existingModel.ModelID;
}
else
{
var newModel = new Model { BrandID = updatedAdDto.BrandID, Name = modelNameCandidate };
_context.Models.Add(newModel);
await _context.SaveChangesAsync();
finalModelId = newModel.ModelID;
}
}
// --- Mapeo manual desde el DTO a la Entidad ---
ad.BrandID = updatedAdDto.BrandID;
ad.ModelID = finalModelId; // Usar el ID resuelto
ad.VersionName = updatedAdDto.VersionName;
ad.Year = updatedAdDto.Year;
ad.KM = updatedAdDto.KM;
ad.Price = updatedAdDto.Price;
ad.Currency = updatedAdDto.Currency;
ad.Description = updatedAdDto.Description;
ad.FuelType = updatedAdDto.FuelType;
ad.Color = updatedAdDto.Color;
ad.Segment = updatedAdDto.Segment;
ad.Location = updatedAdDto.Location;
ad.Condition = updatedAdDto.Condition;
ad.DoorCount = updatedAdDto.DoorCount;
ad.Transmission = updatedAdDto.Transmission;
ad.Steering = updatedAdDto.Steering;
ad.ContactPhone = updatedAdDto.ContactPhone;
ad.ContactEmail = updatedAdDto.ContactEmail;
ad.DisplayContactInfo = updatedAdDto.DisplayContactInfo;
// Nota: IsFeatured y otros campos sensibles se manejan por separado (pago/admin)
// 📝 AUDITORÍA
var adBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
_context.AuditLogs.Add(new AuditLog
{
Action = "AD_UPDATED",
Entity = "Ad",
EntityID = ad.AdID,
UserID = userId,
Details = $"Aviso ID {ad.AdID} ({adBrandName} {ad.VersionName}) actualizado por el propietario/admin."
});
await _context.SaveChangesAsync();
return Ok(ad);
}
[HttpDelete("{id}")]
[Authorize]
public async Task<IActionResult> Delete(int id)
{
var ad = await _context.Ads.FindAsync(id);
if (ad == null) return NotFound();
var userId = GetCurrentUserId();
if (ad.UserID != userId && !IsUserAdmin())
{
return Forbid();
}
ad.StatusID = (int)AdStatusEnum.Deleted;
ad.DeletedAt = DateTime.UtcNow;
// 📝 AUDITORÍA
var delBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
_context.AuditLogs.Add(new AuditLog
{
Action = "AD_DELETED_SOFT",
Entity = "Ad",
EntityID = ad.AdID,
UserID = userId,
Details = $"Aviso ID {ad.AdID} ({delBrandName} {ad.VersionName}) marcado como eliminado (borrado lógico)."
});
await _context.SaveChangesAsync();
return NoContent();
}
[HttpPost("favorites")]
public async Task<IActionResult> AddFavorite([FromBody] Favorite fav)
{
if (await _context.Favorites.AnyAsync(f => f.UserID == fav.UserID && f.AdID == fav.AdID))
return BadRequest("Ya es favorito");
_context.Favorites.Add(fav);
await _context.SaveChangesAsync();
return Ok();
}
[HttpDelete("favorites")]
public async Task<IActionResult> RemoveFavorite([FromQuery] int userId, [FromQuery] int adId)
{
var fav = await _context.Favorites.FindAsync(userId, adId);
if (fav == null) return NotFound();
_context.Favorites.Remove(fav);
await _context.SaveChangesAsync();
return NoContent();
}
[HttpGet("favorites/{userId}")]
public async Task<IActionResult> GetFavorites(int userId)
{
var ads = await _context.Favorites
.Where(f => f.UserID == userId)
.Join(_context.Ads, f => f.AdID, a => a.AdID, (f, a) => a)
.Include(a => a.Photos)
.Select(a => new
{
id = a.AdID,
BrandName = a.Brand != null ? a.Brand.Name : null,
VersionName = a.VersionName,
price = a.Price,
currency = a.Currency,
year = a.Year,
km = a.KM,
image = a.Photos.OrderBy(p => p.SortOrder).Select(p => p.FilePath).FirstOrDefault()
})
.ToListAsync();
return Ok(ads);
}
[HttpPatch("{id}/status")]
public async Task<IActionResult> ChangeStatus(int id, [FromBody] int newStatus)
{
var ad = await _context.Ads.FindAsync(id);
if (ad == null) return NotFound();
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
bool isAdmin = userRole == "Admin";
if (ad.UserID != userId && !isAdmin) return Forbid();
// 🛡️ VALIDACIONES DE ESTADO
if (!isAdmin)
{
// 1. No activar borradores sin pagar
if (ad.StatusID == (int)AdStatusEnum.Draft || ad.StatusID == (int)AdStatusEnum.PaymentPending)
{
return BadRequest("Debes completar el pago para activar este aviso.");
}
// 2. NUEVO: 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.");
}
}
// Validar estados destino permitidos para el usuario
var allowedUserStatuses = new[] { 4, 6, 7, 9, 10 };
if (!isAdmin && !allowedUserStatuses.Contains(newStatus))
{
return BadRequest("Estado destino no permitido.");
}
int oldStatus = ad.StatusID;
ad.StatusID = newStatus;
// 📝 AUDITORÍA
var statusBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
_context.AuditLogs.Add(new AuditLog
{
Action = "AD_STATUS_CHANGED",
Entity = "Ad",
EntityID = ad.AdID,
UserID = userId,
Details = $"Estado de aviso ({statusBrandName} {ad.VersionName}) cambiado de {oldStatus} a {newStatus}."
});
await _context.SaveChangesAsync();
return Ok(new { message = "Estado actualizado" });
}
[HttpGet("payment-methods")]
public async Task<IActionResult> GetPaymentMethods()
{
var methods = await _context.PaymentMethods
.OrderBy(p => p.PaymentMethodID)
.Select(p => new { id = p.PaymentMethodID, mediodepago = p.Name })
.ToListAsync();
return Ok(methods);
}
}

View File

@@ -0,0 +1,485 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Infrastructure.Data;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.DTOs;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.RateLimiting;
namespace MotoresArgentinosV2.API.Controllers;
[ApiController]
[Route("api/[controller]")]
// CORRECCIÓN: Se quitó [EnableRateLimiting("AuthPolicy")] de aquí para no bloquear /me ni /logout
public class AuthController : ControllerBase
{
private readonly IIdentityService _identityService;
private readonly ITokenService _tokenService;
private readonly INotificationService _notificationService;
private readonly MotoresV2DbContext _context;
public AuthController(IIdentityService identityService, ITokenService tokenService, INotificationService notificationService, MotoresV2DbContext context)
{
_identityService = identityService;
_tokenService = tokenService;
_notificationService = notificationService;
_context = context;
}
// Helper privado para cookies
private void SetTokenCookie(string token, string cookieName)
{
var cookieOptions = new CookieOptions
{
HttpOnly = true, // Seguridad: JS no puede leer esto
Expires = DateTime.UtcNow.AddDays(7),
Secure = true, // Solo HTTPS (localhost con https cuenta)
SameSite = SameSiteMode.Strict,
IsEssential = true
};
Response.Cookies.Append(cookieName, token, cookieOptions);
}
[HttpPost("login")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO (5 intentos/min)
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
var (user, message) = await _identityService.AuthenticateAsync(request.Username, request.Password);
if (user == null)
{
if (message == "EMAIL_NOT_VERIFIED")
return Unauthorized(new { message = "Debes verificar tu email antes de ingresar." });
if (message == "USER_BLOCKED")
return Unauthorized(new { message = "Tu usuario ha sido bloqueado por un administrador." });
return Unauthorized(new { message = "Credenciales inválidas" });
}
if (message == "FORCE_PASSWORD_CHANGE")
{
return Ok(new { status = "MIGRATION_REQUIRED", username = user.UserName });
}
// Lógica MFA (Si aplica)
if (user.IsMFAEnabled)
{
return Ok(new { status = "TOTP_REQUIRED", username = user.UserName });
}
if (user.UserType == 3 && string.IsNullOrEmpty(user.MFASecret)) // Admin forzar setup
{
var secret = _tokenService.GenerateBase32Secret();
user.MFASecret = secret;
await _context.SaveChangesAsync();
var qrUri = _tokenService.GetQrCodeUri(user.Email, secret);
return Ok(new { status = "MFA_SETUP_REQUIRED", username = user.UserName, qrUri, secret });
}
// --- LOGIN EXITOSO ---
// 1. Generar Tokens
var jwtToken = _tokenService.GenerateJwtToken(user);
var refreshToken = _tokenService.GenerateRefreshToken(HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0");
// 2. Guardar RefreshToken en DB
// Asegúrate de que la propiedad RefreshTokens exista en User (Paso 2 abajo)
user.RefreshTokens.Add(refreshToken);
await _context.SaveChangesAsync();
// 3. Setear Cookies
SetTokenCookie(jwtToken, "accessToken");
SetTokenCookie(refreshToken.Token, "refreshToken");
// 4. Audit Log
_context.AuditLogs.Add(new AuditLog
{
Action = "LOGIN_SUCCESS",
Entity = "User",
EntityID = user.UserID,
UserID = user.UserID,
Details = "Login con Cookies exitoso"
});
await _context.SaveChangesAsync();
// 5. Retornar User (Sin el token, porque va en cookie)
return Ok(new
{
status = "SUCCESS",
recommendMfa = !user.IsMFAEnabled,
user = new
{
id = user.UserID,
username = user.UserName,
email = user.Email,
firstName = user.FirstName,
lastName = user.LastName,
userType = user.UserType,
isMFAEnabled = user.IsMFAEnabled
}
});
}
[HttpPost("refresh-token")]
// NO PROTEGIDO ESTRICTAMENTE (Usa límite global)
public async Task<IActionResult> RefreshToken()
{
var refreshToken = Request.Cookies["refreshToken"];
if (string.IsNullOrEmpty(refreshToken))
return Unauthorized(new { message = "Token no proporcionado" });
var user = await _context.Users
.Include(u => u.RefreshTokens)
.FirstOrDefaultAsync(u => u.RefreshTokens.Any(t => t.Token == refreshToken));
if (user == null) return Unauthorized(new { message = "Usuario no encontrado" });
var storedToken = user.RefreshTokens.SingleOrDefault(x => x.Token == refreshToken);
if (storedToken == null || !storedToken.IsActive)
return Unauthorized(new { message = "Token inválido o expirado" });
// Rotar Refresh Token
var newRefreshToken = _tokenService.GenerateRefreshToken(HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0");
storedToken.Revoked = DateTime.UtcNow;
storedToken.RevokedByIp = HttpContext.Connection.RemoteIpAddress?.ToString();
storedToken.ReplacedByToken = newRefreshToken.Token;
user.RefreshTokens.Add(newRefreshToken);
await _context.SaveChangesAsync();
// Nuevo JWT
var newJwtToken = _tokenService.GenerateJwtToken(user);
// Actualizar Cookies
SetTokenCookie(newJwtToken, "accessToken");
SetTokenCookie(newRefreshToken.Token, "refreshToken");
return Ok(new { message = "Token renovado" });
}
[HttpPost("logout")]
// NO PROTEGIDO ESTRICTAMENTE
public IActionResult Logout()
{
Response.Cookies.Delete("accessToken");
Response.Cookies.Delete("refreshToken");
return Ok(new { message = "Sesión cerrada" });
}
[HttpGet("me")]
// 1. Sin [Authorize] para controlar nosotros la respuesta
public async Task<IActionResult> GetMe()
{
// 2. Verificamos si existe la cookie de acceso
var hasToken = Request.Cookies.ContainsKey("accessToken");
// 3. Si NO tiene cookie, es un visitante anónimo.
// Devolvemos 200 OK con null para no ensuciar la consola con 401.
if (!hasToken)
{
return Ok(null);
}
// 4. Si TIENE cookie, verificamos si el Middleware pudo leer el usuario.
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
// Si tiene cookie pero userIdStr es null, significa que el token expiró o es inválido.
// Aquí SI devolvemos 401 para disparar el "Auto Refresh" del frontend.
if (string.IsNullOrEmpty(userIdStr))
{
return Unauthorized();
}
// 5. Todo válido, buscamos datos
var userId = int.Parse(userIdStr);
var user = await _context.Users.FindAsync(userId);
if (user == null) return Unauthorized(); // Usuario borrado de DB
return Ok(new
{
id = user.UserID,
username = user.UserName,
email = user.Email,
firstName = user.FirstName,
lastName = user.LastName,
userType = user.UserType,
phoneNumber = user.PhoneNumber,
isMFAEnabled = user.IsMFAEnabled
});
}
// Permite a un usuario logueado iniciar el proceso de MFA voluntariamente
[Authorize]
[HttpPost("init-mfa")]
public async Task<IActionResult> InitMFA()
{
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
var user = await _context.Users.FindAsync(int.Parse(userIdStr));
if (user == null) return NotFound();
// Generar secreto si no tiene
if (string.IsNullOrEmpty(user.MFASecret))
{
user.MFASecret = _tokenService.GenerateBase32Secret();
await _context.SaveChangesAsync();
}
// 📝 AUDITORÍA
_context.AuditLogs.Add(new AuditLog
{
Action = "MFA_INITIATED",
Entity = "User",
EntityID = user.UserID,
UserID = user.UserID,
Details = "Proceso de configuración de MFA (TOTP) iniciado."
});
await _context.SaveChangesAsync();
var qrUri = _tokenService.GetQrCodeUri(user.Email, user.MFASecret);
return Ok(new
{
qrUri = qrUri,
secret = user.MFASecret
});
}
[HttpPost("verify-mfa")]
[EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> VerifyMFA([FromBody] MFARequest request)
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.UserName == request.Username);
if (user == null || string.IsNullOrEmpty(user.MFASecret))
{
return Unauthorized(new { message = "Sesión de autenticación inválida" });
}
bool isValid = _tokenService.ValidateTOTP(user.MFASecret, request.Code);
if (!isValid)
{
return Unauthorized(new { message = "Código de verificación inválido" });
}
if (!user.IsMFAEnabled)
{
user.IsMFAEnabled = true;
await _context.SaveChangesAsync();
}
var token = _tokenService.GenerateJwtToken(user);
var refreshToken = _tokenService.GenerateRefreshToken(HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0");
// Cargar colección explícitamente para evitar errores de EF
await _context.Entry(user).Collection(u => u.RefreshTokens).LoadAsync();
user.RefreshTokens.Add(refreshToken);
await _context.SaveChangesAsync();
// Setear Cookies Seguras
SetTokenCookie(token, "accessToken");
SetTokenCookie(refreshToken.Token, "refreshToken");
_context.AuditLogs.Add(new AuditLog
{
Action = "LOGIN_TOTP_SUCCESS",
Entity = "User",
EntityID = user.UserID,
UserID = user.UserID,
Details = "Login con TOTP (Authenticator) exitoso"
});
await _context.SaveChangesAsync();
return Ok(new
{
status = "SUCCESS",
// user: retornamos datos para el frontend, pero NO el token
user = new
{
id = user.UserID,
username = user.UserName,
email = user.Email,
firstName = user.FirstName,
lastName = user.LastName,
userType = user.UserType,
isMFAEnabled = user.IsMFAEnabled
}
});
}
[Authorize]
[HttpPost("disable-mfa")]
public async Task<IActionResult> DisableMFA()
{
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
var user = await _context.Users.FindAsync(int.Parse(userIdStr));
if (user == null) return NotFound();
// Desactivamos MFA y limpiamos el secreto por seguridad
user.IsMFAEnabled = false;
user.MFASecret = null;
// 📝 AUDITORÍA
_context.AuditLogs.Add(new AuditLog
{
Action = "MFA_DISABLED",
Entity = "User",
EntityID = user.UserID,
UserID = user.UserID,
Details = "Autenticación de dos factores desactivada por el usuario."
});
await _context.SaveChangesAsync();
return Ok(new { message = "MFA Desactivado correctamente." });
}
[HttpPost("migrate-password")]
[EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> MigratePassword([FromBody] MigrateRequest request)
{
var success = await _identityService.MigratePasswordAsync(request.Username, request.NewPassword);
if (!success)
{
return BadRequest(new { message = "No se pudo actualizar la contraseña" });
}
return Ok(new { message = "Contraseña actualizada con éxito. Ya puede iniciar sesión." });
}
[HttpPost("register")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var (success, message) = await _identityService.RegisterUserAsync(request);
if (!success) return BadRequest(new { message });
// 📝 AUDITORÍA
_context.AuditLogs.Add(new AuditLog
{
Action = "USER_REGISTERED",
Entity = "User",
EntityID = 0,
UserID = 0,
Details = $"Nuevo registro de usuario: {request.Email}"
});
await _context.SaveChangesAsync();
return Ok(new { message });
}
[HttpPost("verify-email")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest request)
{
var (success, message) = await _identityService.VerifyEmailAsync(request.Token);
if (!success) return BadRequest(new { message });
// 📝 AUDITORÍA
_context.AuditLogs.Add(new AuditLog
{
Action = "EMAIL_VERIFIED",
Entity = "User",
EntityID = 0,
UserID = 0,
Details = "Correo electrónico verificado con éxito vía token."
});
await _context.SaveChangesAsync();
return Ok(new { message });
}
[HttpPost("resend-verification")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
public async Task<IActionResult> ResendVerification([FromBody] ResendVerificationRequest request)
{
var (success, message) = await _identityService.ResendVerificationEmailAsync(request.Email);
if (!success) return BadRequest(new { message });
return Ok(new { message });
}
[HttpPost("forgot-password")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
{
var (success, message) = await _identityService.ForgotPasswordAsync(request.Email);
if (!success)
{
// Si falló por Rate Limit, devolvemos BadRequest o 429 Too Many Requests
return BadRequest(new { message });
}
return Ok(new { message });
}
[HttpPost("reset-password")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
{
var (success, message) = await _identityService.ResetPasswordAsync(request.Token, request.NewPassword);
if (!success) return BadRequest(new { message });
// 📝 AUDITORÍA
_context.AuditLogs.Add(new AuditLog
{
Action = "PASSWORD_RESET_SUCCESS",
Entity = "User",
EntityID = 0, // No tenemos el ID directo aquí sin buscarlo
UserID = 0,
Details = "Contraseña restablecida vía token de recuperación."
});
await _context.SaveChangesAsync();
return Ok(new { message });
}
[Authorize]
[HttpPost("change-password")]
[EnableRateLimiting("AuthPolicy")] // PROTEGIDO
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
var (success, message) = await _identityService.ChangePasswordAsync(userId, request.CurrentPassword, request.NewPassword);
if (!success) return BadRequest(new { message });
// Enviar notificación por mail
try
{
var user = await _context.Users.FindAsync(userId);
if (user != null)
{
await _notificationService.SendSecurityAlertEmailAsync(user.Email, "Cambio de contraseña");
}
}
catch (Exception) { /* No bloqueamos el éxito si falla el mail */ }
// 📝 AUDITORÍA
_context.AuditLogs.Add(new AuditLog
{
Action = "PASSWORD_CHANGED",
Entity = "User",
EntityID = userId,
UserID = userId,
Details = "Contraseña cambiada por el usuario desde la configuración."
});
await _context.SaveChangesAsync();
return Ok(new { message });
}
}
public class ChangePasswordRequest
{
public string CurrentPassword { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Mvc;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Interfaces;
namespace MotoresArgentinosV2.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AvisosLegacyController : ControllerBase
{
private readonly IAvisosLegacyService _avisosService;
private readonly ILogger<AvisosLegacyController> _logger;
public AvisosLegacyController(IAvisosLegacyService avisosService, ILogger<AvisosLegacyController> logger)
{
_avisosService = avisosService;
_logger = logger;
}
/// <summary>
/// Obtiene las tarifas y configuración de avisos según la tarea y paquete
/// </summary>
/// <param name="tarea">Tipo de tarea (ej: EMOTORES, EAUTOS)</param>
/// <param name="paquete">ID del paquete (opcional, default 0)</param>
[HttpGet("configuracion")]
public async Task<ActionResult<List<DatosAvisoDto>>> GetConfiguracion([FromQuery] string tarea, [FromQuery] int paquete = 0)
{
try
{
if (string.IsNullOrEmpty(tarea))
{
return BadRequest("El parámetro 'tarea' es obligatorio.");
}
var resultados = await _avisosService.ObtenerDatosAvisosAsync(tarea, paquete);
return Ok(resultados);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener configuración de avisos");
return StatusCode(500, "Ocurrió un error interno al procesar la solicitud.");
}
}
/// <summary>
/// Crea un nuevo aviso en el sistema legacy
/// </summary>
[HttpPost]
public async Task<ActionResult<bool>> CrearAviso([FromBody] InsertarAvisoDto dto)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var resultado = await _avisosService.InsertarAvisoAsync(dto);
if (resultado)
{
return CreatedAtAction(nameof(CrearAviso), true);
}
return BadRequest("No se pudo crear el aviso.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al crear aviso para cliente {IdCliente}", dto.IdCliente);
return StatusCode(500, "Ocurrió un error interno al crear el aviso.");
}
}
[HttpGet("cliente/{nroDoc}")]
public async Task<ActionResult<List<AvisoWebDto>>> GetAvisosPorCliente(string nroDoc)
{
var avisos = await _avisosService.ObtenerAvisosPorClienteAsync(nroDoc);
return Ok(avisos);
}
}

View File

@@ -0,0 +1,126 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Infrastructure.Data;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using Microsoft.AspNetCore.Authorization;
namespace MotoresArgentinosV2.API.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ChatController : ControllerBase
{
private readonly MotoresV2DbContext _context;
private readonly INotificationService _notificationService;
public ChatController(MotoresV2DbContext context, INotificationService notificationService)
{
_context = context;
_notificationService = notificationService;
}
[HttpPost("send")]
public async Task<IActionResult> SendMessage([FromBody] ChatMessage msg)
{
// 1. Guardar Mensaje
msg.SentAt = DateTime.UtcNow;
msg.IsRead = false;
_context.ChatMessages.Add(msg);
await _context.SaveChangesAsync();
// 2. Notificar por Email (Con manejo de errores silencioso)
try
{
var receiver = await _context.Users.FindAsync(msg.ReceiverID);
var sender = await _context.Users.FindAsync(msg.SenderID);
if (receiver != null && !string.IsNullOrEmpty(receiver.Email))
{
// LÓGICA DE NOMBRE DE REMITENTE
string senderDisplayName;
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}";
}
await _notificationService.SendChatNotificationEmailAsync(
receiver.Email,
senderDisplayName, // Pasamos el nombre formateado
msg.MessageText,
msg.AdID);
}
}
catch (Exception ex)
{
// Loguear error sin romper el flujo del chat
Console.WriteLine($"Error enviando notificación de chat: {ex.Message}");
}
return Ok(msg);
}
[HttpGet("inbox/{userId}")]
public async Task<IActionResult> GetInbox(int userId)
{
// Obtener todas las conversaciones donde el usuario es remitente o destinatario
var messages = await _context.ChatMessages
.Where(m => m.SenderID == userId || m.ReceiverID == userId)
.OrderByDescending(m => m.SentAt)
.ToListAsync();
return Ok(messages);
}
[HttpGet("conversation/{adId}/{user1Id}/{user2Id}")]
public async Task<IActionResult> GetConversation(int adId, int user1Id, int user2Id)
{
var messages = await _context.ChatMessages
.Where(m => m.AdID == adId &&
((m.SenderID == user1Id && m.ReceiverID == user2Id) ||
(m.SenderID == user2Id && m.ReceiverID == user1Id)))
.OrderBy(m => m.SentAt)
.ToListAsync();
return Ok(messages);
}
[HttpPost("mark-read/{messageId}")]
public async Task<IActionResult> MarkRead(int messageId)
{
var msg = await _context.ChatMessages.FindAsync(messageId);
if (msg == null) return NotFound();
msg.IsRead = true;
await _context.SaveChangesAsync();
return Ok();
}
[HttpGet("unread-count/{userId}")]
public async Task<IActionResult> GetUnreadCount(int userId)
{
// Seguridad: Asegurarse de que el usuario que consulta es el dueño de los mensajes o un admin.
var currentUserId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
var isAdmin = User.IsInRole("Admin");
if (currentUserId != userId && !isAdmin)
{
return Forbid();
}
var count = await _context.ChatMessages
.CountAsync(m => m.ReceiverID == userId && !m.IsRead);
return Ok(new { count });
}
}

View File

@@ -0,0 +1,119 @@
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.");
}
}
}

View File

@@ -0,0 +1,145 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Interfaces;
using Microsoft.AspNetCore.RateLimiting;
using System.Security.Cryptography;
using System.Text;
namespace MotoresArgentinosV2.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class PaymentsController : ControllerBase
{
private readonly IPaymentService _paymentService;
private readonly IConfiguration _config;
public PaymentsController(IPaymentService paymentService, IConfiguration config)
{
_paymentService = paymentService;
_config = config;
}
[HttpPost("process")]
[EnableRateLimiting("AuthPolicy")]
public async Task<IActionResult> ProcessPayment([FromBody] CreatePaymentRequestDto request)
{
try
{
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
var result = await _paymentService.ProcessPaymentAsync(request, userId);
if (result.Status == "approved")
{
return Ok(new { status = "approved", id = result.PaymentId });
}
else if (result.Status == "in_process")
{
return Ok(new { status = "in_process", message = "El pago está siendo revisado." });
}
else
{
return BadRequest(new { status = "rejected", detail = result.StatusDetail });
}
}
catch (Exception ex)
{
return StatusCode(500, new { message = ex.Message });
}
}
// --- MÉTODO WEBHOOK ACTUALIZADO CON VALIDACIÓN DE FIRMA ---
[HttpPost("webhook")]
[AllowAnonymous]
public async Task<IActionResult> MercadoPagoWebhook([FromQuery] string? topic, [FromQuery] string? id)
{
// 1. EXTRACCIÓN DE DATOS DE LA PETICIÓN
Request.Headers.TryGetValue("x-request-id", out var xRequestId);
Request.Headers.TryGetValue("x-signature", out var xSignature);
var resourceId = id;
var resourceTopic = topic;
if (string.IsNullOrEmpty(resourceId))
{
try
{
Request.EnableBuffering();
using var reader = new System.IO.StreamReader(Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
Request.Body.Position = 0;
var json = System.Text.Json.JsonDocument.Parse(body);
if (json.RootElement.TryGetProperty("type", out var typeProp))
{
resourceTopic = typeProp.GetString();
}
if (json.RootElement.TryGetProperty("data", out var dataProp) && dataProp.TryGetProperty("id", out var idProp))
{
resourceId = idProp.GetString();
}
}
catch { /* Ignorar errores de lectura */ }
}
if (string.IsNullOrEmpty(resourceId) || resourceTopic != "payment")
{
return Ok();
}
// 2. VALIDACIÓN DE LA FIRMA
var secret = _config["MercadoPago:WebhookSecret"];
if (!string.IsNullOrEmpty(secret))
{
var signatureParts = xSignature.ToString().Split(',')
.Select(part => part.Trim().Split('='))
.ToDictionary(split => split[0], split => split.Length > 1 ? split[1] : "");
if (!signatureParts.TryGetValue("ts", out var ts) || !signatureParts.TryGetValue("v1", out var hash))
{
return Unauthorized("Invalid signature header.");
}
// Según la documentación de MP, el data.id debe estar en minúsculas para la firma.
var manifest = $"id:{resourceId.ToLower()};request-id:{xRequestId};ts:{ts};";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var computedHashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(manifest));
var computedHash = Convert.ToHexString(computedHashBytes).ToLower();
if (computedHash != hash)
{
return Unauthorized("Signature mismatch.");
}
}
// 3. PROCESAMIENTO (SOLO SI LA FIRMA ES VÁLIDA)
try
{
await _paymentService.ProcessWebhookAsync(resourceTopic, resourceId);
return Ok();
}
catch (Exception ex)
{
Console.WriteLine($"Error procesando webhook validado: {ex.Message}");
return StatusCode(500, "Internal server error processing webhook.");
}
}
[HttpPost("check-status/{adId}")]
public async Task<IActionResult> CheckStatus(int adId)
{
try
{
var result = await _paymentService.CheckPaymentStatusAsync(adId);
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(new { message = ex.Message });
}
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Infrastructure.Data;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
namespace MotoresArgentinosV2.API.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ProfileController : ControllerBase
{
private readonly MotoresV2DbContext _context;
public ProfileController(MotoresV2DbContext context)
{
_context = context;
}
[HttpGet]
public async Task<IActionResult> GetProfile()
{
var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "0");
var user = await _context.Users
.Where(u => u.UserID == userId)
.Select(u => new
{
u.UserID,
u.UserName,
u.Email,
u.FirstName,
u.LastName,
u.PhoneNumber,
u.UserType,
u.CreatedAt,
u.IsEmailVerified
})
.FirstOrDefaultAsync();
if (user == null) return NotFound();
return Ok(user);
}
[HttpPut]
public async Task<IActionResult> UpdateProfile([FromBody] ProfileUpdateDto dto)
{
var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "0");
var user = await _context.Users.FindAsync(userId);
if (user == null) return NotFound();
user.FirstName = dto.FirstName;
user.LastName = dto.LastName;
user.PhoneNumber = dto.PhoneNumber;
await _context.SaveChangesAsync();
// Audit Log
_context.AuditLogs.Add(new AuditLog
{
Action = "PROFILE_UPDATED",
Entity = "User",
EntityID = userId,
UserID = userId,
Details = "Usuario actualizó su perfil personal."
});
await _context.SaveChangesAsync();
return Ok(new { message = "Perfil actualizado con éxito." });
}
}

View File

@@ -0,0 +1,180 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Infrastructure.Data;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
namespace MotoresArgentinosV2.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SeedController : ControllerBase
{
private readonly MotoresV2DbContext _context;
private readonly IPasswordService _passwordService;
public SeedController(MotoresV2DbContext context, IPasswordService passwordService)
{
_context = context;
_passwordService = passwordService;
}
[HttpPost("database")]
public async Task<IActionResult> SeedDatabase()
{
// 1. Asegurar Marcas y Modelos
if (!await _context.Brands.AnyAsync())
{
var toyota = new Brand { VehicleTypeID = 1, Name = "Toyota" };
var ford = new Brand { VehicleTypeID = 1, Name = "Ford" };
var vw = new Brand { VehicleTypeID = 1, Name = "Volkswagen" };
var honda = new Brand { VehicleTypeID = 2, Name = "Honda" };
var yamaha = new Brand { VehicleTypeID = 2, Name = "Yamaha" };
_context.Brands.AddRange(toyota, ford, vw, honda, yamaha);
await _context.SaveChangesAsync();
_context.Models.AddRange(
new Model { BrandID = toyota.BrandID, Name = "Corolla" },
new Model { BrandID = toyota.BrandID, Name = "Hilux" },
new Model { BrandID = ford.BrandID, Name = "Ranger" },
new Model { BrandID = vw.BrandID, Name = "Amarok" },
new Model { BrandID = honda.BrandID, Name = "Wave 110" },
new Model { BrandID = yamaha.BrandID, Name = "FZ FI" }
);
await _context.SaveChangesAsync();
}
// 2. Crear Usuarios de Prueba
var testUser = await _context.Users.FirstOrDefaultAsync(u => u.UserName == "testuser");
if (testUser == null)
{
testUser = new User
{
UserName = "testuser",
Email = "test@motores.com.ar",
PasswordHash = _passwordService.HashPassword("test123"),
FirstName = "Usuario",
LastName = "Prueba",
MigrationStatus = 1,
UserType = 1
};
_context.Users.Add(testUser);
}
var adminUser = await _context.Users.FirstOrDefaultAsync(u => u.UserName == "admin");
if (adminUser == null)
{
adminUser = new User
{
UserName = "admin",
Email = "admin@motoresargentinos.com.ar",
PasswordHash = _passwordService.HashPassword("admin123"),
FirstName = "Admin",
LastName = "Motores",
MigrationStatus = 1,
UserType = 3 // ADMIN
};
_context.Users.Add(adminUser);
}
await _context.SaveChangesAsync();
// 3. Crear Avisos de Prueba
if (!await _context.Ads.AnyAsync())
{
var brands = await _context.Brands.ToListAsync();
var models = await _context.Models.ToListAsync();
var ad1 = new Ad
{
UserID = testUser.UserID,
VehicleTypeID = 1,
BrandID = brands.First(b => b.Name == "Toyota").BrandID,
ModelID = models.First(m => m.Name == "Corolla").ModelID,
VersionName = "Toyota Corolla 1.8 XLI",
Year = 2022,
KM = 15000,
Price = 25000,
Currency = "USD",
Description = "Excelente estado, único dueño. Service al día.",
StatusID = 4, // Activo
IsFeatured = true,
CreatedAt = DateTime.UtcNow,
PublishedAt = DateTime.UtcNow
};
var ad2 = new Ad
{
UserID = testUser.UserID,
VehicleTypeID = 2,
BrandID = brands.First(b => b.Name == "Honda").BrandID,
ModelID = models.First(m => m.Name == "Wave 110").ModelID,
VersionName = "Honda Wave 110 S",
Year = 2023,
KM = 2500,
Price = 1800,
Currency = "USD",
Description = "Impecable, como nueva. Muy económica.",
StatusID = 4, // Activo
CreatedAt = DateTime.UtcNow,
PublishedAt = DateTime.UtcNow
};
var ad3 = new Ad
{
UserID = testUser.UserID,
VehicleTypeID = 1,
BrandID = brands.First(b => b.Name == "Ford").BrandID,
ModelID = models.First(m => m.Name == "Ranger").ModelID,
VersionName = "Ford Ranger Limited 4x4",
Year = 2021,
KM = 35000,
Price = 42000,
Currency = "USD",
Description = "Camioneta impecable, lista para transferir.",
StatusID = 3, // Moderacion Pendiente
CreatedAt = DateTime.UtcNow
};
_context.Ads.AddRange(ad1, ad2, ad3);
await _context.SaveChangesAsync();
// Agregar fotos
_context.AdPhotos.AddRange(
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?auto=format&fit=crop&q=80&w=1200", IsCover = true },
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1590362891991-f776e933a68e?auto=format&fit=crop&q=80&w=1200" },
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1549317661-bd32c8ce0db2?auto=format&fit=crop&q=80&w=1200" },
new AdPhoto { AdID = ad2.AdID, FilePath = "https://images.unsplash.com/photo-1558981403-c5f91cbba527?auto=format&fit=crop&q=80&w=800", IsCover = true },
new AdPhoto { AdID = ad3.AdID, FilePath = "https://images.unsplash.com/photo-1533473359331-0135ef1b58bf?auto=format&fit=crop&q=80&w=1200", IsCover = true }
);
// Agregar Características Técnicas
_context.AdFeatures.AddRange(
new AdFeature { AdID = ad1.AdID, FeatureKey = "Combustible", FeatureValue = "Nafta" },
new AdFeature { AdID = ad1.AdID, FeatureKey = "Transmision", FeatureValue = "Automática" },
new AdFeature { AdID = ad1.AdID, FeatureKey = "Color", FeatureValue = "Blanco" },
new AdFeature { AdID = ad2.AdID, FeatureKey = "Combustible", FeatureValue = "Nafta" },
new AdFeature { AdID = ad2.AdID, FeatureKey = "Color", FeatureValue = "Rojo" }
);
await _context.SaveChangesAsync();
}
return Ok("Database seeded successfully with Features and Multiple Photos");
}
[HttpPost("reset")]
public async Task<IActionResult> ResetDatabase()
{
_context.ChangeTracker.Clear();
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Transactions");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM AdPhotos");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM AdFeatures");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Ads");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Models");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Brands");
return await SeedDatabase();
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Mvc;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Interfaces;
namespace MotoresArgentinosV2.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class UsuariosLegacyController : ControllerBase
{
private readonly IUsuariosLegacyService _usuariosService;
public UsuariosLegacyController(IUsuariosLegacyService usuariosService)
{
_usuariosService = usuariosService;
}
[HttpGet("particular/{usuario}")]
public async Task<ActionResult<UsuarioLegacyDto>> GetParticular(string usuario)
{
var datos = await _usuariosService.ObtenerParticularPorUsuarioAsync(usuario);
if (datos == null) return NotFound("Usuario particular no encontrado en legacy.");
return Ok(datos);
}
[HttpGet("agencia/{usuario}")]
public async Task<ActionResult<AgenciaLegacyDto>> GetAgencia(string usuario)
{
var datos = await _usuariosService.ObtenerAgenciaPorUsuarioAsync(usuario);
if (datos == null) return NotFound("Agencia no encontrada en legacy.");
return Ok(datos);
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="mercadopago-sdk" Version="2.11.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MotoresArgentinosV2.Infrastructure\MotoresArgentinosV2.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@MotoresArgentinosV2.API_HostAddress = http://localhost:5262
GET {{MotoresArgentinosV2.API_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,216 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.RateLimiting; // Rate Limit
using System.Threading.RateLimiting; // Rate Limit
using System.Text;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Infrastructure.Data;
using MotoresArgentinosV2.Infrastructure.Services;
using MotoresArgentinosV2.Core.Models;
using DotNetEnv;
using Microsoft.AspNetCore.HttpOverrides;
// 🔒 ENV VARS
Env.Load();
var builder = WebApplication.CreateBuilder(args);
// Forzar a la configuración a leer las variables
builder.Configuration.AddEnvironmentVariables();
// 🔒 KESTREL HARDENING
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
// LOGGING
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
// 🔒 CORS POLICY
var frontendUrls = (builder.Configuration["AppSettings:FrontendUrl"] ?? "http://localhost:5173").Split(',');
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowSpecificOrigin",
policy => policy.WithOrigins(frontendUrls)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
// FORWARDED HEADERS (CRÍTICO PARA DOCKER/NGINX)
// Por defecto, .NET solo confía en localhost. En Docker, Nginx tiene otra IP.
// Debemos limpiar las redes conocidas para que confíe en el proxy interno de Docker.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// 🔒 RATE LIMITING
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
// En producción detrás de Nginx, RemoteIpAddress será la IP real del usuario.
// Si por alguna razón falla (ej: conexión directa local), usamos "unknown".
var remoteIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// Si es Loopback (localhost), sin límites (útil para dev)
if (System.Net.IPAddress.IsLoopback(context.Connection.RemoteIpAddress!))
{
return RateLimitPartition.GetNoLimiter("loopback");
}
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: remoteIp, // Clave correcta: IP del usuario
factory: _ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 100,
QueueLimit = 2,
Window = TimeSpan.FromMinutes(1)
});
});
options.AddPolicy("AuthPolicy", context =>
{
// 🟢 FIX: Si es localhost, SIN LÍMITES
var remoteIp = context.Connection.RemoteIpAddress;
if (System.Net.IPAddress.IsLoopback(remoteIp!))
{
return RateLimitPartition.GetNoLimiter("loopback_auth");
}
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: remoteIp?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 5, // 5 intentos por minuto para IPs externas
QueueLimit = 0,
Window = TimeSpan.FromMinutes(1)
});
});
});
// DB CONTEXTS
builder.Services.AddDbContext<InternetDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Internet")));
builder.Services.AddDbContext<AutosDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Autos")));
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.Configure<MailSettings>(builder.Configuration.GetSection("SmtpSettings"));
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<IImageStorageService, ImageStorageService>();
builder.Services.AddHostedService<AdExpirationService>();
// 🔒 JWT AUTH
var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key Missing");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
// 🟢 LEER TOKEN DESDE COOKIE
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// Buscar el token en la cookie llamada "accessToken"
var accessToken = context.Request.Cookies["accessToken"];
if (!string.IsNullOrEmpty(accessToken))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// USAR EL MIDDLEWARE AL PRINCIPIO
// Debe ser lo primero para que el RateLimiter y los Logs vean la IP real
app.UseForwardedHeaders();
// 🔒 HEADERS DE SEGURIDAD MIDDLEWARE
app.Use(async (context, next) =>
{
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");
// CSP adaptada para permitir pagos en Payway y WebSockets de Vite
string csp = "default-src 'self'; " +
"img-src 'self' data: https: blob:; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"connect-src 'self' https: ws: wss:; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self' https://developers-ventasonline.payway.com.ar; " +
"frame-ancestors 'none';";
context.Response.Headers.Append("Content-Security-Policy", csp);
context.Response.Headers.Remove("Server");
context.Response.Headers.Remove("X-Powered-By");
await next();
});
// PIPELINE
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
else
{
// 🔒 HSTS en Producción
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
// 🔒 APLICAR CORS & RATE LIMIT
app.UseCors("AllowSpecificOrigin");
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5262",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7251;http://localhost:5262",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,120 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace MotoresArgentinosV2.Core.DTOs;
public class CreateAdRequestDto
{
[Required]
[Range(1, 3, ErrorMessage = "Tipo de vehículo inválido")]
[JsonPropertyName("vehicleTypeID")]
public int VehicleTypeID { get; set; }
[Required]
[JsonPropertyName("brandID")]
public int BrandID { get; set; }
[JsonPropertyName("modelID")]
public int ModelID { get; set; }
[Required]
[StringLength(100, MinimumLength = 1, ErrorMessage = "La versión debe tener entre 1 y 100 caracteres.")]
[RegularExpression(@"^[a-zA-Z0-9\s\-\.\(\),/áéíóúÁÉÍÓÚñÑ]+$", ErrorMessage = "Caracteres no permitidos en el nombre de versión.")]
[JsonPropertyName("versionName")]
public string VersionName { get; set; } = string.Empty;
[Range(1900, 2100)]
[JsonPropertyName("year")]
public int Year { get; set; }
[Range(0, 2000000)]
[JsonPropertyName("km")]
public int KM { get; set; }
[Range(0, 999999999)]
[JsonPropertyName("price")]
public decimal Price { get; set; }
[Required]
[StringLength(3, MinimumLength = 3)]
[RegularExpression(@"^(ARS|USD)$", ErrorMessage = "Moneda inválida.")]
[JsonPropertyName("currency")]
public string Currency { get; set; } = "ARS";
[StringLength(1000, ErrorMessage = "La descripción no puede superar los 1000 caracteres.")]
[RegularExpression(@"^(?!.*<script>).*$", ErrorMessage = "Contenido no permitido.")]
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
[JsonPropertyName("isFeatured")]
public bool IsFeatured { get; set; }
[StringLength(50)]
[JsonPropertyName("fuelType")]
public string? FuelType { get; set; }
[StringLength(50)]
[JsonPropertyName("color")]
public string? Color { get; set; }
[StringLength(50)]
[JsonPropertyName("segment")]
public string? Segment { get; set; }
[StringLength(100)]
[JsonPropertyName("location")]
public string? Location { get; set; }
[StringLength(50)]
[JsonPropertyName("condition")]
public string? Condition { get; set; }
[JsonPropertyName("doorCount")]
public int? DoorCount { get; set; }
[StringLength(50)]
[JsonPropertyName("transmission")]
public string? Transmission { get; set; }
[StringLength(50)]
[JsonPropertyName("steering")]
public string? Steering { get; set; }
// --- Contacto Personalizado ---
[StringLength(50)]
[RegularExpression(@"^\+?[0-9\s\-\(\)]+$", ErrorMessage = "Formato de teléfono inválido.")]
[JsonPropertyName("contactPhone")]
public string? ContactPhone { get; set; }
[StringLength(100)]
[EmailAddress(ErrorMessage = "Formato de email inválido.")]
[JsonPropertyName("contactEmail")]
public string? ContactEmail { get; set; }
[JsonPropertyName("displayContactInfo")]
public bool DisplayContactInfo { get; set; } = true;
// --- Admin Only ---
[JsonPropertyName("targetUserID")]
public int? TargetUserID { get; set; }
[StringLength(100)]
[EmailAddress(ErrorMessage = "Email de usuario fantasma inválido.")]
[JsonPropertyName("ghostUserEmail")]
public string? GhostUserEmail { get; set; }
[StringLength(100)]
[JsonPropertyName("ghostFirstName")]
public string? GhostFirstName { get; set; }
[StringLength(100)]
[JsonPropertyName("ghostLastName")]
public string? GhostLastName { get; set; }
[StringLength(50)]
[RegularExpression(@"^\+?[0-9\s\-\(\)]+$", ErrorMessage = "Teléfono de usuario fantasma inválido.")]
[JsonPropertyName("ghostUserPhone")]
public string? GhostUserPhone { get; set; }
}

View File

@@ -0,0 +1,50 @@
namespace MotoresArgentinosV2.Core.DTOs;
public class LoginRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public class MigrateRequest
{
public string Username { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}
public class MFARequest
{
public string Username { get; set; } = string.Empty;
public string Code { get; set; } = string.Empty;
}
public class RegisterRequest
{
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string PhoneNumber { get; set; } = string.Empty;
}
public class VerifyEmailRequest
{
public string Token { get; set; } = string.Empty;
}
public class ResendVerificationRequest
{
public string Email { get; set; } = string.Empty;
}
public class ForgotPasswordRequest
{
public string Email { get; set; } = string.Empty;
}
public class ResetPasswordRequest
{
public string Token { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,13 @@
namespace MotoresArgentinosV2.Core.DTOs;
public class AvisoWebDto
{
public string NombreAviso { get; set; } = string.Empty;
public DateTime? FechaInicio { get; set; }
public decimal ImporteAviso { get; set; }
public string? Estado { get; set; }
// Agrego campos extra útiles si existen (deducidos)
public int? NroOperacion { get; set; }
public string? Razon { get; set; }
}

View File

@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace MotoresArgentinosV2.Core.DTOs;
public class DatosAvisoDto
{
// --- Bytes (TinyInt en SQL) ---
[Column("ID_TIPOAVI")]
public byte IdTipoavi { get; set; }
[Column("ID_SUBRUBRO")]
public byte IdSubrubro { get; set; }
// --- Shorts (SmallInt en SQL) ---
// ESTE ERA EL CAUSANTE DEL ERROR PRINCIPAL
[Column("ID_RUBRO")]
public short IdRubro { get; set; }
// --- Ints (Int32 en SQL) ---
[Column("ID_COMBINADO")]
public int IdCombinado { get; set; }
[Column("PORCENTAJE_COMBINADO")]
public int PorcentajeCombinado { get; set; }
[Column("CANTIDAD_DIAS")]
public int CantidadDias { get; set; }
[Column("DIAS_CORRIDOS")]
public int DiasCorridos { get; set; }
[Column("PALABRAS")]
public int Palabras { get; set; }
[Column("CENTIMETROS")]
public int Centimetros { get; set; }
[Column("COLUMNAS")]
public int Columnas { get; set; }
[Column("TOTAL_AVISOS")]
public int TotalAvisos { get; set; }
[Column("DESTACADO")]
public int Destacado { get; set; }
[Column("PAQUETE")]
public int Paquete { get; set; }
// --- Decimales ---
[Column("IMPORTE_SINIVA")]
public decimal ImporteSiniva { get; set; }
[Column("IMPORTE_TOTSINIVA")]
public decimal ImporteTotsiniva { get; set; }
// --- Strings ---
[Column("NOMAVI")]
public string Nomavi { get; set; } = string.Empty;
[Column("TEXTOAVI")]
public string Textoavi { get; set; } = string.Empty;
[Column("DESCRIPCION")]
public string Descripcion { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,70 @@
namespace MotoresArgentinosV2.Core.DTOs;
/// <summary>
/// DTO para insertar un aviso mediante el SP spInsertaAvisos
/// </summary>
public class InsertarAvisoDto
{
// Datos básicos
public string Tipo { get; set; } = string.Empty;
public int NroOperacion { get; set; }
public int IdCliente { get; set; }
public int Tipodoc { get; set; }
public string NroDoc { get; set; } = string.Empty;
public string Razon { get; set; } = string.Empty;
// Ubicación del cliente
public string Calle { get; set; } = string.Empty;
public string Numero { get; set; } = string.Empty;
public string Localidad { get; set; } = string.Empty;
public string CodigoPostal { get; set; } = string.Empty;
// Contacto
public string Telefono { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
// Impuestos
public byte IdTipoiva { get; set; }
public decimal PorcentajeIva1 { get; set; }
public decimal PorcentajeIva2 { get; set; }
public decimal PorcentajePercepcion { get; set; }
// Datos del aviso
public byte IdTipoaviso { get; set; }
public string Nombreaviso { get; set; } = string.Empty;
public short IdRubro { get; set; }
public byte IdSubrubro { get; set; }
public byte IdCombinado { get; set; }
public decimal PorcentajeCombinado { get; set; }
// Publicación
public DateTime FechaInicio { get; set; }
public byte CantDias { get; set; }
public bool DiasCorridos { get; set; }
public byte Palabras { get; set; }
public decimal Centimetros { get; set; }
public byte Columnas { get; set; }
// Pago
public byte IdTarjeta { get; set; }
public string NroTarjeta { get; set; } = string.Empty;
public int CvcTarjeta { get; set; }
public DateTime Vencimiento { get; set; }
// Dirección de envío (si aplica)
public string CalleEnvio { get; set; } = string.Empty;
public string NumeroEnvio { get; set; } = string.Empty;
public string LocalidadEnvio { get; set; } = string.Empty;
// Importes
public decimal Tarifa { get; set; }
public decimal ImporteAviso { get; set; }
public decimal ImporteIva1 { get; set; }
public decimal ImporteIva2 { get; set; }
public decimal ImportePercepcion { get; set; }
// Extras
public int Cantavi { get; set; } = 1;
public int Paquete { get; set; } = 0;
public bool Destacado { get; set; } = false;
}

View File

@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
namespace MotoresArgentinosV2.Core.DTOs;
public class CreatePaymentRequestDto
{
[Required]
public int AdId { get; set; } // ID del aviso que se está pagando
[Required]
public string Token { get; set; } = string.Empty; // Token de la tarjeta generado en el front
[Required]
public string PaymentMethodId { get; set; } = string.Empty; // ej: "visa", "master"
[Required]
public int Installments { get; set; } // Cuotas
[Required]
public string IssuerId { get; set; } = string.Empty; // Banco emisor
[Required]
public decimal TransactionAmount { get; set; }
[Required]
[EmailAddress]
public string PayerEmail { get; set; } = string.Empty;
public string? Description { get; set; }
}
public class PaymentResponseDto
{
public long PaymentId { get; set; }
public string Status { get; set; } = string.Empty; // approved, rejected, in_process
public string StatusDetail { get; set; } = string.Empty;
public string OperationCode { get; set; } = string.Empty; // Nuestro ID interno (M2-...)
}

View File

@@ -0,0 +1,32 @@
namespace MotoresArgentinosV2.Core.DTOs;
public class UserDetailDto
{
public int UserID { get; set; }
public string UserName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? PhoneNumber { get; set; }
public byte UserType { get; set; }
public bool IsBlocked { get; set; }
public byte MigrationStatus { get; set; }
public bool IsEmailVerified { get; set; }
public DateTime CreatedAt { get; set; }
}
public class UserUpdateDto
{
public string UserName { get; set; } = string.Empty;
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? PhoneNumber { get; set; }
public byte UserType { get; set; }
}
public class ProfileUpdateDto
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? PhoneNumber { get; set; }
}

View File

@@ -0,0 +1,41 @@
namespace MotoresArgentinosV2.Core.DTOs;
public class UsuarioLegacyDto
{
// Mapeo tentativo basado en convenciones v_particulares
public int Part_Id { get; set; }
public string Part_Usu_Nombre { get; set; } = string.Empty;
public string? Part_Nombre { get; set; } // O razon social
public string? Part_Apellido { get; set; }
public string? Part_Email { get; set; }
public string? Part_Telefono { get; set; }
// Dirección
public string? Part_Calle { get; set; }
public string? Part_Nro { get; set; }
public string? Part_Localidad { get; set; }
public string? Part_CP { get; set; }
// Fiscal
public int? Part_TipoDoc { get; set; }
public string? Part_NroDoc { get; set; }
public int? Part_IdIVA { get; set; }
}
public class AgenciaLegacyDto
{
// Mapeo tentativo basado en convenciones v_agencias
public int Agen_Id { get; set; }
public string Agen_usuario { get; set; } = string.Empty;
public string? Agen_Nombre { get; set; } // Razon social
public string? Agen_Email { get; set; }
public string? Agen_Telefono { get; set; }
// Dirección
public string? Agen_Domicilio { get; set; }
public string? Agen_Localidad { get; set; }
// Fiscal
public string? Agen_Cuit { get; set; }
public int? Agen_IdIVA { get; set; }
}

View File

@@ -0,0 +1,209 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace MotoresArgentinosV2.Core.Entities;
public class Brand
{
public int BrandID { get; set; }
public int VehicleTypeID { get; set; }
public string Name { get; set; } = string.Empty;
public int? LegacyID { get; set; }
}
public class Model
{
public int ModelID { get; set; }
public int BrandID { get; set; }
public string Name { get; set; } = string.Empty;
public int? LegacyID { get; set; }
}
public enum UserMigrationStatus : byte { LegacyPending = 0, MigratedActive = 1 }
public enum AdStatusEnum
{
Draft = 1,
PaymentPending = 2,
ModerationPending = 3,
Active = 4,
Rejected = 5,
Paused = 6,
Sold = 7,
Expired = 8,
Deleted = 9,
Reserved = 10
}
public class User
{
public int UserID { get; set; }
public string UserName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string? PasswordSalt { get; set; }
public byte MigrationStatus { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? PhoneNumber { get; set; }
public byte UserType { get; set; } = 1; // 1: Particular, 3: Admin
public string? MFASecret { get; set; }
public bool IsMFAEnabled { get; set; }
// Email Verification
public bool IsEmailVerified { get; set; }
public string? VerificationToken { get; set; }
public DateTime? VerificationTokenExpiresAt { get; set; }
public DateTime? LastVerificationEmailSentAt { get; set; }
// Password Reset
public string? PasswordResetToken { get; set; }
public DateTime? PasswordResetTokenExpiresAt { get; set; }
public DateTime? LastPasswordResetEmailSentAt { get; set; }
// Bloqueo de usuario
public bool IsBlocked { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Relación con Refresh Tokens
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
// Notificaciones de recordatorio de mensajes no leídos
public DateTime? LastUnreadMessageReminderSentAt { get; set; }
}
public class AuditLog
{
public int AuditLogID { get; set; }
public string Action { get; set; } = string.Empty;
public string Entity { get; set; } = string.Empty;
public int EntityID { get; set; }
public int UserID { get; set; }
public string Details { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class Ad
{
public int AdID { get; set; }
public int UserID { get; set; }
public User User { get; set; } = null!;
public int VehicleTypeID { get; set; }
public int BrandID { get; set; }
public Brand Brand { get; set; } = null!;
public int ModelID { get; set; }
public Model Model { get; set; } = null!;
public string? VersionName { get; set; }
public int Year { get; set; }
public int KM { get; set; }
public string? FuelType { get; set; }
public string? Color { get; set; }
public string? Segment { get; set; }
public decimal Price { get; set; }
public string Currency { get; set; } = "USD";
public string? Description { get; set; }
public string? Location { get; set; }
public string? Condition { get; set; }
public int? DoorCount { get; set; }
public string? Transmission { get; set; }
public string? Steering { get; set; }
public string? ContactPhone { get; set; }
public string? ContactEmail { get; set; }
public bool DisplayContactInfo { get; set; } = true;
public bool IsFeatured { get; set; }
public int StatusID { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? PublishedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public int? LegacyAdID { get; set; }
public DateTime? DeletedAt { get; set; }
public int ViewsCounter { get; set; }
public ICollection<AdPhoto> Photos { get; set; } = new List<AdPhoto>();
public ICollection<AdFeature> Features { get; set; } = new List<AdFeature>();
public ICollection<ChatMessage> Messages { get; set; } = new List<ChatMessage>();
// CAMPOS DE CONTROL DE NOTIFICACIONES
public DateTime? LastPerformanceEmailSentAt { get; set; } // Para el resumen semanal
public DateTime? PaymentReminderSentAt { get; set; } // Para carrito abandonado
public bool ExpirationWarningSent { get; set; } // Para aviso por vencer
}
public class AdPhoto
{
public int PhotoID { get; set; }
public int AdID { get; set; }
public string FilePath { get; set; } = string.Empty;
public bool IsCover { get; set; }
public int SortOrder { get; set; }
public Ad Ad { get; set; } = null!;
}
public class AdFeature
{
public int AdID { get; set; }
public string FeatureKey { get; set; } = string.Empty;
public string? FeatureValue { get; set; }
}
public class TransactionRecord
{
public int TransactionID { get; set; }
public int AdID { get; set; }
public Ad Ad { get; set; } = null!;
public string OperationCode { get; set; } = string.Empty;
public int PaymentMethodID { get; set; }
public decimal Amount { get; set; }
public string Status { get; set; } = "PENDING";
public string? ProviderPaymentId { get; set; }
public string? ProviderResponse { get; set; }
public string? SnapshotUserEmail { get; set; }
public string? SnapshotUserName { get; set; }
public string? SnapshotAdTitle { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
}
public class Favorite
{
public int UserID { get; set; }
public int AdID { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class ChatMessage
{
[Key]
public int MessageID { get; set; }
public int AdID { get; set; }
[ForeignKey("AdID")]
[JsonIgnore]
public Ad? Ad { get; set; }
public int SenderID { get; set; }
public int ReceiverID { get; set; }
public string MessageText { get; set; } = string.Empty;
public DateTime SentAt { get; set; } = DateTime.UtcNow;
public bool IsRead { get; set; } = false;
}
public class PaymentMethod
{
public int PaymentMethodID { get; set; }
public string Name { get; set; } = string.Empty;
}
public class AdViewLog
{
public int Id { get; set; }
public int AdID { get; set; }
public string IPAddress { get; set; } = string.Empty;
public DateTime ViewDate { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,18 @@
namespace MotoresArgentinosV2.Core.Entities;
/// <summary>
/// Entidad que representa un medio de pago disponible
/// Tabla legacy: mediodepago (DB: autos)
/// </summary>
public class MedioDePago
{
/// <summary>
/// Identificador único del medio de pago
/// </summary>
public byte Id { get; set; }
/// <summary>
/// Nombre del medio de pago (ej: Visa, MasterCard, etc.)
/// </summary>
public string Mediodepago { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,163 @@
namespace MotoresArgentinosV2.Core.Entities;
/// <summary>
/// Entidad que representa una operación de pago
/// Tabla legacy: operaciones (DB: autos)
/// </summary>
public class Operacion
{
/// <summary>
/// Identificador único de la operación (IDENTITY)
/// </summary>
public int Id { get; set; }
/// <summary>
/// Fecha de la operación
/// </summary>
public DateTime? Fecha { get; set; }
/// <summary>
/// Motivo de la operación
/// </summary>
public string? Motivo { get; set; }
/// <summary>
/// Moneda utilizada
/// </summary>
public string? Moneda { get; set; }
/// <summary>
/// Dirección de entrega
/// </summary>
public string? Direccionentrega { get; set; }
/// <summary>
/// Código de validación de domicilio
/// </summary>
public string? Validaciondomicilio { get; set; }
/// <summary>
/// Código de pedido
/// </summary>
public string? Codigopedido { get; set; }
/// <summary>
/// Nombre para la entrega
/// </summary>
public string? Nombreentrega { get; set; }
/// <summary>
/// Fecha y hora de la transacción
/// </summary>
public string? Fechahora { get; set; }
/// <summary>
/// Teléfono del comprador
/// </summary>
public string? Telefonocomprador { get; set; }
/// <summary>
/// Barrio de entrega
/// </summary>
public string? Barrioentrega { get; set; }
/// <summary>
/// Código de autorización de la transacción
/// </summary>
public string? Codautorizacion { get; set; }
/// <summary>
/// País de entrega
/// </summary>
public string? Paisentrega { get; set; }
/// <summary>
/// Cantidad de cuotas
/// </summary>
public string? Cuotas { get; set; }
/// <summary>
/// Validación de fecha de nacimiento
/// </summary>
public string? Validafechanac { get; set; }
/// <summary>
/// Validación de número de documento
/// </summary>
public string? Validanrodoc { get; set; }
/// <summary>
/// Titular de la tarjeta
/// </summary>
public string? Titular { get; set; }
/// <summary>
/// Número de pedido
/// </summary>
public string? Pedido { get; set; }
/// <summary>
/// Código postal de entrega
/// </summary>
public string? Zipentrega { get; set; }
/// <summary>
/// Monto de la transacción
/// </summary>
public string? Monto { get; set; }
/// <summary>
/// Tipo de tarjeta utilizada
/// </summary>
public string? Tarjeta { get; set; }
/// <summary>
/// Fecha de entrega
/// </summary>
public string? Fechaentrega { get; set; }
/// <summary>
/// Email del comprador
/// </summary>
public string? Emailcomprador { get; set; }
/// <summary>
/// Validación número de puerta
/// </summary>
public string? Validanropuerta { get; set; }
/// <summary>
/// Ciudad de entrega
/// </summary>
public string? Ciudadentrega { get; set; }
/// <summary>
/// Validación tipo de documento
/// </summary>
public string? Validatipodoc { get; set; }
/// <summary>
/// Número de operación
/// </summary>
public string? Noperacion { get; set; }
/// <summary>
/// Estado de la entrega
/// </summary>
public string? Estadoentrega { get; set; }
/// <summary>
/// Resultado de la operación (APROBADA/RECHAZADA)
/// </summary>
public string? Resultado { get; set; }
/// <summary>
/// Mensaje sobre la entrega
/// </summary>
public string? Mensajeentrega { get; set; }
/// <summary>
/// Precio neto de la operación
/// </summary>
public int? Precioneto { get; set; }
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace MotoresArgentinosV2.Core.Entities;
public class RefreshToken
{
[Key]
public int Id { get; set; }
public string Token { get; set; } = string.Empty;
public DateTime Expires { get; set; }
public DateTime Created { get; set; } = DateTime.UtcNow;
public string? CreatedByIp { get; set; }
public DateTime? Revoked { get; set; }
public string? RevokedByIp { get; set; }
public string? ReplacedByToken { get; set; }
public bool IsActive => Revoked == null && !IsExpired;
public bool IsExpired => DateTime.UtcNow >= Expires;
public int UserId { get; set; }
public User User { get; set; } = null!;
}

View File

@@ -0,0 +1,8 @@
using MotoresArgentinosV2.Core.DTOs;
namespace MotoresArgentinosV2.Core.Interfaces;
public interface IAdSyncService
{
Task<bool> SyncAdToLegacyAsync(int adId);
}

View File

@@ -0,0 +1,28 @@
using MotoresArgentinosV2.Core.DTOs;
namespace MotoresArgentinosV2.Core.Interfaces;
/// <summary>
/// Interfaz para servicios que interactúan con stored procedures legacy
/// relacionados con avisos
/// </summary>
public interface IAvisosLegacyService
{
/// <summary>
/// Ejecuta el SP spDatosAvisos para obtener tarifas y configuración
/// </summary>
/// <param name="tarea">Tipo de tarea (EMOTORES, EREPUESTOS, EAUTOS, etc.)</param>
/// <param name="paquete">ID del paquete (opcional)</param>
/// <returns>Lista de configuraciones de avisos disponibles</returns>
Task<List<DatosAvisoDto>> ObtenerDatosAvisosAsync(string tarea, int paquete = 0);
/// <summary>
/// Ejecuta el SP spInsertaAvisos para crear un nuevo aviso
/// </summary>
/// <param name="aviso">Datos del aviso a crear</param>
/// <returns>True si se insertó correctamente</returns>
Task<bool> InsertarAvisoAsync(InsertarAvisoDto aviso);
Task<List<DatosAvisoDto>> ObtenerTarifasAsync(string formulario, int paquete);
Task<List<AvisoWebDto>> ObtenerAvisosPorClienteAsync(string nroDocumento);
}

View File

@@ -0,0 +1,6 @@
namespace MotoresArgentinosV2.Core.Interfaces;
public interface IEmailService
{
Task SendEmailAsync(string to, string subject, string htmlBody);
}

View File

@@ -0,0 +1,20 @@
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Entities;
namespace MotoresArgentinosV2.Core.Interfaces;
public interface IIdentityService
{
Task<(User? User, string? MigrationMessage)> AuthenticateAsync(string username, string password);
Task<bool> MigratePasswordAsync(string username, string newPassword);
// métodos para registro
Task<(bool Success, string Message)> RegisterUserAsync(RegisterRequest request);
Task<(bool Success, string Message)> VerifyEmailAsync(string token);
Task<(bool Success, string Message)> ResendVerificationEmailAsync(string email);
Task<(bool Success, string Message)> ForgotPasswordAsync(string email);
Task<(bool Success, string Message)> ResetPasswordAsync(string token, string newPassword);
Task<(bool Success, string Message)> ChangePasswordAsync(int userId, string current, string newPwd);
Task<User> CreateGhostUserAsync(string email, string firstName, string lastName, string phone);
}

View File

@@ -0,0 +1,16 @@
namespace MotoresArgentinosV2.Core.Interfaces;
public record AdPriceResult(decimal BasePrice, decimal Tax, decimal TotalPrice, string Currency);
public interface ILegacyPaymentService
{
/// <summary>
/// Consulta el precio en el sistema legacy (SPDATOSAVISOS)
/// </summary>
Task<AdPriceResult> GetAdPriceAsync(string category, bool isFeatured);
/// <summary>
/// Finaliza la operación tras el pago (Simula callback de Decidir y ejecuta spInsertaAvisos)
/// </summary>
Task<bool> ProcessPaymentResponseAsync(string operationCode, string status, string providerData);
}

View File

@@ -0,0 +1,14 @@
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);
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);
Task SendPaymentReceiptEmailAsync(string toEmail, string userName, string adTitle, decimal amount, string operationCode);
Task SendUnreadMessagesReminderEmailAsync(string toEmail, string userName, int unreadCount);
}

View File

@@ -0,0 +1,38 @@
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();
}

View File

@@ -0,0 +1,7 @@
namespace MotoresArgentinosV2.Core.Interfaces;
public interface IPasswordService
{
string HashPassword(string password);
bool VerifyPassword(string password, string hash, string? salt, bool isLegacy);
}

View File

@@ -0,0 +1,10 @@
using MotoresArgentinosV2.Core.DTOs;
namespace MotoresArgentinosV2.Core.Interfaces;
public interface IPaymentService
{
Task<PaymentResponseDto> ProcessPaymentAsync(CreatePaymentRequestDto request, int userId);
Task ProcessWebhookAsync(string topic, string id);
Task<PaymentResponseDto> CheckPaymentStatusAsync(int adId);
}

View File

@@ -0,0 +1,15 @@
using MotoresArgentinosV2.Core.Entities;
namespace MotoresArgentinosV2.Core.Interfaces;
public interface ITokenService
{
string GenerateJwtToken(User user);
RefreshToken GenerateRefreshToken(string ipAddress);
string GenerateMFACode(); // Legacy email code
// TOTP (Google Authenticator)
string GenerateBase32Secret();
string GetQrCodeUri(string userEmail, string secret);
bool ValidateTOTP(string secret, string code);
}

View File

@@ -0,0 +1,9 @@
using MotoresArgentinosV2.Core.DTOs;
namespace MotoresArgentinosV2.Core.Interfaces;
public interface IUsuariosLegacyService
{
Task<UsuarioLegacyDto?> ObtenerParticularPorUsuarioAsync(string nombreUsuario);
Task<AgenciaLegacyDto?> ObtenerAgenciaPorUsuarioAsync(string nombreUsuario);
}

View File

@@ -0,0 +1,11 @@
namespace MotoresArgentinosV2.Core.Models;
public class MailSettings
{
public string SmtpHost { get; set; } = string.Empty;
public int SmtpPort { get; set; }
public string SmtpUser { get; set; } = string.Empty;
public string SmtpPass { get; set; } = string.Empty;
public string SenderEmail { get; set; } = string.Empty;
public string SenderName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Core.Entities;
namespace MotoresArgentinosV2.Infrastructure.Data;
/// <summary>
/// Contexto de Entity Framework para la base de datos Autos (legacy)
/// Servidor: TECNICA3
/// Base de Datos: autos
/// Propósito: Acceso a operaciones de pago y medios de pago
/// </summary>
public class AutosDbContext : DbContext
{
public AutosDbContext(DbContextOptions<AutosDbContext> options) : base(options)
{
}
/// <summary>
/// Tabla de operaciones de pago
/// </summary>
public DbSet<Operacion> Operaciones { get; set; }
/// <summary>
/// Tabla de medios de pago disponibles
/// </summary>
public DbSet<MedioDePago> MediosDePago { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configuración para la tabla operaciones
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");
});
// Configuración para la tabla mediodepago
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);
});
}
}

View File

@@ -0,0 +1,36 @@
// Backend/MotoresArgentinosV2.Infrastructure/Data/InternetDbContext.cs
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Core.DTOs;
namespace MotoresArgentinosV2.Infrastructure.Data;
/// <summary>
/// Contexto de Entity Framework para la base de datos Internet (legacy)
/// Servidor: ...
/// Base de Datos: internet
/// Propósito: Acceso a datos de avisos web
/// </summary>
public class InternetDbContext : DbContext
{
public InternetDbContext(DbContextOptions<InternetDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Registrar el DTO como entidad sin llave (Keyless) para que SqlQueryRaw funcione bien
modelBuilder.Entity<DatosAvisoDto>(e =>
{
e.HasNoKey();
e.ToView(null); // No mapea a tabla
// Configurar precisión de decimales para silenciar warnings
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)");
});
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Core.Entities;
namespace MotoresArgentinosV2.Infrastructure.Data;
public class MotoresV2DbContext : DbContext
{
public MotoresV2DbContext(DbContextOptions<MotoresV2DbContext> options)
: base(options)
{
}
public DbSet<User> Users { get; set; }
public DbSet<Ad> Ads { get; set; }
public DbSet<AdPhoto> AdPhotos { get; set; }
public DbSet<AdFeature> AdFeatures { get; set; }
public DbSet<Brand> Brands { get; set; }
public DbSet<Model> Models { get; set; }
public DbSet<TransactionRecord> Transactions { get; set; }
public DbSet<Favorite> Favorites { get; set; }
public DbSet<ChatMessage> ChatMessages { get; set; }
public DbSet<AuditLog> AuditLogs { get; set; }
public DbSet<PaymentMethod> PaymentMethods { get; set; }
public DbSet<RefreshToken> RefreshTokens { get; set; }
public DbSet<AdViewLog> AdViewLogs { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PaymentMethod>().HasKey(p => p.PaymentMethodID);
modelBuilder.Entity<ChatMessage>().HasKey(m => m.MessageID);
modelBuilder.Entity<ChatMessage>().ToTable("ChatMessages");
// Configuración de Cascada para Mensajes
modelBuilder.Entity<ChatMessage>()
.HasOne(m => m.Ad)
.WithMany(a => a.Messages)
.HasForeignKey(m => m.AdID)
.OnDelete(DeleteBehavior.Cascade); // Esto asegura que EF intente borrar los mensajes
// Configuración de Favorites (Clave compuesta)
modelBuilder.Entity<Favorite>()
.HasKey(f => new { f.UserID, f.AdID });
// Nombres de tablas exactos
modelBuilder.Entity<Favorite>().ToTable("Favorites");
// Configuración de AdFeatures (Clave compuesta)
modelBuilder.Entity<AdFeature>()
.HasKey(af => new { af.AdID, af.FeatureKey });
// Configuración de Identificadores (Claves Primarias)
modelBuilder.Entity<AdPhoto>().HasKey(p => p.PhotoID);
modelBuilder.Entity<Brand>().HasKey(b => b.BrandID);
modelBuilder.Entity<Model>().HasKey(m => m.ModelID);
modelBuilder.Entity<User>().HasKey(u => u.UserID);
modelBuilder.Entity<Ad>().HasKey(a => a.AdID);
modelBuilder.Entity<TransactionRecord>().HasKey(t => t.TransactionID);
modelBuilder.Entity<Ad>().Property(a => a.Price).HasColumnType("decimal(18,2)");
modelBuilder.Entity<TransactionRecord>().Property(t => t.Amount).HasColumnType("decimal(18,2)");
// Nombres de tablas exactos para coincidir con el Roadmap
modelBuilder.Entity<User>().ToTable("Users");
modelBuilder.Entity<Ad>().ToTable("Ads");
modelBuilder.Entity<AdPhoto>().ToTable("AdPhotos");
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 });
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MotoresArgentinosV2.Core\MotoresArgentinosV2.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="MailKit" Version="4.14.1" />
<PackageReference Include="mercadopago-sdk" Version="2.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="MimeKit" Version="4.14.0" />
<PackageReference Include="Otp.NET" Version="1.3.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,448 @@
// backend/MotoresArgentinosV2.Infrastructure/Services/AdExpirationService.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MotoresArgentinosV2.Infrastructure.Data;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using Microsoft.Extensions.Configuration;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class AdExpirationService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<AdExpirationService> _logger;
public AdExpirationService(IServiceProvider serviceProvider, ILogger<AdExpirationService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Servicio de Mantenimiento Integral iniciado.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
// 1. Vencimientos
await CheckExpiredAdsAsync();
await ProcessExpirationWarningsAsync();
// 2. Limpiezas
await CleanupUnpaidDraftsAsync();
await PermanentDeleteOldDeletedAdsAsync();
await CleanupOldRefreshTokensAsync();
await CleanupAdViewLogsAsync();
// 3. Marketing y Retención
await ProcessWeeklyStatsAsync();
await ProcessPaymentRemindersAsync();
await ProcessUnreadMessagesRemindersAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error CRÍTICO en ciclo de mantenimiento.");
}
// Ejecutar cada 1 hora
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
private async Task CleanupAdViewLogsAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
// Borrar logs de visitas de más de 30 días de antigüedad
var cutoffDate = DateTime.UtcNow.AddDays(-30);
var deletedCount = await context.AdViewLogs
.Where(l => l.ViewDate < cutoffDate)
.ExecuteDeleteAsync();
if (deletedCount > 0)
{
logger.LogInformation("Mantenimiento: Se eliminaron {Count} registros de visitas antiguos.", deletedCount);
}
}
}
private async Task CheckExpiredAdsAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var cutoffDate = DateTime.UtcNow.AddDays(-30);
var expiredAds = await context.Ads
.Include(a => a.User)
.Include(a => a.Brand)
.Where(a =>
// Regla 1: Aviso activo
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
)
.ToListAsync();
foreach (var ad in expiredAds)
{
ad.StatusID = (int)AdStatusEnum.Expired;
if (ad.User != null && !string.IsNullOrEmpty(ad.User.Email))
{
var title = $"{ad.Brand?.Name} {ad.VersionName}";
await notifService.SendAdExpiredEmailAsync(ad.User.Email, ad.User.FirstName ?? "Usuario", title);
}
context.AuditLogs.Add(new AuditLog
{
Action = "SYSTEM_AD_EXPIRED",
Entity = "Ad",
EntityID = ad.AdID,
UserID = 0,
Details = $"Aviso ID {ad.AdID} vencido. Email enviado a usuario no-admin."
});
}
if (expiredAds.Any()) await context.SaveChangesAsync();
}
}
private async Task ProcessExpirationWarningsAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var warningThreshold = DateTime.UtcNow.AddDays(-25);
var adsToWarn = await context.Ads
.Include(a => a.User)
.Include(a => a.Brand)
.Where(a =>
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
)
.ToListAsync();
foreach (var ad in adsToWarn)
{
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
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);
ad.ExpirationWarningSent = true;
}
catch { /* Log error pero continuar */ }
}
if (adsToWarn.Any()) await context.SaveChangesAsync();
}
}
private async Task ProcessWeeklyStatsAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
var adsForStats = await context.Ads
.Include(a => a.User)
.Include(a => a.Brand)
.Where(a =>
a.StatusID == (int)AdStatusEnum.Active &&
a.User != null && a.User.UserType != 3 && // Ya estaba excluido aquí
a.PublishedAt.HasValue && a.PublishedAt.Value <= sevenDaysAgo &&
(a.LastPerformanceEmailSentAt == null || a.LastPerformanceEmailSentAt <= sevenDaysAgo)
)
.Take(50)
.ToListAsync();
foreach (var ad in adsForStats)
{
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
var favCount = await context.Favorites.CountAsync(f => f.AdID == ad.AdID);
var title = $"{ad.Brand?.Name} {ad.VersionName}";
await notifService.SendWeeklyPerformanceEmailAsync(
ad.User.Email,
ad.User.FirstName ?? "Usuario",
title,
ad.ViewsCounter,
favCount
);
ad.LastPerformanceEmailSentAt = DateTime.UtcNow;
}
if (adsForStats.Any()) await context.SaveChangesAsync();
}
}
private async Task ProcessPaymentRemindersAsync()
{
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"] ?? "http://localhost:5173";
var cutoff = DateTime.UtcNow.AddHours(-24);
var abandonedCarts = await context.Ads
.Include(a => a.User)
.Include(a => a.Brand)
.Where(a =>
(a.StatusID == 1 || a.StatusID == 2) &&
a.CreatedAt < cutoff &&
a.PaymentReminderSentAt == null &&
a.User != null && a.User.UserType != 3
)
.Take(20)
.ToListAsync();
foreach (var ad in abandonedCarts)
{
if (ad.User == null || string.IsNullOrEmpty(ad.User.Email)) continue;
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);
ad.PaymentReminderSentAt = DateTime.UtcNow;
}
if (abandonedCarts.Any()) await context.SaveChangesAsync();
}
}
private async Task ProcessUnreadMessagesRemindersAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var notifService = scope.ServiceProvider.GetRequiredService<INotificationService>();
// 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);
var notificationThreshold = DateTime.UtcNow.AddHours(-24);
// 1. Obtener IDs de usuarios con mensajes sin leer viejos
var usersWithUnread = await context.ChatMessages
.Where(m => !m.IsRead && m.SentAt < messageThreshold)
.Select(m => m.ReceiverID)
.Distinct()
.ToListAsync();
foreach (var userId in usersWithUnread)
{
var user = await context.Users.FindAsync(userId);
// Verificar si ya le avisamos hoy
if (user == null || string.IsNullOrEmpty(user.Email) ||
(user.LastUnreadMessageReminderSentAt != null && user.LastUnreadMessageReminderSentAt > notificationThreshold))
{
continue;
}
// 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);
user.LastUnreadMessageReminderSentAt = DateTime.UtcNow;
}
if (usersWithUnread.Any()) await context.SaveChangesAsync();
}
}
private async Task CleanupUnpaidDraftsAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var imageService = scope.ServiceProvider.GetRequiredService<IImageStorageService>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
// Regla: Eliminar avisos IMPAGOS (Borrador = 1) con más de 30 días de antigüedad (CreatedAt).
// No se tocan los que están en Moderación (3) ni los Rechazados (5) a menos que se especifique.
var cutoffDate = DateTime.UtcNow.AddDays(-30);
var oldDrafts = await context.Ads
.Include(a => a.Photos)
.Where(a => a.StatusID == 1 && a.CreatedAt < cutoffDate) // 1 = Borrador/Impago
.ToListAsync();
if (oldDrafts.Any())
{
logger.LogInformation("Eliminando {Count} avisos impagos (borradores) de más de 30 días de antigüedad...", oldDrafts.Count);
foreach (var draft in oldDrafts)
{
try
{
// 1. Borrar fotos del disco físico
if (draft.Photos != null)
{
foreach (var photo in draft.Photos)
{
if (!string.IsNullOrEmpty(photo.FilePath))
imageService.DeleteAdImage(photo.FilePath);
}
}
// 2. Eliminar registro de la DB
context.Ads.Remove(draft);
// 📝 AUDITORÍA
context.AuditLogs.Add(new AuditLog
{
Action = "SYSTEM_DRAFT_CLEANED",
Entity = "Ad",
EntityID = draft.AdID,
UserID = 0, // Sistema
Details = $"Borrador impago ID {draft.AdID} eliminado por antigüedad."
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error eliminando borrador ID {AdId}", draft.AdID);
}
}
await context.SaveChangesAsync();
logger.LogInformation("Limpieza de borradores antiguos completada.");
}
}
}
private async Task PermanentDeleteOldDeletedAdsAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var imageService = scope.ServiceProvider.GetRequiredService<IImageStorageService>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
var cutoffDate = DateTime.UtcNow.AddDays(-60);
var adsToRemove = await context.Ads
.Include(a => a.Photos)
.Include(a => a.Features)
.Include(a => a.Messages)
.Where(a => a.StatusID == (int)AdStatusEnum.Deleted
&& a.DeletedAt.HasValue
&& a.DeletedAt.Value < cutoffDate)
.ToListAsync();
if (adsToRemove.Any())
{
logger.LogInformation("Eliminando permanentemente {Count} avisos...", adsToRemove.Count);
foreach (var ad in adsToRemove)
{
try
{
// 1. Borrar fotos del disco físico
if (ad.Photos != null)
{
foreach (var photo in ad.Photos)
{
if (!string.IsNullOrEmpty(photo.FilePath))
imageService.DeleteAdImage(photo.FilePath);
}
}
// 2. Info para el log antes de borrar
int msgCount = ad.Messages?.Count ?? 0;
// 3. Eliminar registro de la DB
// Al tener Cascade configurado en EF y SQL, esto borrará:
// - El Aviso (Ad)
// - Sus Fotos (AdPhotos)
// - Sus Características (AdFeatures)
// - Sus Mensajes (ChatMessages)
context.Ads.Remove(ad);
// 📝 AUDITORÍA
context.AuditLogs.Add(new AuditLog
{
Action = "SYSTEM_HARD_DELETE",
Entity = "Ad",
EntityID = ad.AdID,
UserID = 0, // Sistema
Details = $"Aviso ID {ad.AdID} eliminado permanentemente. Se eliminaron {msgCount} mensajes de chat asociados."
});
logger.LogInformation("Hard Delete AdID {AdId} completado. Mensajes eliminados: {MsgCount}", ad.AdID, msgCount);
}
catch (Exception ex)
{
logger.LogError(ex, "Error en Hard Delete del aviso ID {AdId}", ad.AdID);
}
}
await context.SaveChangesAsync();
}
}
}
private async Task CleanupOldRefreshTokensAsync()
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdExpirationService>>();
// Política: Eliminar tokens que:
// 1. Ya expiraron hace más de 30 días OR
// 2. Fueron revocados hace más de 30 días
// (Mantenemos un historial de 30 días por seguridad/auditoría, luego se borra)
var cutoffDate = DateTime.UtcNow.AddDays(-30);
// Usamos ExecuteDeleteAsync para borrado masivo eficiente (EF Core 7+)
var deletedCount = await context.RefreshTokens
.Where(t => t.Expires < cutoffDate || (t.Revoked != null && t.Revoked < cutoffDate))
.ExecuteDeleteAsync();
if (deletedCount > 0)
{
logger.LogInformation("Limpieza de Tokens: Se eliminaron {Count} refresh tokens obsoletos.", deletedCount);
// No hace falta guardar AuditLog para esto, es mantenimiento técnico puro,
// pero se podría agregar.
}
}
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore;
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 AdSyncService : IAdSyncService
{
private readonly MotoresV2DbContext _context;
private readonly IAvisosLegacyService _legacyService;
private readonly ILogger<AdSyncService> _logger;
public AdSyncService(MotoresV2DbContext context, IAvisosLegacyService legacyService, ILogger<AdSyncService> logger)
{
_context = context;
_legacyService = legacyService;
_logger = logger;
}
public async Task<bool> SyncAdToLegacyAsync(int adId)
{
try
{
var ad = await _context.Ads
.Include(a => a.User)
.Include(a => a.Photos)
.Include(a => a.Features)
.FirstOrDefaultAsync(a => a.AdID == adId);
if (ad == null) return false;
// Mapeo básico a InsertarAvisoDto
var dto = new InsertarAvisoDto
{
Tipo = "V", // Vehículo
NroOperacion = ad.AdID,
IdCliente = ad.UserID,
NroDoc = ad.User?.Email ?? string.Empty,
Razon = ad.User != null ? $"{ad.User.FirstName} {ad.User.LastName}" : "Usuario Desconocido",
Email = ad.User?.Email ?? string.Empty,
Telefono = ad.ContactPhone ?? string.Empty,
Nombreaviso = ad.VersionName ?? "Vehículo sin nombre",
IdRubro = 1, // Autos por defecto
IdSubrubro = 1,
FechaInicio = DateTime.Now,
CantDias = 30,
ImporteAviso = ad.Price,
Tarifa = ad.Price,
Destacado = ad.IsFeatured
};
// Ejecutar inserción en legacy
var result = await _legacyService.InsertarAvisoAsync(dto);
if (result)
{
_logger.LogInformation("Sincronización exitosa del aviso {AdId} al sistema legacy.", adId);
// Marcamos el aviso en V2 con la referencia legacy
ad.LegacyAdID = ad.AdID; // O el ID real que devuelva el SP si fuera el caso
await _context.SaveChangesAsync();
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sincronizando aviso {AdId} a legacy", adId);
return false;
}
}
}

View File

@@ -0,0 +1,255 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Infrastructure.Data;
using System.Data;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class AvisosLegacyService : IAvisosLegacyService
{
private readonly InternetDbContext _context;
private readonly ILogger<AvisosLegacyService> _logger;
public AvisosLegacyService(InternetDbContext context, ILogger<AvisosLegacyService> logger)
{
_context = context;
_logger = logger;
}
public async Task<List<DatosAvisoDto>> ObtenerDatosAvisosAsync(string tarea, int paquete = 0)
{
var resultados = new List<DatosAvisoDto>();
try
{
// Usamos ADO.NET manual para tener control total sobre columnas faltantes
using (var command = _context.Database.GetDbConnection().CreateCommand())
{
command.CommandText = "dbo.spDatosAvisos";
command.CommandType = CommandType.StoredProcedure;
var p1 = command.CreateParameter();
p1.ParameterName = "@tarea";
string tareaSafe = tarea ?? string.Empty;
if (tareaSafe.Length > 20)
{
tareaSafe = tareaSafe.Substring(0, 20);
}
p1.Value = tareaSafe;
command.Parameters.Add(p1);
// Segundo parámetro
var p2 = command.CreateParameter();
p2.ParameterName = "@paquete";
p2.Value = paquete;
command.Parameters.Add(p2);
await _context.Database.OpenConnectionAsync();
using (var reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
{
var dto = new DatosAvisoDto();
// Usamos helpers seguros que devuelven 0 o "" si la columna no existe o es null
// IDs y Básicos
dto.IdTipoavi = GetByte(reader, "ID_TIPOAVI");
dto.IdRubro = GetShort(reader, "ID_RUBRO");
dto.IdSubrubro = GetByte(reader, "ID_SUBRUBRO");
// Strings
dto.Nomavi = GetString(reader, "NOMAVI");
dto.Textoavi = GetString(reader, "TEXTOAVI");
dto.Descripcion = GetString(reader, "DESCRIPCION");
// Integers (Configuración)
dto.IdCombinado = GetInt(reader, "ID_COMBINADO");
dto.PorcentajeCombinado = GetInt(reader, "PORCENTAJE_COMBINADO");
dto.CantidadDias = GetInt(reader, "CANTIDAD_DIAS");
dto.DiasCorridos = GetInt(reader, "DIAS_CORRIDOS");
dto.Palabras = GetInt(reader, "PALABRAS");
dto.Centimetros = GetInt(reader, "CENTIMETROS");
dto.Columnas = GetInt(reader, "COLUMNAS");
dto.TotalAvisos = GetInt(reader, "TOTAL_AVISOS");
dto.Destacado = GetInt(reader, "DESTACADO");
dto.Paquete = GetInt(reader, "PAQUETE");
// Decimales (Precios)
dto.ImporteSiniva = GetDecimal(reader, "IMPORTE_SINIVA");
// AQUÍ ESTABA EL PROBLEMA:
// Si EMOTOS no trae esta columna, el helper devolverá 0 y no fallará.
dto.ImporteTotsiniva = GetDecimal(reader, "IMPORTE_TOTSINIVA");
// Lógica de Negocio Fallback:
// Si el SP no calculó el total (columna faltante o 0), y tenemos el neto, lo calculamos nosotros.
if (dto.ImporteTotsiniva == 0 && dto.ImporteSiniva > 0)
{
// Asumimos recargo del 30% si es destacado (según lógica del SP leída)
// O simplemente tomamos el neto si es simple.
// Esto es un parche seguro para visualización.
dto.ImporteTotsiniva = dto.ImporteSiniva;
}
resultados.Add(dto);
}
}
}
return resultados;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error crítico ejecutando spDatosAvisos Manual. Tarea: {Tarea}", tarea);
throw;
}
finally
{
_context.Database.CloseConnection();
}
}
// --- Helpers de Lectura Tolerante a Fallos ---
// Intentan leer la columna. Si no existe (IndexOutOfRange) o es DBNull, devuelven valor default.
private byte GetByte(System.Data.Common.DbDataReader reader, string col)
{
try { return Convert.ToByte(reader[col]); } catch { return 0; }
}
private short GetShort(System.Data.Common.DbDataReader reader, string col)
{
try { return Convert.ToInt16(reader[col]); } catch { return 0; }
}
private int GetInt(System.Data.Common.DbDataReader reader, string col)
{
try { return Convert.ToInt32(reader[col]); } catch { return 0; }
}
private decimal GetDecimal(System.Data.Common.DbDataReader reader, string col)
{
try { return Convert.ToDecimal(reader[col]); } catch { return 0; }
}
private string GetString(System.Data.Common.DbDataReader reader, string col)
{
try { return reader[col]?.ToString() ?? ""; } catch { return ""; }
}
// --- Resto de métodos (Mantener igual) ---
public async Task<bool> InsertarAvisoAsync(InsertarAvisoDto dto)
{
try
{
var parameters = new[]
{
new SqlParameter("@tipo", dto.Tipo),
new SqlParameter("@nro_operacion", dto.NroOperacion),
new SqlParameter("@id_cliente", dto.IdCliente),
new SqlParameter("@tipodoc", dto.Tipodoc),
new SqlParameter("@nro_doc", dto.NroDoc),
new SqlParameter("@razon", dto.Razon),
new SqlParameter("@calle", dto.Calle),
new SqlParameter("@numero", dto.Numero),
new SqlParameter("@localidad", dto.Localidad),
new SqlParameter("@codigopostal", dto.CodigoPostal),
new SqlParameter("@telefono", dto.Telefono),
new SqlParameter("@email", dto.Email),
new SqlParameter("@id_tipoiva", dto.IdTipoiva),
new SqlParameter("@porcentaje_iva1", dto.PorcentajeIva1),
new SqlParameter("@porcentaje_iva2", dto.PorcentajeIva2),
new SqlParameter("@porcentaje_percepcion", dto.PorcentajePercepcion),
new SqlParameter("@id_tipoaviso", dto.IdTipoaviso),
new SqlParameter("@nombreaviso", dto.Nombreaviso),
new SqlParameter("@id_rubro", dto.IdRubro),
new SqlParameter("@id_subrubro", dto.IdSubrubro),
new SqlParameter("@id_combinado", dto.IdCombinado),
new SqlParameter("@porcentaje_combinado", dto.PorcentajeCombinado),
new SqlParameter("@fecha_inicio", dto.FechaInicio),
new SqlParameter("@cant_dias", dto.CantDias),
new SqlParameter("@dias_corridos", dto.DiasCorridos),
new SqlParameter("@palabras", dto.Palabras),
new SqlParameter("@centimetros", dto.Centimetros),
new SqlParameter("@columnas", dto.Columnas),
new SqlParameter("@id_tarjeta", dto.IdTarjeta),
new SqlParameter("@nro_tarjeta", dto.NroTarjeta),
new SqlParameter("@cvc_tarjeta", dto.CvcTarjeta),
new SqlParameter("@vencimiento", dto.Vencimiento),
new SqlParameter("@calle_envio", dto.CalleEnvio),
new SqlParameter("@numero_envio", dto.NumeroEnvio),
new SqlParameter("@localidad_envio", dto.LocalidadEnvio),
new SqlParameter("@tarifa", dto.Tarifa),
new SqlParameter("@importe_aviso", dto.ImporteAviso),
new SqlParameter("@importe_iva1", dto.ImporteIva1),
new SqlParameter("@importe_iva2", dto.ImporteIva2),
new SqlParameter("@importe_percepcion", dto.ImportePercepcion),
new SqlParameter("@cantavi", dto.Cantavi),
new SqlParameter("@paquete", dto.Paquete),
new SqlParameter("@destacado", dto.Destacado)
};
await _context.Database.ExecuteSqlRawAsync(
"EXEC dbo.spInsertaAvisos @tipo, @nro_operacion, @id_cliente, @tipodoc, @nro_doc, @razon, " +
"@calle, @numero, @localidad, @codigopostal, @telefono, @email, @id_tipoiva, @porcentaje_iva1, " +
"@porcentaje_iva2, @porcentaje_percepcion, @id_tipoaviso, @nombreaviso, @id_rubro, @id_subrubro, " +
"@id_combinado, @porcentaje_combinado, @fecha_inicio, @cant_dias, @dias_corridos, @palabras, " +
"@centimetros, @columnas, @id_tarjeta, @nro_tarjeta, @cvc_tarjeta, @vencimiento, @calle_envio, " +
"@numero_envio, @localidad_envio, @tarifa, @importe_aviso, @importe_iva1, @importe_iva2, " +
"@importe_percepcion, @cantavi, @paquete, @destacado",
parameters);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al ejecutar spInsertaAvisos");
throw;
}
}
public async Task<List<DatosAvisoDto>> ObtenerTarifasAsync(string formulario, int paquete)
{
return await ObtenerDatosAvisosAsync(formulario, paquete);
}
public async Task<List<AvisoWebDto>> ObtenerAvisosPorClienteAsync(string nroDoc)
{
try
{
var sql = @"
SELECT
nombreaviso as NombreAviso,
fecha_inicio as FechaInicio,
importe_aviso as ImporteAviso,
estado as Estado,
nro_operacion as NroOperacion
FROM dbo.AVISOSWEB
WHERE nro_doc = @nroDoc
ORDER BY fecha_inicio DESC";
var paramDoc = new SqlParameter("@nroDoc", nroDoc);
var resultado = await _context.Database
.SqlQueryRaw<AvisoWebDto>(sql, paramDoc)
.ToListAsync();
return resultado;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener avisos para el documento: {NroDocumento}", nroDoc);
return new List<AvisoWebDto>();
}
}
}

View File

@@ -0,0 +1,336 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Infrastructure.Data;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class IdentityService : IIdentityService
{
private readonly MotoresV2DbContext _v2Context;
private readonly IPasswordService _passwordService;
private readonly IEmailService _emailService;
private readonly IConfiguration _config;
public IdentityService(
MotoresV2DbContext v2Context,
IPasswordService passwordService,
IEmailService emailService,
IConfiguration config)
{
_v2Context = v2Context;
_passwordService = passwordService;
_emailService = emailService;
_config = config;
}
public async Task<(bool Success, string Message)> RegisterUserAsync(RegisterRequest request)
{
// 1. Normalización
request.Username = request.Username.ToLowerInvariant().Trim();
request.Email = request.Email.ToLowerInvariant().Trim();
if (!Regex.IsMatch(request.Username, "^[a-z0-9]{4,20}$"))
return (false, "El usuario debe tener entre 4 y 20 caracteres, solo letras y números.");
// 2. Verificar Existencia
var existingUser = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == request.Email);
// CASO ESPECIAL: Usuario Fantasma (MigrationStatus = 1 pero sin password válido y no verificado)
// Si el email existe, le decimos al usuario que use "Recuperar Contraseña".
if (existingUser != null)
{
// Si es un usuario fantasma (sin password útil o marcado como tal),
// lo ideal es que el usuario haga el flujo de "Olvidé mi contraseña" para setearla y verificar el mail.
return (false, "Este correo ya está registrado. Si te pertenece, usa 'Olvidé mi contraseña' para activar tu cuenta.");
}
var userExists = await _v2Context.Users.AnyAsync(u => u.UserName == request.Username);
if (userExists) return (false, "Este nombre de usuario ya está en uso.");
// 3. Crear Token
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
// 4. Crear Usuario
var newUser = new User
{
UserName = request.Username,
Email = request.Email,
FirstName = request.FirstName,
LastName = request.LastName,
PhoneNumber = request.PhoneNumber,
PasswordHash = _passwordService.HashPassword(request.Password),
MigrationStatus = 1,
UserType = 1,
IsEmailVerified = false,
VerificationToken = token,
VerificationTokenExpiresAt = DateTime.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow
};
_v2Context.Users.Add(newUser);
await _v2Context.SaveChangesAsync();
// 4. Enviar Email REAL
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
var verifyLink = $"{frontendUrl}/verificar-email?token={token}";
var emailBody = $@"
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
<h2 style='color: #0051ff; text-align: center;'>Bienvenido a Motores Argentinos</h2>
<p>Hola <strong>{request.FirstName}</strong>,</p>
<p>Gracias por registrarte. Para activar tu cuenta y comenzar a publicar, por favor confirma tu correo electrónico haciendo clic en el siguiente botón:</p>
<div style='text-align: center; margin: 30px 0;'>
<a href='{verifyLink}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>VERIFICAR MI CUENTA</a>
</div>
<p style='font-size: 12px; color: #666;'>Si no puedes hacer clic en el botón, copia y pega este enlace en tu navegador:</p>
<p style='font-size: 12px; color: #0051ff; word-break: break-all;'>{verifyLink}</p>
<hr style='border: 0; border-top: 1px solid #eee; margin: 20px 0;' />
<p style='font-size: 10px; color: #999; text-align: center;'>© 2026 Motores Argentinos. Este es un mensaje automático, por favor no respondas.</p>
</div>";
try
{
await _emailService.SendEmailAsync(request.Email, "Activa tu cuenta - Motores Argentinos", emailBody);
return (true, "Usuario registrado. Hemos enviado un correo de verificación a tu casilla.");
}
catch (Exception)
{
// Logueamos error pero retornamos true para no bloquear UX, el usuario pedirá reenvío luego.
return (true, "Usuario creado. Hubo un problema enviando el correo, intente ingresar para reenviarlo.");
}
}
public async Task<(bool Success, string Message)> VerifyEmailAsync(string token)
{
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.VerificationToken == token);
if (user == null) return (false, "Token inválido.");
if (user.VerificationTokenExpiresAt < DateTime.UtcNow) return (false, "El enlace ha expirado.");
user.IsEmailVerified = true;
user.VerificationToken = null;
user.VerificationTokenExpiresAt = null;
await _v2Context.SaveChangesAsync();
return (true, "Email verificado correctamente.");
}
public async Task<(User? User, string? MigrationMessage)> AuthenticateAsync(string username, string password)
{
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.UserName == username);
if (user == null) return (null, null);
// Validar Bloqueo
if (user.IsBlocked) return (null, "USER_BLOCKED");
// Validar Verificación de Email (Solo para usuarios modernos o ya migrados)
if (!user.IsEmailVerified && user.MigrationStatus == 1) return (null, "EMAIL_NOT_VERIFIED");
bool isLegacy = user.MigrationStatus == 0;
bool isValid = _passwordService.VerifyPassword(password, user.PasswordHash, user.PasswordSalt, isLegacy);
if (!isValid) return (null, null);
if (isLegacy) return (user, "FORCE_PASSWORD_CHANGE");
return (user, null);
}
public async Task<bool> MigratePasswordAsync(string username, string newPassword)
{
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.UserName == username);
if (user == null) return false;
user.PasswordHash = _passwordService.HashPassword(newPassword);
user.PasswordSalt = null;
user.MigrationStatus = 1;
user.IsEmailVerified = true; // Asumimos verificado al migrar
await _v2Context.SaveChangesAsync();
return true;
}
public async Task<(bool Success, string Message)> ResendVerificationEmailAsync(string emailOrUsername)
{
// Buscar por Email O Username para mayor flexibilidad
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == emailOrUsername || u.UserName == emailOrUsername);
if (user == null) return (false, "No se encontró una cuenta con ese dato.");
if (user.IsEmailVerified) return (false, "Esta cuenta ya está verificada. Puede iniciar sesión.");
// --- RATE LIMITING ---
var cooldown = TimeSpan.FromMinutes(5);
if (user.LastVerificationEmailSentAt.HasValue)
{
var timeSinceLastSend = DateTime.UtcNow - user.LastVerificationEmailSentAt.Value;
if (timeSinceLastSend < cooldown)
{
var remaining = Math.Ceiling((cooldown - timeSinceLastSend).TotalMinutes);
return (false, $"Por seguridad, debe esperar {remaining} minutos antes de solicitar un nuevo correo.");
}
}
// Nuevo Token
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
user.VerificationToken = token;
user.VerificationTokenExpiresAt = DateTime.UtcNow.AddHours(24);
user.LastVerificationEmailSentAt = DateTime.UtcNow;
await _v2Context.SaveChangesAsync();
// Email
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
var verifyLink = $"{frontendUrl}/verificar-email?token={token}";
var emailBody = $@"
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
<h2 style='color: #0051ff; text-align: center;'>Verifica tu cuenta</h2>
<p>Hola <strong>{user.FirstName}</strong>,</p>
<p>Has solicitado un nuevo enlace de verificación. Haz clic abajo para activar tu cuenta:</p>
<div style='text-align: center; margin: 30px 0;'>
<a href='{verifyLink}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>VERIFICAR AHORA</a>
</div>
<p style='font-size: 12px; color: #666;'>Si no solicitaste este correo, ignóralo.</p>
</div>";
try
{
await _emailService.SendEmailAsync(user.Email, "Verificación de Cuenta - Reenvío", emailBody);
return (true, "Correo de verificación reenviado. Revise su bandeja de entrada.");
}
catch
{
return (false, "Error al enviar el correo. Intente más tarde.");
}
}
public async Task<(bool Success, string Message)> ForgotPasswordAsync(string emailOrUsername)
{
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == emailOrUsername || u.UserName == emailOrUsername);
if (user == null)
{
await Task.Delay(new Random().Next(100, 300));
return (true, "Si el correo existe en nuestro sistema, recibirás las instrucciones.");
}
// --- RATE LIMITING ---
var cooldown = TimeSpan.FromMinutes(5);
if (user.LastPasswordResetEmailSentAt.HasValue)
{
var timeSinceLastSend = DateTime.UtcNow - user.LastPasswordResetEmailSentAt.Value;
if (timeSinceLastSend < cooldown)
{
var remaining = Math.Ceiling((cooldown - timeSinceLastSend).TotalMinutes);
return (false, $"Por favor, espera {remaining} minutos antes de solicitar otro correo.");
}
}
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
user.PasswordResetToken = token;
user.PasswordResetTokenExpiresAt = DateTime.UtcNow.AddHours(1);
user.LastPasswordResetEmailSentAt = DateTime.UtcNow;
await _v2Context.SaveChangesAsync();
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
var resetLink = $"{frontendUrl}/restablecer-clave?token={token}";
var emailBody = $@"
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
<h2 style='color: #0051ff; text-align: center;'>Recuperación de Contraseña</h2>
<p>Hola <strong>{user.FirstName}</strong>,</p>
<p>Recibimos una solicitud para restablecer tu contraseña en Motores Argentinos.</p>
<div style='text-align: center; margin: 30px 0;'>
<a href='{resetLink}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>RESTABLECER CLAVE</a>
</div>
<p style='font-size: 12px; color: #666;'>Este enlace expirará en 1 hora.</p>
</div>";
try
{
await _emailService.SendEmailAsync(user.Email, "Restablecer Contraseña - Motores Argentinos", emailBody);
}
catch
{
return (false, "Hubo un error técnico enviando el correo. Intenta más tarde.");
}
return (true, "Si el correo existe en nuestro sistema, recibirás las instrucciones.");
}
public async Task<(bool Success, string Message)> ResetPasswordAsync(string token, string newPassword)
{
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.PasswordResetToken == token);
if (user == null) return (false, "El enlace es inválido.");
if (user.PasswordResetTokenExpiresAt < DateTime.UtcNow) return (false, "El enlace ha expirado. Solicita uno nuevo.");
user.PasswordHash = _passwordService.HashPassword(newPassword);
user.PasswordResetToken = null;
user.PasswordResetTokenExpiresAt = null;
user.PasswordSalt = null;
user.MigrationStatus = 1;
await _v2Context.SaveChangesAsync();
return (true, "Tu contraseña ha sido actualizada correctamente.");
}
public async Task<(bool Success, string Message)> ChangePasswordAsync(int userId, string current, string newPwd)
{
var user = await _v2Context.Users.FindAsync(userId);
if (user == null) return (false, "Usuario no encontrado");
if (!_passwordService.VerifyPassword(current, user.PasswordHash, user.PasswordSalt, user.MigrationStatus == 0))
return (false, "La contraseña actual es incorrecta.");
user.PasswordHash = _passwordService.HashPassword(newPwd);
user.PasswordSalt = null;
user.MigrationStatus = 1;
await _v2Context.SaveChangesAsync();
return (true, "Contraseña actualizada.");
}
// Implementación del método de creación de usuario fantasma para Admin
public async Task<User> CreateGhostUserAsync(string email, string firstName, string lastName, string phone)
{
var existing = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == email);
if (existing != null) return existing;
// Generar username base desde el email (parte izquierda)
string baseUsername = email.Split('@')[0].ToLowerInvariant();
baseUsername = Regex.Replace(baseUsername, "[^a-z0-9]", "");
// Asegurar unicidad simple
string finalUsername = baseUsername;
int count = 1;
while (await _v2Context.Users.AnyAsync(u => u.UserName == finalUsername))
{
finalUsername = $"{baseUsername}{count++}";
}
var user = new User
{
UserName = finalUsername,
Email = email,
FirstName = firstName,
LastName = lastName,
PhoneNumber = phone,
PasswordHash = _passwordService.HashPassword(Guid.NewGuid().ToString()),
MigrationStatus = 1,
UserType = 1,
IsEmailVerified = false,
CreatedAt = DateTime.UtcNow
};
_v2Context.Users.Add(user);
await _v2Context.SaveChangesAsync();
return user;
}
}

View File

@@ -0,0 +1,149 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using System.IO;
namespace MotoresArgentinosV2.Infrastructure.Services;
public interface IImageStorageService
{
Task<string> SaveAdImageAsync(int adId, IFormFile file);
void DeleteAdImage(string relativePath);
}
public class ImageStorageService : IImageStorageService
{
private readonly IWebHostEnvironment _env;
private readonly ILogger<ImageStorageService> _logger;
public ImageStorageService(IWebHostEnvironment env, ILogger<ImageStorageService> logger)
{
_env = env;
_logger = logger;
}
// Firmas de archivos (Magic Numbers) para JPG, PNG, WEBP
private static readonly Dictionary<string, List<byte[]>> _fileSignatures = new Dictionary<string, List<byte[]>>
{
{ ".jpeg", new List<byte[]> { new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE3 } } },
{ ".jpg", new List<byte[]> { new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE1 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE8 } } },
{ ".png", new List<byte[]> { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } },
{ ".webp", new List<byte[]> { new byte[] { 0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50 } } }
};
public async Task<string> SaveAdImageAsync(int adId, IFormFile file)
{
// 1. Validación de Extensión Básica
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext) || !_fileSignatures.ContainsKey(ext))
{
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)
{
throw new Exception("El archivo excede los 3MB permitidos.");
}
// 3. Validación de Magic Numbers (Leer cabecera real)
using (var stream = file.OpenReadStream())
using (var reader = new BinaryReader(stream))
{
// Leemos hasta 12 bytes que cubren nuestras firmas soportadas
var headerBytes = reader.ReadBytes(12);
bool isRealImage = false;
// A. JPG (Flexible: FF D8)
if (headerBytes.Length >= 2 && headerBytes[0] == 0xFF && headerBytes[1] == 0xD8)
{
isRealImage = true;
}
// B. PNG (Sello estricto)
else if (headerBytes.Length >= 8 && headerBytes.Take(8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }))
{
isRealImage = true;
}
// C. WEBP (RIFF [4 bytes] WEBP)
else if (headerBytes.Length >= 12 &&
headerBytes.Take(4).SequenceEqual(new byte[] { 0x52, 0x49, 0x46, 0x46 }) &&
headerBytes.Skip(8).Take(4).SequenceEqual(new byte[] { 0x57, 0x45, 0x42, 0x50 }))
{
isRealImage = true;
}
if (!isRealImage)
{
string hex = BitConverter.ToString(headerBytes.Take(8).ToArray());
_logger.LogWarning("Firma de archivo inválida para {Extension}: {HexBytes}", ext, hex);
throw new Exception($"El archivo parece corrupto o tiene una firma inválida ({hex}). El sistema acepta JPG, PNG y WEBP reales.");
}
}
try
{
// 1. Definir rutas
var uploadFolder = Path.Combine(_env.WebRootPath, "uploads", "ads", adId.ToString());
if (!Directory.Exists(uploadFolder)) Directory.CreateDirectory(uploadFolder);
var uniqueName = Guid.NewGuid().ToString();
var fileName = $"{uniqueName}.jpg";
var thumbName = $"{uniqueName}_thumb.jpg";
var filePath = Path.Combine(uploadFolder, fileName);
var thumbPath = Path.Combine(uploadFolder, thumbName);
// 2. Cargar y Procesar con ImageSharp
using (var image = await Image.LoadAsync(file.OpenReadStream()))
{
// A. Guardar imagen principal (Optimized: Max width 1280px)
if (image.Width > 1280)
{
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(1280, 0), // 0 mantiene aspect ratio
Mode = ResizeMode.Max
}));
}
await image.SaveAsJpegAsync(filePath);
// B. Generar Thumbnail (Max width 400px para grillas)
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(400, 300),
Mode = ResizeMode.Crop // Recorte inteligente para que queden parejitas
}));
await image.SaveAsJpegAsync(thumbPath);
}
// Retornar ruta relativa web de la imagen principal
return $"/uploads/ads/{adId}/{fileName}";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error procesando imagen para el aviso {AdId}", adId);
throw;
}
}
public void DeleteAdImage(string relativePath)
{
if (string.IsNullOrEmpty(relativePath)) return;
try
{
// Eliminar archivo principal
var fullPath = Path.Combine(_env.WebRootPath, relativePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(fullPath)) File.Delete(fullPath);
// Eliminar thumbnail asociado
var thumbPath = fullPath.Replace(".jpg", "_thumb.jpg");
if (File.Exists(thumbPath)) File.Delete(thumbPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error eliminando imagen {Path}", relativePath);
}
}
}

View File

@@ -0,0 +1,176 @@
using System.Data;
using Microsoft.Data.SqlClient;
using Dapper;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using MotoresArgentinosV2.Core.Entities;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class LegacyPaymentService : ILegacyPaymentService
{
private readonly string _internetConn;
private readonly MotoresV2DbContext _v2Context;
private readonly IConfiguration _config;
private readonly ILogger<LegacyPaymentService> _logger;
public LegacyPaymentService(IConfiguration config, MotoresV2DbContext v2Context, ILogger<LegacyPaymentService> logger)
{
_internetConn = config.GetConnectionString("Internet") ?? "";
_v2Context = v2Context;
_config = config;
_logger = logger;
}
public async Task<AdPriceResult> GetAdPriceAsync(string category, bool isFeatured)
{
// Consulta real al Legacy para obtener precio
using IDbConnection db = new SqlConnection(_internetConn);
var parametros = new { tarea = category, paquete = isFeatured ? 1 : 0 };
try
{
var result = await db.QueryFirstOrDefaultAsync<dynamic>(
"SPDATOSAVISOS",
parametros,
commandType: CommandType.StoredProcedure);
if (result != null)
{
// El SP devuelve IMPORTE_TOTSINIVA que incluye el recargo destacado pero SIN IVA
// El IVA está hardcodeado al 10.5% en la lógica legacy
decimal neto = (decimal)result.IMPORTE_TOTSINIVA;
decimal iva = neto * 0.105m;
return new AdPriceResult(neto, iva, neto + iva, "ARS");
}
else
{
throw new Exception("El SP legacy devolvió null para los parámetros dados.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error crítico consultando SPDATOSAVISOS legacy. No se puede determinar precio.");
throw; // Re-throw to prevent hardcoded fallback
}
}
public async Task<bool> ProcessPaymentResponseAsync(string operationCode, string status, string providerData)
{
_logger.LogInformation("Procesando pago Legacy. Op: {OpCode}, Status: {Status}", operationCode, status);
// 1. Buscamos la transacción en V2 para obtener los datos necesarios.
var tx = await _v2Context.Transactions
.Include(t => t.Ad)
.ThenInclude(a => a.User)
.FirstOrDefaultAsync(t => t.OperationCode == operationCode);
// Si no encontramos la transacción, no podemos continuar.
if (tx == null)
{
_logger.LogError("No se encontró la transacción V2 con OperationCode {OpCode} para sincronizar con Legacy.", operationCode);
return false;
}
// Si el pago no fue aprobado, no hay nada que insertar en legacy.
if (!status.Equals("APPROVED", StringComparison.OrdinalIgnoreCase))
{
return true; // La operación no falló, simplemente no aplica.
}
try
{
var ad = tx.Ad;
if (ad == null || ad.User == null)
{
_logger.LogError("La transacción {OpCode} no tiene un aviso o usuario asociado.", operationCode);
return false;
}
decimal importeNeto = Math.Round(tx.Amount / 1.105m, 2);
decimal importeIva = Math.Round(tx.Amount - importeNeto, 2);
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("@tipodoc", 96);
p.Add("@nro_doc", "0");
p.Add("@razon", $"{ad.User.FirstName} {ad.User.LastName}".Trim().ToUpper());
p.Add("@calle", "");
p.Add("@numero", "");
p.Add("@localidad", "LA PLATA");
p.Add("@codigopostal", "1900");
p.Add("@telefono", ad.ContactPhone ?? "");
p.Add("@email", ad.User.Email);
p.Add("@id_tipoiva", 1);
p.Add("@porcentaje_iva1", 10.5m);
p.Add("@porcentaje_iva2", 0);
p.Add("@porcentaje_percepcion", 0);
p.Add("@id_tipoaviso", 16);
p.Add("@nombreaviso", "INTERNET");
p.Add("@id_rubro", 193);
p.Add("@id_subrubro", 0);
p.Add("@id_combinado", 0);
p.Add("@porcentaje_combinado", 0);
p.Add("@fecha_inicio", DateTime.Now.Date, DbType.DateTime);
p.Add("@cant_dias", 30);
p.Add("@dias_corridos", true);
p.Add("@palabras", 20);
p.Add("@centimetros", 0);
p.Add("@columnas", 0);
p.Add("@id_tarjeta", 1);
p.Add("@nro_tarjeta", "0000000000000000");
p.Add("@cvc_tarjeta", 111);
p.Add("@vencimiento", DateTime.Now.AddDays(1), DbType.DateTime);
p.Add("@calle_envio", "");
p.Add("@numero_envio", "");
p.Add("@localidad_envio", "");
p.Add("@tarifa", importeNeto);
p.Add("@importe_aviso", importeNeto);
p.Add("@importe_iva1", importeIva);
p.Add("@importe_iva2", 0);
p.Add("@importe_percepcion", 0);
p.Add("@cantavi", 1);
p.Add("@paquete", 1);
p.Add("@destacado", ad.IsFeatured);
using (var dbInternet = new SqlConnection(_internetConn))
{
// El SP legacy convierte internamente @fecha_inicio a un string 'dd/mm/yyyy'.
// Esto falla en servidores con formato 'mdy'.
// Al anteponer 'SET DATEFORMAT dmy;', forzamos a la sesión a interpretar
// correctamente el formato 'dd/mm/yyyy' y se soluciona el error.
var sql = @"
SET DATEFORMAT dmy;
EXEC spInsertaAvisos
@tipo, @nro_operacion, @id_cliente, @tipodoc, @nro_doc, @razon,
@calle, @numero, @localidad, @codigopostal, @telefono, @email,
@id_tipoiva, @porcentaje_iva1, @porcentaje_iva2, @porcentaje_percepcion,
@id_tipoaviso, @nombreaviso, @id_rubro, @id_subrubro, @id_combinado,
@porcentaje_combinado, @fecha_inicio, @cant_dias, @dias_corridos,
@palabras, @centimetros, @columnas, @id_tarjeta, @nro_tarjeta,
@cvc_tarjeta, @vencimiento, @calle_envio, @numero_envio,
@localidad_envio, @tarifa, @importe_aviso, @importe_iva1,
@importe_iva2, @importe_percepcion, @cantavi, @paquete, @destacado;
";
await dbInternet.ExecuteAsync(sql, p);
}
_logger.LogInformation("Aviso insertado en Legacy AvisosWeb correctamente para Op: {OpCode}", operationCode);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error crítico insertando en Legacy AvisosWeb. Op: {OpCode}", operationCode);
// Devolvemos false para que el servicio que lo llamó sepa que la integración falló.
return false;
}
}
}

View File

@@ -0,0 +1,392 @@
// backend/MotoresArgentinosV2.Infrastructure/Services/MercadoPagoService.cs
using MercadoPago.Client;
using MercadoPago.Client.Payment;
using MercadoPago.Config;
using MercadoPago.Resource.Payment;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Infrastructure.Data;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class MercadoPagoService : IPaymentService
{
private readonly IConfiguration _config;
private readonly MotoresV2DbContext _context;
private readonly ILegacyPaymentService _legacyService;
private readonly IAvisosLegacyService _legacyAdsService;
private readonly INotificationService _notificationService;
private readonly IAdSyncService _syncService;
private readonly ILogger<MercadoPagoService> _logger;
public MercadoPagoService(
IConfiguration config,
MotoresV2DbContext context,
ILegacyPaymentService legacyService,
IAvisosLegacyService legacyAdsService,
INotificationService notificationService,
IAdSyncService syncService,
ILogger<MercadoPagoService> logger)
{
_config = config;
_context = context;
_legacyService = legacyService;
_legacyAdsService = legacyAdsService;
_notificationService = notificationService;
_syncService = syncService;
_logger = logger;
var token = _config["MercadoPago:AccessToken"] ?? throw new Exception("MP AccessToken no configurado");
MercadoPagoConfig.AccessToken = token;
}
public async Task<PaymentResponseDto> ProcessPaymentAsync(CreatePaymentRequestDto request, int userId)
{
// INTEGRIDAD DE PRECIOS Y PROPIEDAD
var ad = await _context.Ads
.Include(a => a.User)
.Include(a => a.Brand)
.FirstOrDefaultAsync(a => a.AdID == request.AdId);
if (ad == null) throw new Exception("El aviso no existe.");
if (ad.UserID != userId) throw new Exception("No tienes permiso para pagar este aviso.");
// VALIDACIÓN DE PRECIO CONTRA LEGACY (Integridad Corregida)
// El SP spDatosAvisos usa ("EMOTORES", 1) para destacados y ("EMOTORES", 0) para normales (Autos/Motos)
int paquete = ad.IsFeatured ? 1 : 0;
var tarifas = await _legacyAdsService.ObtenerDatosAvisosAsync("EMOTORES", paquete);
var tarifaOficial = tarifas.FirstOrDefault();
if (tarifaOficial == null)
{
_logger.LogWarning("No se encontró tarifa en Legacy para (EMOTORES, {Paquete}). Aplicando validación básica.", paquete);
if (request.TransactionAmount <= 0) throw new Exception("Monto de transacción inválido.");
}
else
{
// Calcular precio final con IVA y Redondear igual que en el Frontend
// La lógica de negocio es: (Neto * 1.105) redondeado al entero más cercano.
decimal precioCalculado = Math.Round(tarifaOficial.ImporteTotsiniva * 1.105m, 0);
// Comparamos el monto que viene del front con nuestro cálculo redondeado
if (request.TransactionAmount != precioCalculado)
{
_logger.LogCritical("¡ALERTA DE SEGURIDAD! Intento de manipulación de precio. AdID: {AdId}, Calculado: {Expected}, Recibido: {Actual}",
ad.AdID, precioCalculado, request.TransactionAmount);
// Mensaje genérico para el usuario, logueando el detalle real
throw new Exception($"Integridad de precio fallida. El monto solicitado no coincide con la tarifa oficial vigente.");
}
}
// 1. Generar ID de Operación Único
string operationCode = $"M2-{request.AdId}-{DateTime.Now.Ticks % 10000000}";
// 2. Crear Request
var paymentRequest = new PaymentCreateRequest
{
TransactionAmount = request.TransactionAmount,
Token = request.Token,
Description = request.Description ?? $"Publicación Aviso #{request.AdId}",
Installments = request.Installments,
PaymentMethodId = request.PaymentMethodId,
IssuerId = request.IssuerId,
Payer = new PaymentPayerRequest
{
Email = request.PayerEmail,
FirstName = "Usuario",
LastName = "Motores"
},
ExternalReference = operationCode,
StatementDescriptor = "MOTORESARG" // Aparece en el resumen de la tarjeta
};
// 🛡️ SEGURIDAD: IDEMPOTENCIA
var requestOptions = new RequestOptions();
requestOptions.CustomHeaders.Add("X-Idempotency-Key", operationCode);
var client = new PaymentClient();
Payment payment;
try
{
// 3. Procesar Pago con Idempotencia
payment = await client.CreateAsync(paymentRequest, requestOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error comunicándose con Mercado Pago");
throw new Exception("Error al procesar el pago con el proveedor. Por favor intente nuevamente.");
}
// 4. Guardar Transacción en V2
var transaction = new TransactionRecord
{
AdID = request.AdId,
OperationCode = operationCode,
Amount = request.TransactionAmount,
Status = MapStatus(payment.Status),
PaymentMethodID = 1,
ProviderPaymentId = payment.Id?.ToString(),
ProviderResponse = System.Text.Json.JsonSerializer.Serialize(payment),
SnapshotUserEmail = ad.User?.Email ?? request.PayerEmail,
SnapshotUserName = ad.User?.UserName ?? "Usuario",
SnapshotAdTitle = ad.Brand != null ? $"{ad.Brand.Name} {ad.VersionName}" : ad.VersionName,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.Transactions.Add(transaction);
// Actualizar estado del aviso si queda PENDIENTE (in_process)
// Esto evita que el usuario vea el botón "Continuar Pago" y pague doble.
if (payment.Status == PaymentStatus.InProcess || payment.Status == PaymentStatus.Pending)
{
ad.StatusID = 2; // PaymentPending -> Habilita botón "Verificar Ahora" en el front
}
await _context.SaveChangesAsync();
// 5. Impactar en Legacy y V2 si está aprobado
if (payment.Status == PaymentStatus.Approved)
{
await FinalizeApprovedPayment(ad, payment.Id?.ToString() ?? "0", operationCode, request.PaymentMethodId, request.PayerEmail, request.TransactionAmount);
}
else
{
// 📝 AUDITORÍA (Pago no aprobado inmediatamente)
_context.AuditLogs.Add(new AuditLog
{
Action = "PAYMENT_INITIATED",
Entity = "Transaction",
EntityID = transaction.TransactionID,
UserID = userId,
Details = $"Pago iniciado para AdID {request.AdId}. Estado: {payment.Status}"
});
await _context.SaveChangesAsync();
}
return new PaymentResponseDto
{
PaymentId = payment.Id ?? 0,
Status = payment.Status,
StatusDetail = payment.StatusDetail,
OperationCode = operationCode
};
}
public async Task ProcessWebhookAsync(string topic, string id)
{
if (topic != "payment") return;
var client = new PaymentClient();
var payment = await client.GetAsync(long.Parse(id));
if (payment == null) return;
// Buscar la transacción por el ID de pago del proveedor (ProviderPaymentId)
var transaction = await _context.Transactions
.FirstOrDefaultAsync(t => t.ProviderPaymentId == id);
if (transaction == null)
{
_logger.LogWarning("Webhook recibido para un ID de pago no encontrado en la DB: {PaymentId}", id);
return; // No encontramos la transacción, no hay nada que hacer.
}
// Si ya está aprobada, no hacemos nada para evitar procesar dos veces.
if (transaction.Status == "APPROVED") return;
// Actualizar estado en V2 con la información REAL de Mercado Pago
transaction.Status = MapStatus(payment.Status);
transaction.UpdatedAt = DateTime.UtcNow;
// 📝 AUDITORÍA (Webhook)
_context.AuditLogs.Add(new AuditLog
{
Action = "PAYMENT_WEBHOOK_RECEIVED",
Entity = "Transaction",
EntityID = transaction.TransactionID,
UserID = 0, // Sistema
Details = $"Webhook recibido para MP_ID {id}. Nuevo estado: {transaction.Status}"
});
await _context.SaveChangesAsync();
// Si el estado REAL que obtuvimos de MP es "approved", finalizamos el pago.
if (payment.Status == PaymentStatus.Approved)
{
var ad = await _context.Ads.Include(a => a.User).Include(a => a.Brand).FirstOrDefaultAsync(a => a.AdID == transaction.AdID);
if (ad != null)
{
await FinalizeApprovedPayment(ad, id, transaction.OperationCode, payment.PaymentMethodId, payment.Payer.Email, payment.TransactionAmount ?? 0);
}
}
}
private async Task FinalizeApprovedPayment(Ad ad, string mpPaymentId, string operationCode, string paymentMethod, string payerEmail, decimal paidAmount)
{
// A. Actualizar estado del Aviso y Auditoría en V2
ad.StatusID = (int)AdStatusEnum.ModerationPending;
ad.ExpirationWarningSent = false;
ad.PaymentReminderSentAt = null;
ad.LastPerformanceEmailSentAt = DateTime.UtcNow;
_context.AuditLogs.Add(new AuditLog
{
Action = "PAYMENT_APPROVED",
Entity = "Ad",
EntityID = ad.AdID,
UserID = ad.UserID,
Details = $"Pago aprobado para AdID {ad.AdID}. Operación: {operationCode}. MP_ID: {mpPaymentId}"
});
// Guardamos los cambios iniciales en la base de datos V2
await _context.SaveChangesAsync();
// B. Impactar Legacy
var providerResponseForLegacy = System.Text.Json.JsonSerializer.Serialize(new
{
tarjeta = paymentMethod,
mediopago = "1",
titular = "MERCADO PAGO USER",
emailcomprador = payerEmail,
noperacion = operationCode,
mp_id = mpPaymentId
});
// Llamamos al servicio legacy y verificamos si tuvo éxito
var legacySuccess = await _legacyService.ProcessPaymentResponseAsync(operationCode, "APPROVED", providerResponseForLegacy);
// C. Si la sincronización legacy fue exitosa, actualizamos el LegacyAdID
if (legacySuccess)
{
// Volvemos a buscar la transacción para asegurarnos de tener el ID correcto
var transaction = await _context.Transactions.FirstOrDefaultAsync(t => t.OperationCode == operationCode);
if (transaction != null)
{
ad.LegacyAdID = transaction.TransactionID;
await _context.SaveChangesAsync(); // Guardamos el ID legacy en el aviso
}
}
// Si legacySuccess es false, el error ya fue logueado por el LegacyPaymentService.
// D. Notificar al Usuario (RECIBO DE PAGO) - Se ejecuta independientemente del éxito de legacy
try
{
var title = $"{ad.Brand?.Name} {ad.VersionName}";
var userName = ad.User?.FirstName ?? "Usuario";
await _notificationService.SendPaymentReceiptEmailAsync(
ad.User?.Email ?? payerEmail,
userName,
title,
paidAmount,
operationCode
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Falló el envío de email de recibo para Op: {OpCode}", operationCode);
/* No bloqueamos el flujo si falla el mail */
}
}
private string MapStatus(string mpStatus)
{
return mpStatus switch
{
PaymentStatus.Approved => "APPROVED",
PaymentStatus.Rejected => "REJECTED",
PaymentStatus.InProcess => "PENDING",
_ => "PENDING"
};
}
public async Task<PaymentResponseDto> CheckPaymentStatusAsync(int adId)
{
// 1. Buscar la última transacción PENDING para este aviso
var transaction = await _context.Transactions
.Where(t => t.AdID == adId && t.Status == "PENDING")
.OrderByDescending(t => t.CreatedAt)
.FirstOrDefaultAsync();
if (transaction == null)
{
// Si no hay pendientes, buscamos la última aprobada para devolver estado OK
var approved = await _context.Transactions
.Where(t => t.AdID == adId && t.Status == "APPROVED")
.FirstOrDefaultAsync();
if (approved != null) return new PaymentResponseDto { Status = "approved", PaymentId = 0 };
throw new Exception("No se encontraron transacciones pendientes para verificar.");
}
long mpId = 0;
if (long.TryParse(transaction.ProviderResponse, out long simpleId))
{
mpId = simpleId;
}
else
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(transaction.ProviderResponse ?? "{}");
if (doc.RootElement.TryGetProperty("mp_id", out var el)) mpId = long.Parse(el.GetString()!);
else if (doc.RootElement.TryGetProperty("id", out var el2)) mpId = el2.GetInt64();
}
catch { }
}
if (mpId == 0) throw new Exception("No se pudo recuperar el ID de Mercado Pago.");
var client = new PaymentClient();
var payment = await client.GetAsync(mpId);
// 3. Actualizar DB Local si cambió el estado
var newStatus = MapStatus(payment.Status);
if (newStatus != transaction.Status)
{
transaction.Status = newStatus;
transaction.UpdatedAt = DateTime.UtcNow;
// Si se aprobó ahora, ejecutamos la lógica de finalización
if (newStatus == "APPROVED")
{
var ad = await _context.Ads.Include(a => a.User).FirstOrDefaultAsync(a => a.AdID == adId);
if (ad != null)
{
await FinalizeApprovedPayment(ad, payment.Id?.ToString() ?? "", transaction.OperationCode, payment.PaymentMethodId, payment.Payer.Email, transaction.Amount);
}
}
// Si se rechazó o canceló
if (newStatus == "REJECTED")
{
// Liberar el aviso para intentar pagar de nuevo
var ad = await _context.Ads.FindAsync(adId);
if (ad != null) ad.StatusID = 1; // Volver a Borrador
}
await _context.SaveChangesAsync();
}
return new PaymentResponseDto
{
PaymentId = payment.Id ?? 0,
Status = payment.Status,
StatusDetail = payment.StatusDetail,
OperationCode = transaction.OperationCode
};
}
}

View File

@@ -0,0 +1,210 @@
using Microsoft.Extensions.Logging;
using MotoresArgentinosV2.Core.Interfaces;
using Microsoft.Extensions.Configuration;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class NotificationService : INotificationService
{
private readonly IEmailService _emailService;
private readonly ILogger<NotificationService> _logger;
private readonly string _frontendUrl;
public NotificationService(IEmailService emailService, ILogger<NotificationService> logger, IConfiguration config)
{
_emailService = emailService;
_logger = logger;
// Leemos la URL del appsettings o usamos localhost como fallback
_frontendUrl = config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
}
private string GetEmailShell(string title, string content)
{
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);'>
<div style='background: linear-gradient(to right, #2563eb, #22d3ee); padding: 30px; text-align: center;'>
<h1 style='color: white; margin: 0; font-size: 24px; text-transform: uppercase; letter-spacing: 2px; font-weight: 900;'>Motores <span style='color: #bfdbfe;'>Argentinos</span></h1>
</div>
<div style='padding: 40px;'>
<h2 style='color: white; font-size: 20px; font-weight: 800; margin-top: 0; text-transform: uppercase;'>{title}</h2>
<div style='color: #9ca3af; font-size: 14px;'>
{content}
</div>
</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>
</div>
</div>
</div>";
}
public async Task SendChatNotificationEmailAsync(string toEmail, string fromUser, string message, int adId)
{
string subject = "Tienes un nuevo mensaje - Motores Argentinos";
string content = $@"
<p>Hola,</p>
<p><strong>{fromUser}</strong> te ha enviado un mensaje sobre el aviso #{adId}:</p>
<blockquote style='background: #1a1d24; border-left: 4px solid #2563eb; padding: 15px; margin: 20px 0; font-style: italic; color: #d1d5db;'>
""{message}""
</blockquote>
<p style='margin-top: 20px;'>Ingresa a tu cuenta para responder.</p>
<div style='text-align: center; margin: 30px 0;'>
<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));
}
public async Task SendAdStatusChangedEmailAsync(string toEmail, string adTitle, string status, string? reason = null)
{
string subject = "Estado de tu aviso - Motores Argentinos";
string color = status.ToUpper() == "APROBADO" ? "#10b981" : "#ef4444";
string content = $@"
<p>Hola,</p>
<p>Te informamos que el estado de tu aviso <strong>""{adTitle}""</strong> ha cambiado a:</p>
<div style='background: {color}20; border: 1px solid {color}; color: {color}; padding: 15px; border-radius: 8px; text-align: center; font-weight: 900; text-transform: uppercase; margin: 20px 0; font-size: 16px;'>
{status}
</div>
{(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));
}
public async Task SendSecurityAlertEmailAsync(string toEmail, string actionDescription)
{
string subject = "Alerta de Seguridad - Motores Argentinos";
string content = $@"
<p style='color: #f87171; font-weight: bold; font-size: 16px;'>¡Alerta de Seguridad!</p>
<p>Te informamos que se ha realizado la siguiente acción crítica en tu cuenta:</p>
<div style='background: #1a1d24; border: 1px solid #374151; padding: 15px; border-radius: 8px; margin: 20px 0;'>
<span style='color: white; font-weight: bold;'>Acción:</span> {actionDescription}
</div>
<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>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Alerta de Seguridad", content));
}
public async Task SendExpirationWarningEmailAsync(string toEmail, string userName, string adTitle, DateTime expirationDate)
{
string subject = "Tu aviso está por vencer - Motores Argentinos";
string content = $@"
<p>Hola <strong>{userName}</strong>,</p>
<p>Te recordamos que tu publicación <strong>""{adTitle}""</strong> finalizará el día <strong>{expirationDate:dd/MM/yyyy}</strong>.</p>
<p>Para asegurar que tu vehículo siga visible y no pierdas potenciales compradores, te recomendamos renovarlo ahora.</p>
<div style='text-align: center; margin: 30px 0;'>
<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));
}
public async Task SendAdExpiredEmailAsync(string toEmail, string userName, string adTitle)
{
string subject = "Tu aviso ha finalizado - Motores Argentinos";
string content = $@"
<p>Hola <strong>{userName}</strong>,</p>
<p>Tu aviso <strong>""{adTitle}""</strong> ha llegado al fin de su vigencia y ya no es visible en los listados.</p>
<p>Si aún no vendiste tu vehículo, puedes republicarlo fácilmente desde tu panel de gestión.</p>
<div style='text-align: center; margin: 30px 0;'>
<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));
}
public async Task SendWeeklyPerformanceEmailAsync(string toEmail, string userName, string adTitle, int views, int favorites)
{
string subject = "Resumen semanal de tu aviso - Motores Argentinos";
string content = $@"
<p>Hola <strong>{userName}</strong>,</p>
<p>Aquí tienes el rendimiento de tu aviso <strong>""{adTitle}""</strong> en los últimos 7 días:</p>
<!-- Contenedor Principal Centrado -->
<div style='text-align: center; margin: 30px 0; font-size: 0;'>
<!--[if mso]>
<table role='presentation' width='100%'>
<tr>
<td style='width:50%; padding: 5px;' valign='top'>
<![endif]-->
<!-- Caja Visitas -->
<div style='display: inline-block; width: 46%; min-width: 140px; vertical-align: top; margin: 1%; background: #1f2937; border-radius: 12px; padding: 25px 10px; box-sizing: border-box; border: 1px solid #374151;'>
<span style='font-size: 28px; display: block; margin-bottom: 5px;'>👁️</span>
<strong style='font-size: 24px; color: #ffffff; display: block; font-family: sans-serif;'>{views}</strong>
<span style='font-size: 11px; color: #9ca3af; text-transform: uppercase; font-weight: bold; letter-spacing: 1px; font-family: sans-serif;'>Visitas</span>
</div>
<!--[if mso]>
</td>
<td style='width:50%; padding: 5px;' valign='top'>
<![endif]-->
<!-- Caja Favoritos -->
<div style='display: inline-block; width: 46%; min-width: 140px; vertical-align: top; margin: 1%; background: #1f2937; border-radius: 12px; padding: 25px 10px; box-sizing: border-box; border: 1px solid #374151;'>
<span style='font-size: 28px; display: block; margin-bottom: 5px;'>⭐</span>
<strong style='font-size: 24px; color: #ffffff; display: block; font-family: sans-serif;'>{favorites}</strong>
<span style='font-size: 11px; color: #9ca3af; text-transform: uppercase; font-weight: bold; letter-spacing: 1px; font-family: sans-serif;'>Favoritos</span>
</div>
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Rendimiento Semanal", content));
}
public async Task SendPaymentReminderEmailAsync(string toEmail, string userName, string adTitle, string link)
{
string subject = "Finaliza la publicación de tu aviso - Motores Argentinos";
string content = $@"
<p>Hola <strong>{userName}</strong>,</p>
<p>Tu aviso del <strong>""{adTitle}""</strong> está casi listo, pero aún no es visible para los compradores.</p>
<p>Solo falta confirmar el pago para activarlo. ¡No pierdas oportunidades de venta!</p>
<div style='text-align: center; margin: 30px 0;'>
<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));
}
public async Task SendPaymentReceiptEmailAsync(string toEmail, string userName, string adTitle, decimal amount, string operationCode)
{
string subject = "Comprobante de Pago - Motores Argentinos";
string content = $@"
<p>Hola <strong>{userName}</strong>,</p>
<p>Hemos recibido tu pago correctamente. Aquí tienes el detalle de la operación:</p>
<div style='background: #1f2937; padding: 20px; border-radius: 8px; margin: 20px 0; border: 1px solid #374151;'>
<p style='margin: 5px 0;'><strong>Concepto:</strong> Publicación Aviso Clasificado</p>
<p style='margin: 5px 0;'><strong>Vehículo:</strong> {adTitle}</p>
<p style='margin: 5px 0;'><strong>Operación:</strong> {operationCode}</p>
<p style='margin: 5px 0;'><strong>Fecha:</strong> {DateTime.Now:dd/MM/yyyy HH:mm}</p>
<hr style='border: 0; border-top: 1px solid #374151; margin: 15px 0;'>
<p style='margin: 5px 0; font-size: 18px; text-align: right; color: #10b981;'><strong>Total: ${amount:N2}</strong></p>
</div>
<p>Tu aviso ha pasado a la etapa de moderación y será activado a la brevedad.</p>";
await _emailService.SendEmailAsync(toEmail, subject, GetEmailShell("Recibo de Pago", content));
}
public async Task SendUnreadMessagesReminderEmailAsync(string toEmail, string userName, int unreadCount)
{
string subject = "Tienes mensajes sin leer - Motores Argentinos";
string content = $@"
<p>Hola <strong>{userName}</strong>,</p>
<p>Tienes <strong>{unreadCount} mensaje{(unreadCount > 1 ? "s" : "")} sin leer</strong> en tu bandeja de entrada.</p>
<p>Es importante responder a los interesados o moderadores para mantener la actividad de tu cuenta.</p>
<div style='text-align: center; margin: 30px 0;'>
<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));
}
}

View File

@@ -0,0 +1,111 @@
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 AutosDbContext para acceder a tablas y ejecutar SPs de la DB 'autos'
/// </summary>
public class OperacionesLegacyService : IOperacionesLegacyService
{
private readonly AutosDbContext _context;
private readonly ILogger<OperacionesLegacyService> _logger;
public OperacionesLegacyService(AutosDbContext 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();
}
}

View File

@@ -0,0 +1,45 @@
using System.Security.Cryptography;
using System.Text;
using BC = BCrypt.Net.BCrypt;
using MotoresArgentinosV2.Core.Interfaces;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class PasswordService : IPasswordService
{
public string HashPassword(string password)
{
return BC.HashPassword(password);
}
public bool VerifyPassword(string password, string hash, string? salt, bool isLegacy)
{
if (isLegacy)
{
return VerifyLegacyHash(password, hash, salt);
}
return BC.Verify(password, hash);
}
private bool VerifyLegacyHash(string password, string storedHash, string? salt)
{
// Lógica típica de ASP.NET Membership Provider (SHA1)
// El formato común es Base64(SHA1(Salt + Password))
if (string.IsNullOrEmpty(salt)) return false;
byte[] passwordBytes = Encoding.Unicode.GetBytes(password);
byte[] saltBytes = Convert.FromBase64String(salt);
byte[] allBytes = new byte[saltBytes.Length + passwordBytes.Length];
Buffer.BlockCopy(saltBytes, 0, allBytes, 0, saltBytes.Length);
Buffer.BlockCopy(passwordBytes, 0, allBytes, saltBytes.Length, passwordBytes.Length);
using (var sha1 = SHA1.Create())
{
byte[] hashBytes = sha1.ComputeHash(allBytes);
string computedHash = Convert.ToBase64String(hashBytes);
return computedHash == storedHash;
}
}
}

View File

@@ -0,0 +1,70 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Core.Models;
using System.Net.Security;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class SmtpEmailService : IEmailService
{
private readonly MailSettings _mailSettings;
private readonly ILogger<SmtpEmailService> _logger;
public SmtpEmailService(IOptions<MailSettings> mailSettings, ILogger<SmtpEmailService> logger)
{
_mailSettings = mailSettings.Value;
_logger = logger;
}
public async Task SendEmailAsync(string to, string subject, string htmlBody)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
email.From.Add(email.Sender);
email.To.Add(new MailboxAddress(to, to)); // Usamos el email como nombre si no lo tenemos
email.Subject = subject;
var builder = new BodyBuilder { HtmlBody = htmlBody };
email.Body = builder.ToMessageBody();
using var smtp = new SmtpClient();
try
{
// Bypass de SSL para red interna / certificados autofirmados
smtp.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true;
// Conectar
// SecureSocketOptions.StartTls es lo estándar para puerto 587
// Si el servidor es muy viejo y no soporta TLS, usa SecureSocketOptions.None
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
// Autenticar
if (!string.IsNullOrEmpty(_mailSettings.SmtpUser))
{
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
}
// Enviar
await smtp.SendAsync(email);
_logger.LogInformation("Email enviado exitosamente a {To}", to);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error crítico enviando email a {To} mediante MailKit", to);
// Re-lanzar para que el IdentityService sepa que falló
throw;
}
finally
{
if (smtp.IsConnected)
{
await smtp.DisconnectAsync(true);
}
}
}
}

View File

@@ -0,0 +1,89 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
using OtpNet;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class TokenService : ITokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config)
{
_config = config;
}
public string GenerateJwtToken(User user)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_config["Jwt:Key"] ?? "SUPER_SECRET_KEY_FOR_MOTORES_ARGENTINOS_V2_2026");
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, user.UserID.ToString()),
new Claim(ClaimTypes.Name, user.UserName),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, user.UserType == 3 ? "Admin" : "User")
}),
Expires = DateTime.UtcNow.AddHours(8),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
Issuer = _config["Jwt:Issuer"],
Audience = _config["Jwt:Audience"]
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
public RefreshToken GenerateRefreshToken(string ipAddress)
{
var refreshToken = new RefreshToken
{
Token = Convert.ToHexString(RandomNumberGenerator.GetBytes(64)),
Expires = DateTime.UtcNow.AddDays(7), // Dura 7 días
Created = DateTime.UtcNow,
CreatedByIp = ipAddress
};
return refreshToken;
}
public string GenerateMFACode()
{
var random = new Random();
return random.Next(100000, 999999).ToString();
}
public string GenerateBase32Secret()
{
var key = KeyGeneration.GenerateRandomKey(20);
return Base32Encoding.ToString(key);
}
public string GetQrCodeUri(string userEmail, string secret)
{
return $"otpauth://totp/MotoresV2:{userEmail}?secret={secret}&issuer=MotoresArgentinosV2";
}
public bool ValidateTOTP(string secret, string code)
{
try
{
var bytes = Base32Encoding.ToBytes(secret);
var totp = new Totp(bytes);
return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1));
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Core.DTOs;
using MotoresArgentinosV2.Core.Interfaces;
using MotoresArgentinosV2.Infrastructure.Data;
using Microsoft.Data.SqlClient;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class UsuariosLegacyService : IUsuariosLegacyService
{
private readonly InternetDbContext _context;
private readonly ILogger<UsuariosLegacyService> _logger;
public UsuariosLegacyService(InternetDbContext context, ILogger<UsuariosLegacyService> logger)
{
_context = context;
_logger = logger;
}
public async Task<UsuarioLegacyDto?> ObtenerParticularPorUsuarioAsync(string nombreUsuario)
{
try
{
var paramUsuario = new SqlParameter("@usuario_nom", nombreUsuario);
// Usamos SqlQueryRaw para mapear a DTO directamente (EF Core feature moderna)
// Nota: Si las columnas no coinciden exactamente, EF no llenará las propiedades.
// Para robustez en legacy, a veces conviene un mapeo manual si los nombres de columna son muy crípticos.
var resultado = await _context.Database
.SqlQueryRaw<UsuarioLegacyDto>("EXEC dbo.sp_VerDatosUsuario @usuario_nom", paramUsuario)
.ToListAsync();
return resultado.FirstOrDefault();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener datos de particular legacy para usuario: {Usuario}", nombreUsuario);
throw;
}
}
public async Task<AgenciaLegacyDto?> ObtenerAgenciaPorUsuarioAsync(string nombreUsuario)
{
try
{
var paramUsuario = new SqlParameter("@usuario_nom", nombreUsuario);
var resultado = await _context.Database
.SqlQueryRaw<AgenciaLegacyDto>("EXEC dbo.sp_VerDatosAgencia @usuario_nom", paramUsuario)
.ToListAsync();
return resultado.FirstOrDefault();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener datos de agencia legacy para usuario: {Usuario}", nombreUsuario);
throw;
}
}
}

View File

@@ -0,0 +1,386 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Microsoft.Data.SqlClient;
using MotoresArgentinosV2.MigrationTool.Models;
namespace MotoresArgentinosV2.MigrationTool;
public class AdMigrator
{
private readonly string _connStringAutos;
private readonly string _connStringV2;
public AdMigrator(string connAutos, string connV2)
{
_connStringAutos = connAutos;
_connStringV2 = connV2;
}
public async Task ExecuteAsync()
{
Console.WriteLine("🚗 INICIANDO MIGRACIÓN DE AUTOS...");
using var dbAutos = new SqlConnection(_connStringAutos);
using var dbV2 = new SqlConnection(_connStringV2);
// 1. CARGAR CACHÉS DE V2
Console.WriteLine(" 📥 Cargando diccionarios de Marcas, Modelos y Usuarios...");
var brandsV2 = (await dbV2.QueryAsync("SELECT BrandID, LegacyID, Name FROM Brands WHERE VehicleTypeID = 1")).ToList();
var usersV2 = (await dbV2.QueryAsync("SELECT UserID, Email, PhoneNumber FROM Users")).ToDictionary(u => (string)u.Email, u => u);
// 2. OBTENER AVISOS LEGACY (QUERY MEJORADA PARA ADMINS)
Console.WriteLine(" 📥 Leyendo avisos activos del Legacy...");
// Esta query intenta obtener el email de dos fuentes:
// 1. De la tabla Particulares (prioridad)
// 2. De la tabla de autenticación (aspnet_Membership) si no está en Particulares
var queryAds = @"
SELECT
A.Auto_Id,
A.Auto_Fecha_Publicacion,
A.Auto_Cant_Visitas,
COALESCE(P.Part_Mail, Mem.Email) as EmailVendedor,
M.Marca_Detalle,
Mo.Modelo_Detalle,
A.Auto_Version,
An.Año_Detalle,
A.Auto_precio,
Mon.Mone_Detalle,
A.Auto_Kilometros,
A.Auto_Detalle as Descripcion,
C.Comb_Detalle,
Col.Color_detalle,
Car.Carroceria_Nombre,
CASE WHEN D.DetAut_PapelesALDia = 1 THEN 1 ELSE 0 END as PapelesAlDia,
COALESCE(P.Part_Telefono, '') as Part_Telefono,
COALESCE(P.Part_Celular, '') as Part_Celular
FROM Autos A
-- Join original con particulares
LEFT JOIN Particulares P ON A.Auto_Usuario_Id = P.Part_Usu_Nombre
-- Join adicional para rescatar usuarios admin/internos sin perfil particular
LEFT JOIN aspnet_Users U ON A.Auto_Usuario_Id = U.UserName
LEFT JOIN aspnet_Membership Mem ON U.UserId = Mem.UserId
-- Joins de datos técnicos
LEFT JOIN Marca M ON A.Auto_Marca_Id = M.Marca_Id
LEFT JOIN Modelo Mo ON A.Auto_Modelo_Id = Mo.Modelo_Id
LEFT JOIN Año An ON A.Auto_Año = An.Año_Id
LEFT JOIN Combustible C ON A.Auto_Comb_Id = C.Comb_Id
LEFT JOIN Color Col ON A.Auto_Color = Col.Color_Id
LEFT JOIN Carroceria Car ON A.Auto_Carroceria_Id = Car.Carroceria_Id
LEFT JOIN Moneda Mon ON A.Auto_Moneda_Id = Mon.Mone_id
LEFT JOIN Detalle_Auto D ON A.Auto_Id = D.DetAut_Auto_Id
WHERE A.Auto_Estado = 0
AND A.Auto_Tipo_Id = 1
AND A.Auto_Fecha_Finalizacion >= GETDATE()";
var adsLegacy = (await dbAutos.QueryAsync<LegacyAdData>(queryAds)).ToList();
Console.WriteLine($" ✅ Encontrados {adsLegacy.Count} avisos candidatos.");
// 3. OBTENER FOTOS
var idsString = string.Join(",", adsLegacy.Select(a => a.Auto_Id));
var photosLegacy = new List<LegacyAdPhoto>();
if (adsLegacy.Any())
{
photosLegacy = (await dbAutos.QueryAsync<LegacyAdPhoto>(
$"SELECT Foto_Id, Foto_Auto_Id, Foto_Ruta FROM Fotos_Autos WHERE Foto_Auto_Id IN ({idsString})")).ToList();
}
int migrados = 0;
int omitidosUsuario = 0;
foreach (var ad in adsLegacy)
{
// A. VALIDAR USUARIO
string emailKey = ad.EmailVendedor?.ToLower().Trim() ?? "";
if (!usersV2.TryGetValue(emailKey, out var userV2))
{
omitidosUsuario++;
// Console.WriteLine($" ⚠️ Aviso {ad.Auto_Id} omitido: Usuario {emailKey} no existe en V2.");
continue;
}
// B. RESOLVER MARCA Y MODELO
var brandV2 = brandsV2.FirstOrDefault(b => b.Name.ToLower() == (ad.Marca_Detalle ?? "").ToLower());
if (brandV2 == null)
{
// Si la marca no existe, saltamos por seguridad o podríamos asignar "Otros"
continue;
}
int modelId = await GetOrCreateModelAsync(dbV2, (int)brandV2.BrandID, ad.Modelo_Detalle);
// C. CONSTRUIR DATOS
int.TryParse(ad.Año_Detalle, out int year);
string versionName = $"{ad.Modelo_Detalle} {ad.Auto_Version}".Trim();
if (versionName.Length > 100) versionName = versionName.Substring(0, 100);
string desc = ad.Descripcion ?? "";
if (ad.PapelesAlDia == 1) desc += "\n\n✔ Papeles al día.";
// Contacto: Si venía de Particulares, usamos eso. Si no (admins), usamos el del usuario V2
string contactPhone = !string.IsNullOrWhiteSpace(ad.Part_Celular) ? ad.Part_Celular : ad.Part_Telefono;
if (string.IsNullOrWhiteSpace(contactPhone)) contactPhone = userV2.PhoneNumber;
string currency = (ad.Mone_Detalle != null && ad.Mone_Detalle.Contains("US")) ? "USD" : "ARS";
// D. INSERTAR AVISO
var insertAdSql = @"
INSERT INTO Ads (
UserID, VehicleTypeID, BrandID, ModelID, VersionName, Year, KM,
Price, Currency, Description, IsFeatured, StatusID, CreatedAt, PublishedAt, ExpiresAt,
FuelType, Color, Segment, Location, Condition, Transmission, Steering,
ContactPhone, ContactEmail, DisplayContactInfo, LegacyAdID,
ViewsCounter
) VALUES (
@UserID, @VehicleTypeID, @BrandID, @ModelID, @VersionName, @Year, @KM,
@Price, @Currency, @Description, 0, 4, @CreatedAt, @PublishedAt, DATEADD(day, 30, @PublishedAt),
@FuelType, @Color, @Segment, 'La Plata', 'No Especificado', 'No Especificado', 'No Especificado',
@ContactPhone, @ContactEmail, 1, @LegacyAdID,
@ViewsCounter
);
SELECT CAST(SCOPE_IDENTITY() as int);";
int newAdId = await dbV2.ExecuteScalarAsync<int>(insertAdSql, new
{
UserID = userV2.UserID,
VehicleTypeID = 1,
BrandID = brandV2.BrandID,
ModelID = modelId,
VersionName = versionName,
Year = year,
KM = ad.Auto_Kilometros,
Price = ad.Auto_precio,
Currency = currency,
Description = desc,
FuelType = ad.Comb_Detalle,
Color = ad.Color_detalle,
Segment = ad.Carroceria_Nombre,
ContactPhone = contactPhone,
ContactEmail = userV2.Email,
LegacyAdID = ad.Auto_Id,
ViewsCounter = ad.Auto_Cant_Visitas,
CreatedAt = ad.Auto_Fecha_Publicacion,
PublishedAt = ad.Auto_Fecha_Publicacion
});
// E. INSERTAR FOTOS
var misFotos = photosLegacy
.Where(p => p.Foto_Auto_Id == ad.Auto_Id && !p.Foto_Ruta.Contains("nofoto"))
.ToList();
int order = 0;
foreach (var foto in misFotos)
{
string fileName = Path.GetFileName(foto.Foto_Ruta);
// Aquí podrías agregar la lógica para copiar el archivo físico si tienes acceso
// Por ahora solo guardamos la referencia en DB
string v2Path = $"/uploads/legacy/{fileName}";
await dbV2.ExecuteAsync(@"
INSERT INTO AdPhotos (AdID, FilePath, IsCover, SortOrder)
VALUES (@AdID, @FilePath, @IsCover, @SortOrder)",
new { AdID = newAdId, FilePath = v2Path, IsCover = (order == 0), SortOrder = order });
order++;
}
migrados++;
}
Console.WriteLine("==================================================");
Console.WriteLine($"🏁 MIGRACIÓN DE AUTOS FINALIZADA");
Console.WriteLine($"✅ Insertados: {migrados}");
Console.WriteLine($"⏩ Omitidos (Sin usuario/marca): {omitidosUsuario}");
Console.WriteLine("==================================================");
}
public async Task MigrateMotosAsync()
{
Console.WriteLine("\n🏍 INICIANDO MIGRACIÓN DE MOTOS...");
using var dbAutos = new SqlConnection(_connStringAutos);
using var dbV2 = new SqlConnection(_connStringV2);
// 1. CARGAR CACHÉS (Solo Marcas de Motos)
Console.WriteLine(" 📥 Cargando diccionarios...");
var brandsV2 = (await dbV2.QueryAsync("SELECT BrandID, LegacyID, Name FROM Brands WHERE VehicleTypeID = 2")).ToList(); // 2 = Motos
var usersV2 = (await dbV2.QueryAsync("SELECT UserID, Email, PhoneNumber FROM Users")).ToDictionary(u => (string)u.Email, u => u);
// 2. QUERY MOTOS
var query = @"
SELECT
M.Moto_Id,
M.Moto_Fecha_Publicacion,
M.Moto_Cant_Visitas,
COALESCE(P.Part_Mail, Mem.Email) as EmailVendedor,
Ma.Marca_Moto_Detalle as Marca_Detalle,
Mo.Modelo_Moto_Detalle as Modelo_Detalle,
An.Año_Detalle,
M.Moto_Precio,
Mon.Mone_Detalle,
M.Moto_Kilometraje,
M.Moto_Detalle as Descripcion,
M.Moto_Cilindrada,
M.Moto_Tipo_Cuatri_Id,
COALESCE(P.Part_Telefono, '') as Part_Telefono,
COALESCE(P.Part_Celular, '') as Part_Celular
FROM Motos M
LEFT JOIN Particulares P ON M.Moto_Usuario_Id = P.Part_Usu_Nombre
LEFT JOIN aspnet_Users U ON M.Moto_Usuario_Id = U.UserName
LEFT JOIN aspnet_Membership Mem ON U.UserId = Mem.UserId
-- JOINS TÉCNICOS
LEFT JOIN Marca_Moto Ma ON M.Moto_Marca_Id = Ma.Marca_Moto_Id
LEFT JOIN Modelo_Moto Mo ON M.Moto_Modelo_Id = Mo.Modelo_Moto_Id
LEFT JOIN Año An ON M.Moto_Año = An.Año_Id
LEFT JOIN Moneda Mon ON M.Moto_Moneda_Id = Mon.Mone_id
WHERE M.Moto_Estado = 0
AND M.Moto_Fecha_Finalizacion >= GETDATE()";
var adsLegacy = (await dbAutos.QueryAsync<LegacyMotoData>(query)).ToList();
Console.WriteLine($" ✅ Encontradas {adsLegacy.Count} motos candidatas.");
// 3. FOTOS
var idsString = string.Join(",", adsLegacy.Select(a => a.Moto_Id));
var photosLegacy = new List<LegacyMotoPhoto>();
if (adsLegacy.Any())
{
photosLegacy = (await dbAutos.QueryAsync<LegacyMotoPhoto>(
$"SELECT Foto_Id, Foto_Moto_Id, Foto_Ruta FROM Fotos_Motos WHERE Foto_Moto_Id IN ({idsString})")).ToList();
}
int migrados = 0;
int omitidos = 0;
foreach (var ad in adsLegacy)
{
// A. Validar Usuario
string emailKey = ad.EmailVendedor?.ToLower().Trim() ?? "";
if (!usersV2.TryGetValue(emailKey, out var userV2))
{
omitidos++;
continue;
}
// B. Resolver Marca y Modelo
var brandV2 = brandsV2.FirstOrDefault(b => b.Name.ToLower() == (ad.Marca_Detalle ?? "").ToLower());
if (brandV2 == null) continue; // Marca no mapeada, saltar
int modelId = await GetOrCreateModelAsync(dbV2, (int)brandV2.BrandID, ad.Modelo_Detalle);
// C. Datos
int.TryParse(ad.Año_Detalle, out int year);
string currency = (ad.Mone_Detalle != null && ad.Mone_Detalle.Contains("US")) ? "USD" : "ARS";
// Nombre Versión: Concatenamos Modelo + Cilindrada si existe
string versionName = ad.Modelo_Detalle;
// La cilindrada la ponemos en la descripción como pediste, pero a veces queda bien en el titulo.
// Lo dejamos en el título solo si es muy corto, si no, description.
// Descripción: Concatenar Cilindrada
string desc = ad.Descripcion ?? "";
if (!string.IsNullOrEmpty(ad.Moto_Cilindrada) && ad.Moto_Cilindrada != "0")
{
desc += $"\n\nCilindrada: {ad.Moto_Cilindrada}cc";
}
// Segmento: Lógica de Cuatriciclo
string segment = ad.Moto_Tipo_Cuatri_Id > 0 ? "Cuatriciclo" : "No Especificado";
// Contacto
string contactPhone = !string.IsNullOrWhiteSpace(ad.Part_Celular) ? ad.Part_Celular : ad.Part_Telefono;
if (string.IsNullOrWhiteSpace(contactPhone)) contactPhone = userV2.PhoneNumber;
// D. Insertar
var insertSql = @"
INSERT INTO Ads (
UserID, VehicleTypeID, BrandID, ModelID, VersionName, Year, KM,
Price, Currency, Description, IsFeatured, StatusID, CreatedAt, PublishedAt, ExpiresAt,
FuelType, Color, Segment, Location, Condition, Transmission, Steering,
ContactPhone, ContactEmail, DisplayContactInfo, LegacyAdID, ViewsCounter
) VALUES (
@UserID, 2, @BrandID, @ModelID, @VersionName, @Year, @KM,
@Price, @Currency, @Description, 0, 4, @CreatedAt, @PublishedAt, DATEADD(day, 30, @PublishedAt),
'Nafta', 'No Especificado', @Segment, 'La Plata', 'No Especificado', 'Manual', 'No Especificado',
@ContactPhone, @ContactEmail, 1, @LegacyAdID, @ViewsCounter
);
SELECT CAST(SCOPE_IDENTITY() as int);";
int newAdId = await dbV2.ExecuteScalarAsync<int>(insertSql, new
{
UserID = userV2.UserID,
BrandID = brandV2.BrandID,
ModelID = modelId,
VersionName = versionName,
Year = year,
KM = ad.Moto_Kilometraje,
Price = ad.Moto_Precio,
Currency = currency,
Description = desc,
Segment = segment,
ContactPhone = contactPhone,
ContactEmail = userV2.Email,
LegacyAdID = ad.Moto_Id,
ViewsCounter = ad.Moto_Cant_Visitas,
CreatedAt = ad.Moto_Fecha_Publicacion,
PublishedAt = ad.Moto_Fecha_Publicacion
});
// E. Fotos
var misFotos = photosLegacy
.Where(p => p.Foto_Moto_Id == ad.Moto_Id && !p.Foto_Ruta.Contains("nofoto"))
.ToList();
int order = 0;
foreach (var foto in misFotos)
{
string fileName = Path.GetFileName(foto.Foto_Ruta);
string v2Path = $"/uploads/legacy/{fileName}";
await dbV2.ExecuteAsync(@"
INSERT INTO AdPhotos (AdID, FilePath, IsCover, SortOrder)
VALUES (@AdID, @FilePath, @IsCover, @SortOrder)",
new { AdID = newAdId, FilePath = v2Path, IsCover = (order == 0), SortOrder = order });
order++;
}
migrados++;
}
Console.WriteLine("==================================================");
Console.WriteLine($"🏁 MIGRACIÓN MOTOS FINALIZADA: {migrados} insertados.");
Console.WriteLine("==================================================");
}
private async Task<int> GetOrCreateModelAsync(SqlConnection db, int brandId, string modelName)
{
if (string.IsNullOrWhiteSpace(modelName)) modelName = "Modelo Genérico";
var existing = await db.QueryFirstOrDefaultAsync<int?>(
"SELECT ModelID FROM Models WHERE BrandID = @brandId AND Name = @modelName",
new { brandId, modelName });
if (existing.HasValue) return existing.Value;
var newId = await db.ExecuteScalarAsync<int>(
"INSERT INTO Models (BrandID, Name) VALUES (@brandId, @modelName); SELECT CAST(SCOPE_IDENTITY() as int);",
new { brandId, modelName });
return newId;
}
}

View File

@@ -0,0 +1,30 @@
namespace MotoresArgentinosV2.MigrationTool.Models;
public class LegacyAdData
{
public int Auto_Id { get; set; }
public string EmailVendedor { get; set; } = string.Empty;
public string Marca_Detalle { get; set; } = string.Empty;
public string Modelo_Detalle { get; set; } = string.Empty;
public string Auto_Version { get; set; } = string.Empty;
public string Año_Detalle { get; set; } = string.Empty;
public decimal Auto_precio { get; set; }
public string Mone_Detalle { get; set; } = string.Empty;
public int Auto_Kilometros { get; set; }
public string Descripcion { get; set; } = string.Empty;
public string Comb_Detalle { get; set; } = string.Empty;
public string Color_detalle { get; set; } = string.Empty;
public string Carroceria_Nombre { get; set; } = string.Empty;
public int PapelesAlDia { get; set; }
public string Part_Telefono { get; set; } = string.Empty;
public string Part_Celular { get; set; } = string.Empty;
public int Auto_Cant_Visitas { get; set; }
public DateTime Auto_Fecha_Publicacion { get; set; }
}
public class LegacyAdPhoto
{
public int Foto_Id { get; set; }
public int Foto_Auto_Id { get; set; }
public string Foto_Ruta { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,33 @@
// Backend/MotoresArgentinosV2.MigrationTool/Models/LegacyMotoData.cs
namespace MotoresArgentinosV2.MigrationTool.Models;
public class LegacyMotoData
{
public int Moto_Id { get; set; }
public DateTime Moto_Fecha_Publicacion { get; set; }
public int Moto_Cant_Visitas { get; set; }
public string EmailVendedor { get; set; } = string.Empty;
public string Marca_Detalle { get; set; } = string.Empty;
public string Modelo_Detalle { get; set; } = string.Empty;
public string Año_Detalle { get; set; } = string.Empty;
public decimal Moto_Precio { get; set; }
public string Mone_Detalle { get; set; } = string.Empty;
public int Moto_Kilometraje { get; set; }
public string Descripcion { get; set; } = string.Empty;
public string Moto_Cilindrada { get; set; } = string.Empty;
public int Moto_Tipo_Cuatri_Id { get; set; } // > 0 es Cuatriciclo
public string Part_Telefono { get; set; } = string.Empty;
public string Part_Celular { get; set; } = string.Empty;
}
public class LegacyMotoPhoto
{
public int Foto_Id { get; set; }
public int Foto_Moto_Id { get; set; }
public string Foto_Ruta { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using Dapper;
namespace MotoresArgentinosV2.MigrationTool;
class Program
{
// --- CONFIGURACIÓN ---
public const string ConnectionStringAutos = "Server=localhost;Database=autos;Trusted_Connection=True;TrustServerCertificate=True;";
public const string ConnectionStringV2 = "Server=localhost;Database=MotoresV2;Trusted_Connection=True;TrustServerCertificate=True;";
// Fecha de corte para usuarios inactivos
static readonly DateTime FechaCorte = new DateTime(2021, 01, 01);
static async Task Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
while (true)
{
Console.Clear();
Console.WriteLine("==================================================");
Console.WriteLine("🚀 HERRAMIENTA DE MIGRACIÓN MOTORES V2");
Console.WriteLine("==================================================");
Console.WriteLine("1. Migrar USUARIOS (Desde 2021)");
Console.WriteLine("2. Migrar AVISOS DE AUTOS (Activos)");
Console.WriteLine("3. Migrar AVISOS DE MOTOS (Activos)");
Console.WriteLine("4. Salir");
Console.Write("\nSeleccione una opción: ");
var key = Console.ReadLine();
if (key == "1")
{
await RunUserMigration();
Console.WriteLine("\nPresione Enter para volver...");
Console.ReadLine();
}
else if (key == "2")
{
var adMigrator = new AdMigrator(ConnectionStringAutos, ConnectionStringV2);
await adMigrator.ExecuteAsync();
Console.WriteLine("\nPresione Enter para volver...");
Console.ReadLine();
}
else if (key == "3")
{
var adMigrator = new AdMigrator(ConnectionStringAutos, ConnectionStringV2);
await adMigrator.MigrateMotosAsync();
Console.WriteLine("\nPresione Enter para volver...");
Console.ReadLine();
}
else if (key == "4")
{
break;
}
}
}
// --- LÓGICA DE MIGRACIÓN DE USUARIOS (Encapsulada) ---
static async Task RunUserMigration()
{
Console.WriteLine("\n🔍 Iniciando migración de usuarios...");
try
{
using var dbAutos = new SqlConnection(ConnectionStringAutos);
using var dbV2 = new SqlConnection(ConnectionStringV2);
var queryLegacy = @"
SELECT
u.UserName,
m.Email,
m.Password as PasswordHash,
m.PasswordSalt,
u.LastActivityDate,
1 as UserType
FROM aspnet_Users u
INNER JOIN aspnet_Membership m ON u.UserId = m.UserId
WHERE u.LastActivityDate >= @FechaCorte";
var usersLegacy = (await dbAutos.QueryAsync<UserLegacy>(queryLegacy, new { FechaCorte })).ToList();
Console.WriteLine($"✅ Encontrados {usersLegacy.Count} usuarios activos para procesar.");
var processedUsernames = new HashSet<string>();
int migrados = 0;
int saltados = 0;
int passReset = 0;
foreach (var user in usersLegacy)
{
// A. NORMALIZACIÓN
string cleanUsername = NormalizeUsername(user.UserName);
if (cleanUsername.Contains("@"))
{
cleanUsername = cleanUsername.Split('@')[0];
cleanUsername = NormalizeUsername(cleanUsername);
}
string finalUsername = cleanUsername;
int counter = 1;
while (processedUsernames.Contains(finalUsername) || await UserExistsInDb(dbV2, finalUsername))
{
finalUsername = $"{cleanUsername}{counter}";
counter++;
}
processedUsernames.Add(finalUsername);
// B. LÓGICA DE CONTRASEÑAS
bool isPlainText = user.PasswordHash.Length < 20;
string finalHash = user.PasswordHash;
string? finalSalt = user.PasswordSalt;
byte migrationStatus = 0;
if (isPlainText)
{
finalHash = "INVALID_PASSWORD_NEEDS_RESET";
finalSalt = null;
passReset++;
}
// C. INSERCIÓN
var existeEmail = await dbV2.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM Users WHERE Email = @Email", new { user.Email });
if (existeEmail == 0)
{
var insertQuery = @"
INSERT INTO Users (
UserName, Email, PasswordHash, PasswordSalt, MigrationStatus,
UserType, CreatedAt, IsMFAEnabled, FirstName, LastName
)
VALUES (
@UserName, @Email, @PasswordHash, @PasswordSalt, @MigrationStatus,
@UserType, @CreatedAt, 0, NULL, NULL
)";
await dbV2.ExecuteAsync(insertQuery, new
{
UserName = finalUsername,
user.Email,
PasswordHash = finalHash,
PasswordSalt = finalSalt,
MigrationStatus = migrationStatus,
user.UserType,
CreatedAt = user.LastActivityDate
});
migrados++;
}
else
{
saltados++;
}
}
Console.WriteLine($"🏁 FIN: {migrados} migrados, {saltados} saltados (email duplicado).");
}
catch (Exception ex)
{
Console.WriteLine($"❌ ERROR: {ex.Message}");
}
}
static async Task<bool> UserExistsInDb(SqlConnection db, string username)
{
var count = await db.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM Users WHERE UserName = @username", new { username });
return count > 0;
}
static string NormalizeUsername(string text)
{
if (string.IsNullOrWhiteSpace(text)) return "usuario_desconocido";
text = text.ToLowerInvariant();
var normalizedString = text.Normalize(NormalizationForm.FormD);
var stringBuilder = new StringBuilder();
foreach (var c in normalizedString)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)
stringBuilder.Append(c);
}
text = stringBuilder.ToString().Normalize(NormalizationForm.FormC);
text = Regex.Replace(text, "[^a-z0-9]", "");
if (string.IsNullOrEmpty(text)) return "usuario";
return text;
}
}
public class UserLegacy
{
public string UserName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string PasswordSalt { get; set; } = string.Empty;
public DateTime LastActivityDate { get; set; }
public int UserType { get; set; }
}

31
Frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
appsettings.Development.json
#.env
#.env.local
#.env.development.local
#.env.test.local
#.env.production.local

31
Frontend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Etapa de construcción
FROM node:22-alpine AS build
WORKDIR /app
# Copiar archivos de dependencia
COPY package*.json ./
RUN npm install
# Copiar código fuente
COPY . .
# Argumentos de construcción para variables de entorno
# Se pueden pasar vía docker-compose o comando build
ARG VITE_API_BASE_URL
ARG VITE_STATIC_BASE_URL
ARG VITE_MP_PUBLIC_KEY
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_STATIC_BASE_URL=$VITE_STATIC_BASE_URL
ENV VITE_MP_PUBLIC_KEY=$VITE_MP_PUBLIC_KEY
# Construir la aplicación
RUN npm run build
# Etapa de producción con Nginx
FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
Frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
Frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

14
Frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<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>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
Frontend/nginx.conf Normal file
View File

@@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Proxy interno al Backend (Nombre del servicio en docker-compose)
location /api/ {
proxy_pass http://motores-backend:8080/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy para las imágenes servidas por el backend (fotos de vehículos)
location /uploads/ {
proxy_pass http://motores-backend:8080/uploads/;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

4200
Frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
Frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@mercadopago/sdk-react": "^1.0.7",
"@tailwindcss/vite": "^4.1.18",
"axios": "^1.13.2",
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

BIN
Frontend/public/bg-car.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -0,0 +1,64 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Sombra paralela para levantar el logo del fondo -->
<filter id="dropShadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="10"/>
<feOffset dx="0" dy="12" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.8"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Degradado Cromo Principal (Cara Frontal) -->
<linearGradient id="chromeFace" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#f0f0f0;stop-opacity:1" />
<stop offset="45%" style="stop-color:#8c8c8c;stop-opacity:1" />
<stop offset="50%" style="stop-color:#2b2b2b;stop-opacity:1" />
<stop offset="52%" style="stop-color:#000000;stop-opacity:1" />
<stop offset="100%" style="stop-color:#595959;stop-opacity:1" />
</linearGradient>
<!-- Degradado Bisel (Bordes 3D iluminados) -->
<linearGradient id="bevelLight" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="50%" style="stop-color:#bfbfbf;stop-opacity:1" />
<stop offset="100%" style="stop-color:#404040;stop-opacity:1" />
</linearGradient>
<!-- Degradado Lateral (Profundidad oscura) -->
<linearGradient id="sideDark" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#1a1a1a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#000000;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Grupo Principal sin margen extra para aprovechar todo el espacio -->
<g>
<!-- 1. CAPA DE PROFUNDIDAD (Extrusión) -->
<!-- Ajustada para seguir la nueva forma centrada -->
<path d="M20,480 L20,30 L135,30 L256,300 L377,30 L492,30 L492,480 L377,480 L377,250 L256,450 L135,250 L135,480 Z"
fill="url(#sideDark)" transform="translate(10, 10)" opacity="0.8"/>
<!-- 2. CAPA BORDE / BISEL (Marco Brillante - Base) -->
<!-- Coordenadas expandidas: X va de 20 a 492 (ancho total 472px) -->
<g filter="url(#dropShadow)">
<path d="M20,480 L20,30 L135,30 L256,300 L377,30 L492,30 L492,480 L377,480 L377,250 L256,450 L135,250 L135,480 Z"
fill="url(#bevelLight)" stroke="#111" stroke-width="1"/>
<!-- 3. CAPA FRONTAL (Cara visible) -->
<!-- Inset calculado proporcionalmente para mantener el bisel visible -->
<path d="M40,465 L40,50 L120,50 L256,340 L392,50 L472,50 L472,465 L392,465 L392,270 L256,480 L120,270 L120,465 Z"
fill="url(#chromeFace)" stroke="#333" stroke-width="1" opacity="0.95"/>
<!-- 4. DETALLE DE BRILLO EXTRA (Destello superior) -->
<!-- Reposicionado a los nuevos hombros de la M -->
<path d="M40,50 L120,50 L160,150 L80,150 Z" fill="white" opacity="0.2"/>
<path d="M472,50 L392,50 L352,150 L432,150 Z" fill="white" opacity="0.2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

336
Frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,336 @@
import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useNavigate, useLocation } from 'react-router-dom';
import HomePage from './pages/HomePage';
import ExplorarPage from './pages/ExplorarPage';
import VehiculoDetailPage from './pages/VehiculoDetailPage';
import PublicarAvisoPage from './pages/PublicarAvisoPage';
import MisAvisosPage from './pages/MisAvisosPage';
import SuccessPage from './pages/SuccessPage';
import AdminPage from './pages/AdminPage';
import LoginModal from './components/LoginModal';
import { type UserSession } from './services/auth.service';
import VerifyEmailPage from './pages/VerifyEmailPage';
import ResetPasswordPage from './pages/ResetPasswordPage';
import PerfilPage from './pages/PerfilPage';
import SeguridadPage from './pages/SeguridadPage';
import { FaHome, FaSearch, FaCar, FaUser, FaShieldAlt } from 'react-icons/fa';
import { initMercadoPago } from '@mercadopago/sdk-react';
import { AuthProvider, useAuth } from './context/AuthContext';
function AdminGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return <div className="min-h-screen flex items-center justify-center bg-[#0a0c10]"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div></div>;
if (!user || user.userType !== 3) return <Navigate to="/" replace />;
return <>{children}</>;
}
// COMPONENTE NAVBAR CON DROPDOWN
function Navbar() {
const { user, logout, login, unreadCount } = useAuth();
const [showLoginModal, setShowLoginModal] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
const isAdmin = user?.userType === 3;
const navigate = useNavigate();
const location = useLocation();
const handleLogout = () => {
logout();
setShowUserMenu(false);
setShowMobileMenu(false);
navigate('/');
};
const handleMobileNavClick = (path: string) => {
setShowMobileMenu(false);
navigate(path);
};
const handleLoginSuccess = (userSession: UserSession) => {
login(userSession);
setShowLoginModal(false);
};
const getLinkClass = (path: string) => {
const isActive = location.pathname === path;
return `transition-all duration-300 font-bold tracking-widest text-s hover:text-white ${isActive ? 'text-blue-400 drop-shadow-[0_0_8px_rgba(59,130,246,0.5)]' : 'text-gray-300'}`;
};
return (
<>
<nav className="sticky top-0 z-[100] border-b border-white/10 bg-[#0a0c10]/80 backdrop-blur-xl shadow-2xl transition-all duration-300">
<div className="container mx-auto px-6 h-20 flex justify-between items-center">
<Link to="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 bg-gradient-to-tr from-blue-600 to-cyan-400 rounded-xl flex items-center justify-center shadow-lg shadow-blue-600/20 group-hover:shadow-blue-500/40 group-hover:scale-105 transition-all duration-300">
<span className="text-white font-black text-xl">M</span>
</div>
<div className="flex flex-col">
<span className="text-xl font-black tracking-tighter leading-none text-white group-hover:text-blue-100 transition-colors">MOTORES</span>
<span className="text-[10px] tracking-[0.3em] font-bold text-blue-400 leading-none group-hover:text-blue-300 transition-colors">ARGENTINOS</span>
</div>
</Link>
<div className="hidden md:flex items-center gap-10">
<Link to="/" className={getLinkClass('/')}>HOME</Link>
<Link to="/explorar" className={getLinkClass('/explorar')}>EXPLORAR</Link>
<Link to="/vender" className={`transition-all duration-300 font-bold tracking-widest hover:text-white ${location.pathname === '/vender' || location.pathname === '/publicar' ? 'text-blue-400 drop-shadow-[0_0_8px_rgba(59,130,246,0.5)]' : 'text-gray-300'}`}>
PUBLICAR
</Link>
{/* --- 2. ENLACE DE GESTIÓN --- */}
<Link to="/mis-avisos" className="relative">
<span className={getLinkClass('/mis-avisos')}>MIS AVISOS</span>
{user && unreadCount > 0 && (
<span className="absolute -top-1.5 -right-3.5 w-4 h-4 bg-red-600 text-white text-[9px] font-bold rounded-full flex items-center justify-center border-2 border-[#0a0c10] animate-pulse">
{unreadCount}
</span>
)}
</Link>
{isAdmin && (
<Link to="/admin" className={`transition-all duration-300 font-bold tracking-widest text-xs flex items-center gap-2 hover:text-white ${location.pathname === '/admin' ? 'text-blue-400' : 'text-gray-300'}`}>
<span className="text-sm">🛡</span> ADMIN
</Link>
)}
</div>
<div className="flex items-center gap-4">
{/* Botón de menú hamburguesa para móvil - Mejorado */}
<button
onClick={() => setShowMobileMenu(!showMobileMenu)}
className="md:hidden w-11 h-11 rounded-xl glass border border-white/10 flex flex-col items-center justify-center gap-1.5 group focus:outline-none hover:border-blue-500/50 transition-all shadow-lg"
aria-label="Menú de navegación"
>
<span className={`w-5 h-0.5 bg-gradient-to-r from-blue-400 to-cyan-400 rounded-full transition-all duration-300 ${showMobileMenu ? 'rotate-45 translate-y-2' : 'group-hover:w-6'}`}></span>
<span className={`w-5 h-0.5 bg-gradient-to-r from-blue-400 to-cyan-400 rounded-full transition-all duration-300 ${showMobileMenu ? 'opacity-0' : 'group-hover:w-6'}`}></span>
<span className={`w-5 h-0.5 bg-gradient-to-r from-blue-400 to-cyan-400 rounded-full transition-all duration-300 ${showMobileMenu ? '-rotate-45 -translate-y-2' : 'group-hover:w-6'}`}></span>
</button>
{user ? (
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
onBlur={() => setTimeout(() => setShowUserMenu(false), 200)}
className="flex items-center gap-3 group focus:outline-none"
>
<div className="text-right hidden lg:block">
<span className="text-[9px] font-bold text-gray-400 uppercase tracking-widest block group-hover:text-blue-400 transition-colors">Hola</span>
<span className="text-xs font-black text-white uppercase tracking-wider group-hover:text-blue-200 transition-colors">
{user.firstName || user.username}
</span>
</div>
<div className="w-10 h-10 bg-gradient-to-br from-gray-800 to-gray-900 rounded-xl border border-white/10 flex items-center justify-center group-hover:border-blue-500/50 transition-all shadow-lg">
<span className="text-sm">👤</span>
</div>
</button>
{showUserMenu && (
<div className="absolute right-0 top-full mt-2 w-48 bg-[#161a22] border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-fade-in z-50">
<Link to="/perfil" className="block px-5 py-3 text-sm text-gray-300 hover:bg-white/5 hover:text-white transition-colors border-b border-white/5">
👤 Mi Perfil
</Link>
<Link to="/seguridad" className="block px-5 py-3 text-sm text-gray-300 hover:bg-white/5 hover:text-white transition-colors border-b border-white/5">
🛡 Seguridad
</Link>
<button
onClick={handleLogout}
className="block w-full text-left px-5 py-3 text-xs font-black text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors uppercase tracking-widest"
>
Cerrar Sesión
</button>
</div>
)}
</div>
) : (
<button
onClick={() => setShowLoginModal(true)}
className="bg-blue-600 hover:bg-blue-500 text-white px-4 md:px-7 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/30 hover:shadow-blue-500/50 hover:-translate-y-0.5 flex items-center gap-2"
>
Ingresar
</button>
)}
</div>
</div>
</nav>
{/* Menú móvil overlay MODERNIZADO */}
{showMobileMenu && (
<div
className="fixed inset-0 top-[70px] z-[90] bg-black/95 backdrop-blur-3xl md:hidden animate-fade-in-up flex flex-col p-5 overflow-y-auto border-t border-white/10"
>
<div className="flex flex-col gap-3 mt-2">
<button
onClick={() => handleMobileNavClick('/')}
className={`group flex items-center gap-3.5 p-2.5 rounded-2xl transition-all ${location.pathname === '/' ? 'bg-white/10' : 'hover:bg-white/5'}`}
>
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-lg transition-all ${location.pathname === '/' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white/5 text-gray-400 group-hover:bg-white/10 group-hover:text-white'}`}>
<FaHome />
</div>
<span className={`text-lg font-black uppercase italic tracking-tighter ${location.pathname === '/' ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
Home
</span>
</button>
<button
onClick={() => handleMobileNavClick('/explorar')}
className={`group flex items-center gap-3.5 p-2.5 rounded-2xl transition-all ${location.pathname === '/explorar' ? 'bg-white/10' : 'hover:bg-white/5'}`}
>
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-lg transition-all ${location.pathname === '/explorar' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white/5 text-gray-400 group-hover:bg-white/10 group-hover:text-white'}`}>
<FaSearch />
</div>
<span className={`text-lg font-black uppercase italic tracking-tighter ${location.pathname === '/explorar' ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
Explorar
</span>
</button>
<button
onClick={() => handleMobileNavClick('/vender')}
className={`group flex items-center gap-3.5 p-2.5 rounded-2xl transition-all ${(location.pathname === '/vender' || location.pathname === '/publicar') ? 'bg-white/10' : 'hover:bg-white/5'}`}
>
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-lg transition-all ${(location.pathname === '/vender' || location.pathname === '/publicar') ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white/5 text-gray-400 group-hover:bg-white/10 group-hover:text-white'}`}>
<FaCar />
</div>
<span className={`text-lg font-black uppercase italic tracking-tighter ${(location.pathname === '/vender' || location.pathname === '/publicar') ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
Publicar
</span>
</button>
<button
onClick={() => handleMobileNavClick('/mis-avisos')}
className={`group flex items-center gap-3.5 p-2.5 rounded-2xl transition-all relative ${location.pathname === '/mis-avisos' ? 'bg-white/10' : 'hover:bg-white/5'}`}
>
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-lg transition-all ${location.pathname === '/mis-avisos' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white/5 text-gray-400 group-hover:bg-white/10 group-hover:text-white'}`}>
<FaUser />
</div>
<div className="flex flex-col">
<span className={`text-lg font-black uppercase italic tracking-tighter ${location.pathname === '/mis-avisos' ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
Mis Avisos
</span>
{user && unreadCount > 0 && (
<span className="text-xs font-bold text-red-400 uppercase tracking-widest mt-1">
{unreadCount} mensaje(s) nuevo(s)
</span>
)}
</div>
{user && unreadCount > 0 && (
<span className="absolute top-6 right-6 w-3 h-3 bg-red-500 rounded-full animate-pulse shadow-lg shadow-red-500/50"></span>
)}
</button>
{isAdmin && (
<button
onClick={() => handleMobileNavClick('/admin')}
className={`group flex items-center gap-3.5 p-2.5 rounded-2xl transition-all ${location.pathname === '/admin' ? 'bg-white/10' : 'hover:bg-white/5'}`}
>
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-lg transition-all ${location.pathname === '/admin' ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30' : 'bg-white/5 text-gray-400 group-hover:bg-white/10 group-hover:text-white'}`}>
<FaShieldAlt />
</div>
<span className={`text-lg font-black uppercase italic tracking-tighter ${location.pathname === '/admin' ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}>
Admin
</span>
</button>
)}
</div>
<div className="mt-auto pt-10 pb-6 text-center">
<p className="text-xs text-gray-600 font-bold uppercase tracking-[0.2em]">Motores Argentinos v2.0</p>
</div>
</div>
)}
{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="relative w-full max-w-md">
<LoginModal
onSuccess={handleLoginSuccess}
onClose={() => setShowLoginModal(false)}
/>
</div>
</div>
)}
</>
);
}
function FooterLegal() {
const currentYear = new Date().getFullYear();
const baseEdition = 5858;
const baseDate = new Date('2026-01-21T00:00:00');
const today = new Date();
today.setHours(0, 0, 0, 0);
baseDate.setHours(0, 0, 0, 0);
const diffTime = today.getTime() - baseDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const currentEdition = baseEdition + (diffDays > 0 ? diffDays : 0);
return (
<footer className="border-t border-white/5 py-8 md:py-12 bg-black/40 mt-auto backdrop-blur-lg">
<div className="container mx-auto px-4 md:px-6 text-center">
<div className="flex flex-col gap-2 md:gap-2 text-[11px] md:text-[10px] text-gray-500 uppercase tracking-wider font-medium leading-relaxed max-w-4xl mx-auto">
<p>© {currentYear} MotoresArgentinos. Todos los derechos reservados. <span className="text-gray-400 font-bold ml-1">Edición número: {currentEdition}.</span></p>
<p>Registro DNDA : RL-2024-70042723-APN-DNDA#MJ - Propietario: Publiéxito S.A.</p>
<p>Director: Leonardo Mario Forclaz - 46 N 423 - La Plata - Pcia. de Bs. As.</p>
</div>
</div>
</footer>
);
}
function MainLayout() {
const { loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen bg-[#0a0c10] flex items-center justify-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0c10] text-gray-100 font-sans selection:bg-blue-500/30 flex flex-col">
<Navbar />
<main className="relative flex-grow">
<div className="fixed top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-600/5 blur-[120px] rounded-full z-0 pointer-events-none"></div>
<div className="fixed bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-cyan-400/5 blur-[120px] rounded-full z-0 pointer-events-none"></div>
<div className="relative z-10">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/explorar" element={<ExplorarPage />} />
<Route path="/vehiculo/:id" element={<VehiculoDetailPage />} />
<Route path="/publicar" element={<PublicarAvisoPage />} />
<Route path="/vender" element={<PublicarAvisoPage />} />
<Route path="/mis-avisos" element={<MisAvisosPage />} />
<Route path="/pago-confirmado" element={<SuccessPage />} />
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
<Route path="/verificar-email" element={<VerifyEmailPage />} />
<Route path="/perfil" element={<PerfilPage />} />
<Route path="/seguridad" element={<SeguridadPage />} />
<Route path="/admin" element={
<AdminGuard>
<AdminPage />
</AdminGuard>
} />
</Routes>
</div>
</main>
<FooterLegal />
</div>
);
}
function App() {
useEffect(() => {
const mpPublicKey = import.meta.env.VITE_MP_PUBLIC_KEY;
if (mpPublicKey) initMercadoPago(mpPublicKey);
}, []);
return (
<AuthProvider>
<Router>
<MainLayout />
</Router>
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1,124 @@
import { parseUTCDate, formatCurrency } from '../utils/app.utils';
import { STATUS_CONFIG } from '../constants/adStatuses';
interface Props {
ad: any;
onClose: () => void;
}
export default function AdDetailsModal({ ad, onClose }: Props) {
if (!ad) return null;
const status = STATUS_CONFIG[ad.statusID] || { label: 'Desconocido', color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-white/10' };
// Helper para fecha de pago
const paymentDate = ad.paidDate
? parseUTCDate(ad.paidDate).toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: '-';
return (
<div className="fixed inset-0 z-[2000] flex items-start md:items-center justify-center p-4 md:p-8 pt-24 md:pt-8 animate-fade-in overflow-hidden">
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative bg-[#12141a] w-full max-w-2xl max-h-[85vh] md:max-h-[90vh] flex flex-col rounded-[2rem] border border-white/10 shadow-2xl overflow-hidden animate-scale-up">
{/* Header */}
<div className="px-6 md:px-8 py-5 md:py-6 border-b border-white/5 bg-[#161a22] flex justify-between items-center shrink-0">
<div>
<div className="flex items-center gap-3 mb-1 md:mb-2">
<span className={`px-2 md:px-3 py-0.5 md:py-1 rounded-lg text-[8px] md:text-[10px] font-black uppercase tracking-widest border ${status.bg} ${status.color} ${status.border}`}>
{status.label}
</span>
<span className="text-gray-500 text-[10px] md:text-xs font-bold">ID #{ad.adID}</span>
</div>
<h2 className="text-xl md:text-2xl font-black uppercase tracking-tight text-white line-clamp-1">{ad.title}</h2>
</div>
<button onClick={onClose} className="w-9 h-9 md:w-10 md:h-10 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-all shrink-0"></button>
</div>
{/* Contenido con Scroll Interno */}
<div className="flex-1 overflow-y-auto p-5 md:p-8 custom-scrollbar">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
{/* Columna 1: Datos Financieros */}
<div className="space-y-6">
<div className="glass p-3 rounded-2xl border border-white/5 relative overflow-hidden">
<div className="absolute top-0 right-0 w-16 h-16 bg-green-500/5 rounded-full blur-xl -mr-5 -mt-5"></div>
<h4 className="text-[10px] font-black uppercase tracking-widest text-green-400 mb-4 flex items-center gap-2">💰 Transacción</h4>
<div className="space-y-4">
<div className="flex justify-between items-end border-b border-white/5 pb-3">
<span className="text-xs text-gray-500 font-medium">Monto Abonado</span>
<span className="text-xl text-white font-black tracking-tight">
{ad.paidAmount ? formatCurrency(ad.paidAmount, 'ARS') : <span className="text-gray-600 text-xs text-right">Sin pago registrado</span>}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-gray-500 font-medium">Fecha de Pago</span>
<span className="text-xs text-gray-300 font-bold">{paymentDate}</span>
</div>
</div>
</div>
{/* Métricas */}
<div className="glass p-3 rounded-2xl border border-white/5">
<h4 className="text-[10px] font-black uppercase tracking-widest text-purple-400 mb-4">Métricas</h4>
<div className="flex justify-between items-center">
<div className="text-center">
<span className="block text-2xl font-black text-white">{ad.views}</span>
<span className="text-[9px] text-gray-500 uppercase tracking-widest">Visitas</span>
</div>
<div className="h-8 w-px bg-white/10"></div>
<div className="text-center">
<span className="block text-lg font-bold text-gray-300">{ad.legacyID || '-'}</span>
<span className="text-[9px] text-gray-500 uppercase tracking-widest">ID Operación</span>
</div>
</div>
</div>
</div>
{/* Columna 2: Línea de Tiempo */}
<div className="glass p-3 rounded-2xl border border-white/5">
<h4 className="text-[10px] font-black uppercase tracking-widest text-blue-400 mb-6">Ciclo de Vida</h4>
<div className="space-y-6 relative before:absolute before:left-2 before:top-2 before:bottom-2 before:w-px before:bg-white/10">
<TimelineItem date={ad.createdAt} label="Creado / Borrador" active={!!ad.createdAt} />
<TimelineItem date={ad.publishedAt} label="Aprobado / Publicado" active={!!ad.publishedAt} highlight />
<TimelineItem date={ad.expiresAt} label="Vencimiento Programado" active={!!ad.expiresAt} future={new Date(ad.expiresAt) > new Date()} />
{ad.deletedAt && <TimelineItem date={ad.deletedAt} label="Eliminado (Soft Delete)" active={true} color="text-red-400" />}
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 bg-[#161a22] border-t border-white/5 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-600/20 flex items-center justify-center text-blue-400 font-bold text-xs">
👤
</div>
<div className="flex flex-col">
<span className="text-xs font-bold text-white">{ad.userName}</span>
<span className="text-[10px] text-gray-500">{ad.userEmail}</span>
</div>
</div>
<a href={`/vehiculo/${ad.adID}`} target="_blank" className="bg-white/5 hover:bg-white/10 border border-white/10 text-white px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all">
Ver en Web
</a>
</div>
</div>
</div>
);
}
function TimelineItem({ date, label, active, highlight, future, color = 'text-gray-400' }: any) {
if (!date && !future) return null;
return (
<div className="relative pl-8">
<div className={`absolute left-0 top-1.5 w-4 h-4 rounded-full border-2 ${active ? (highlight ? 'border-green-500 bg-green-500/20' : 'border-blue-500 bg-[#12141a]') : 'border-gray-700 bg-[#12141a]'}`}></div>
<p className={`text-xs font-bold ${active ? (highlight ? 'text-green-400' : 'text-white') : 'text-gray-600'}`}>
{date ? parseUTCDate(date).toLocaleDateString('es-AR', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'}
</p>
<p className={`text-[10px] uppercase tracking-widest font-bold ${color}`}>{label}</p>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { AD_STATUSES, STATUS_CONFIG } from '../constants/adStatuses';
interface Props {
statusId: number;
className?: string;
}
export default function AdStatusBadge({ statusId, className = '' }: Props) {
// Si está activo, no mostramos badge en las tarjetas públicas (Home/Explorar) para mantener limpieza.
if (statusId === AD_STATUSES.ACTIVE) return null;
const config = STATUS_CONFIG[statusId];
if (!config) return null;
return (
<div className={`
flex items-center gap-1.5 px-3 py-1.5 rounded-lg border backdrop-blur-md shadow-lg shadow-black/40
${config.bg} ${config.color} ${config.border}
font-black uppercase tracking-widest text-[10px] select-none
${className}
`}>
<span className="text-xs drop-shadow-md">{config.icon}</span>
<span className="drop-shadow-md">{config.label}</span>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { useState } from 'react';
import { AuthService } from '../services/auth.service';
interface Props {
onClose: () => void;
}
// --- ICONOS SVG (Reutilizados) ---
const EyeIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>);
const EyeSlashIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" /></svg>);
const CheckCircleIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-green-500"><path fillRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" /></svg>);
const XCircleIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-red-500"><path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z" clipRule="evenodd" /></svg>);
const NeutralCircleIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-gray-600"><path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm0 18a8.25 8.25 0 110-16.5 8.25 8.25 0 010 16.5z" clipRule="evenodd" /></svg>);
export default function ChangePasswordModal({ onClose }: Props) {
const [currentPass, setCurrentPass] = useState('');
const [newPass, setNewPass] = useState('');
const [confirmPass, setConfirmPass] = useState('');
// Visibilidad
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
// Validaciones
const validations = {
length: newPass.length >= 8,
upper: /[A-Z]/.test(newPass),
number: /\d/.test(newPass),
special: /[\W_]/.test(newPass),
match: newPass.length > 0 && newPass === confirmPass
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validations.length || !validations.upper || !validations.number || !validations.special || !validations.match) {
setError('La nueva contraseña no cumple con los requisitos.');
return;
}
setLoading(true);
try {
await AuthService.changePassword(currentPass, newPass);
setSuccess(true);
setTimeout(() => onClose(), 2000); // Cerrar automáticamente tras éxito
} catch (err: any) {
setError(err.response?.data?.message || 'Error al cambiar contraseña');
} finally {
setLoading(false);
}
};
const RequirementItem = ({ isValid, text }: { isValid: boolean, text: string }) => {
const isNeutral = newPass.length === 0;
return (
<li className={`flex items-center gap-2 text-xs transition-colors duration-300 ${isValid ? 'text-green-400' : isNeutral ? 'text-gray-500' : 'text-red-400'}`}>
{isNeutral ? <NeutralCircleIcon /> : isValid ? <CheckCircleIcon /> : <XCircleIcon />}
<span>{text}</span>
</li>
);
};
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in p-4">
<div className="glass px-8 pt-5 pb-8 rounded-3xl border border-white/10 shadow-2xl max-w-md w-full relative">
<button onClick={onClose} className="absolute top-4 right-4 text-gray-500 hover:text-white"></button>
<h2 className="text-2xl font-black uppercase tracking-tighter mb-1 text-center">Cambiar Contraseña</h2>
<p className="text-[10px] text-gray-500 text-center mb-6 uppercase tracking-widest">Seguridad de la cuenta</p>
{error && (
<div className="bg-red-500/20 text-red-300 p-3 rounded-xl mb-6 text-xs font-bold border border-red-500/20 flex items-center gap-2">
<XCircleIcon /> {error}
</div>
)}
{success ? (
<div className="bg-green-500/20 text-green-300 p-6 rounded-xl text-center border border-green-500/20 animate-fade-in-up">
<div className="flex justify-center mb-2"><CheckCircleIcon /></div>
<p className="font-bold text-lg">¡Contraseña Actualizada!</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Contraseña Actual */}
<div className="relative">
<input required type={showCurrent ? "text" : "password"} value={currentPass} onChange={e => setCurrentPass(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-4 pr-10 py-3 text-sm text-white outline-none focus:border-blue-500 placeholder:text-gray-600"
placeholder="Contraseña Actual" />
<button type="button" onClick={() => setShowCurrent(!showCurrent)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
{showCurrent ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
<hr className="border-white/5 my-2" />
{/* Nueva Contraseña */}
<div className="relative">
<input required type={showNew ? "text" : "password"} value={newPass} onChange={e => setNewPass(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-4 pr-10 py-3 text-sm text-white outline-none focus:border-green-500 placeholder:text-gray-600"
placeholder="Nueva Contraseña" />
<button type="button" onClick={() => setShowNew(!showNew)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
{showNew ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
{/* Confirmar Nueva */}
<div className="relative">
<input required type={showConfirm ? "text" : "password"} value={confirmPass} onChange={e => setConfirmPass(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-4 pr-10 py-3 text-sm text-white outline-none focus:border-green-500 placeholder:text-gray-600"
placeholder="Repetir Nueva Contraseña" />
<button type="button" onClick={() => setShowConfirm(!showConfirm)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
{showConfirm ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
{/* Validaciones Visuales */}
<div className="px-1 py-2">
<ul className="space-y-1">
<RequirementItem isValid={validations.length} text="Mínimo 8 caracteres" />
<RequirementItem isValid={validations.upper} text="1 Mayúscula" />
<RequirementItem isValid={validations.number} text="1 Número" />
<RequirementItem isValid={validations.special} text="1 Símbolo (!@#)" />
<RequirementItem isValid={validations.match} text="Las contraseñas coinciden" />
</ul>
</div>
<button disabled={loading} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-xl font-bold uppercase tracking-widest text-xs transition-all shadow-lg shadow-blue-600/20 disabled:opacity-50">
{loading ? 'Actualizando...' : 'Confirmar Cambio'}
</button>
</form>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useState, useEffect, useRef } from 'react';
import { ChatService, type ChatMessage } from '../services/chat.service';
import { parseUTCDate } from '../utils/app.utils';
interface ChatModalProps {
isOpen: boolean;
onClose: () => void;
adId: number;
adTitle: string;
sellerId: number;
currentUserId: number;
}
export default function ChatModal({ isOpen, onClose, adId, adTitle, sellerId, currentUserId }: ChatModalProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [newMessage, setNewMessage] = useState('');
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
loadMessages();
const interval = setInterval(loadMessages, 5000);
return () => clearInterval(interval);
}
}, [isOpen]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const loadMessages = async () => {
try {
const data = await ChatService.getConversation(adId, currentUserId, sellerId);
setMessages(data);
} catch (err) {
console.error(err);
}
};
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim()) return;
setLoading(true);
try {
await ChatService.sendMessage({
adID: adId,
senderID: currentUserId,
receiverID: sellerId,
messageText: newMessage
});
setNewMessage('');
loadMessages();
} catch (err) {
alert('Error al enviar mensaje');
} finally {
setLoading(false);
}
};
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="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 */}
<div className="p-5 border-b border-white/5 flex justify-between items-center bg-[#1a1d24]">
<div className="flex items-center gap-4">
{/* Ícono Genérico de Mensaje */}
<div className="w-10 h-10 bg-white/5 rounded-xl flex items-center justify-center text-lg border border-white/5">
💬
</div>
<div>
<h3 className="text-sm font-black uppercase tracking-widest text-white mb-0.5">Mensajes del Aviso</h3>
<p className="text-[10px] text-gray-400 truncate max-w-[200px]">{adTitle}</p>
</div>
</div>
<button onClick={onClose} className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 hover:text-white hover:bg-white/10 transition-all font-bold"></button>
</div>
{/* Cuerpo de Mensajes */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-5 space-y-3 bg-[#0a0c10]"
>
{messages.length === 0 ? (
<div className="h-full flex flex-col justify-center items-center text-center opacity-40 space-y-3">
<span className="text-3xl grayscale">📝</span>
<p className="text-[10px] uppercase font-black tracking-widest text-gray-500">No hay mensajes previos</p>
</div>
) : (
messages.map((m, idx) => {
const isMine = m.senderID === currentUserId;
return (
<div key={idx} className={`flex ${isMine ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] p-3 rounded-xl text-sm shadow-sm ${isMine ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-[#1f222b] text-gray-200 border border-white/5 rounded-tl-none'}`}>
<p className="leading-snug">{m.messageText}</p>
<span className="text-[9px] font-medium uppercase mt-1 block opacity-50 text-right">
{parseUTCDate(m.sentAt!).toLocaleTimeString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour: '2-digit', minute: '2-digit', hour12: false })}
</span>
</div>
</div>
);
})
)}
</div>
{/* Input area */}
<form onSubmit={handleSend} className="p-4 bg-[#1a1d24] border-t border-white/5 gap-2 flex items-center">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Escribe aquí..."
className="flex-1 bg-black/30 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-blue-500 transition-all placeholder:text-gray-600"
/>
<button
type="submit"
disabled={loading || !newMessage.trim()}
className="w-12 h-12 bg-blue-600 hover:bg-blue-500 rounded-xl flex items-center justify-center text-white shadow-lg transition-all disabled:opacity-50 disabled:grayscale"
>
{loading ? <div className="animate-spin h-4 w-4 border-2 border-white/30 border-t-white rounded-full"></div> : '➤'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { useState } from 'react';
import { AuthService, type UserSession } from '../services/auth.service';
import { QRCodeSVG } from 'qrcode.react';
import ChangePasswordModal from './ChangePasswordModal';
import { useAuth } from '../context/AuthContext';
// Iconos
const CopyIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4"><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.381a9.06 9.06 0 001.5-.124A9.06 9.06 0 0021 15m-7.5-10.381V7.5a1.125 1.125 0 001.125 1.125h3.375" /></svg>);
const CheckIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4"><path fillRule="evenodd" d="M19.916 4.626a.75.75 0 01.208 1.04l-9 13.5a.75.75 0 01-1.154.114l-6-6a.75.75 0 011.06-1.06l5.353 5.353 8.493-12.739a.75.75 0 011.04-.208z" clipRule="evenodd" /></svg>);
export default function ConfigPanel({ user }: { user: UserSession }) {
const { refreshSession } = useAuth(); // Para actualizar si isMFAEnabled cambia en el contexto
const [showPasswordModal, setShowPasswordModal] = useState(false);
// Estados MFA
const [mfaStep, setMfaStep] = useState<'IDLE' | 'QR'>('IDLE');
const [qrUri, setQrUri] = useState('');
const [secretKey, setSecretKey] = useState(''); // El código manual
const [mfaCode, setMfaCode] = useState('');
const [msgMfa, setMsgMfa] = useState({ text: '', type: '' }); // type: 'success' | 'error'
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
const isMfaActive = (user as any).isMFAEnabled;
const handleInitMfa = async () => {
if (isMfaActive) {
if (!window.confirm("Al reconfigurar, el código anterior dejará de funcionar en tu otro dispositivo. ¿Continuar?")) return;
}
setLoading(true);
setMsgMfa({ text: '', type: '' });
try {
const data = await AuthService.initMFA();
setQrUri(data.qrUri);
setSecretKey(data.secret);
setMfaStep('QR');
} catch {
setMsgMfa({ text: "Error iniciando configuración.", type: 'error' });
} finally {
setLoading(false);
}
};
const handleVerifyMfa = async () => {
setLoading(true);
try {
await AuthService.verifyMFA(user.username, mfaCode);
setMsgMfa({ text: "¡MFA Activado correctamente!", type: 'success' });
setMfaStep('IDLE');
setMfaCode('');
await refreshSession(); // Actualizar estado global
} catch {
setMsgMfa({ text: "Código incorrecto. Intenta nuevamente.", type: 'error' });
} finally {
setLoading(false);
}
};
const handleDisableMfa = async () => {
if (!window.confirm("¿Seguro que deseas desactivar la protección de dos factores? Tu cuenta será menos segura.")) return;
setLoading(true);
try {
await AuthService.disableMFA();
setMsgMfa({ text: "MFA Desactivado.", type: 'success' });
await refreshSession();
} catch {
setMsgMfa({ text: "Error al desactivar MFA.", type: 'error' });
} finally {
setLoading(false);
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(secretKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* SECCIÓN CONTRASEÑA */}
<section className="glass p-8 rounded-[2rem] border border-white/5 flex flex-col items-center justify-center text-center relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-600 to-transparent opacity-50"></div>
<div className="w-16 h-16 bg-white/5 rounded-2xl flex items-center justify-center text-3xl mb-4 shadow-inner border border-white/5">
🔑
</div>
<h3 className="text-xl font-bold uppercase mb-2 text-white">Contraseña</h3>
<p className="text-sm text-gray-400 mb-6 max-w-xs leading-relaxed">
Mantén tu cuenta segura actualizando tu contraseña periódicamente.
</p>
<button
onClick={() => setShowPasswordModal(true)}
className="bg-white/5 hover:bg-white/10 border border-white/10 text-white px-8 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest w-full transition-all hover:border-white/20 active:scale-95"
>
Cambiar Contraseña
</button>
</section>
{/* SECCIÓN MFA */}
<section className={`glass p-8 rounded-[2rem] border relative overflow-hidden flex flex-col items-center transition-all ${isMfaActive ? 'border-green-500/20 bg-green-900/5' : 'border-white/5'}`}>
{/* Indicador de Estado */}
<div className={`absolute top-4 right-4 px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border ${isMfaActive ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-gray-500/10 text-gray-500 border-white/10'}`}>
{isMfaActive ? 'Protegido' : 'No Activo'}
</div>
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl mb-4 transition-colors ${isMfaActive ? 'bg-green-500/20 text-green-400 shadow-[0_0_20px_rgba(34,197,94,0.2)]' : 'bg-blue-600/10 text-blue-500'}`}>
🛡
</div>
<h3 className="text-xl font-bold uppercase mb-2 text-white">Doble Factor (2FA)</h3>
{mfaStep === 'IDLE' ? (
<div className="text-center w-full flex-1 flex flex-col">
<p className="text-sm text-gray-400 mb-6 max-w-xs mx-auto leading-relaxed">
{isMfaActive
? "Tu cuenta está protegida. Se solicita un código cada vez que inicias sesión en un dispositivo nuevo."
: "Añade una capa extra de seguridad. Requerirá un código de tu celular al iniciar sesión."}
</p>
<div className="mt-auto space-y-3">
{isMfaActive ? (
<div className="flex gap-3">
<button onClick={handleDisableMfa} disabled={loading} className="flex-1 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-4 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all">
{loading ? '...' : 'Desactivar'}
</button>
<button onClick={handleInitMfa} disabled={loading} className="flex-1 bg-white/5 hover:bg-white/10 text-white border border-white/10 px-4 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all">
Reconfigurar
</button>
</div>
) : (
<button onClick={handleInitMfa} disabled={loading} className="bg-blue-600 hover:bg-blue-500 text-white px-8 py-4 rounded-xl text-[10px] font-black uppercase tracking-widest w-full shadow-lg shadow-blue-600/20 transition-all hover:scale-[1.02]">
{loading ? 'Cargando...' : 'Activar MFA'}
</button>
)}
{msgMfa.text && (
<p className={`text-[10px] font-bold uppercase tracking-wide mt-4 animate-fade-in ${msgMfa.type === 'error' ? 'text-red-400' : 'text-green-400'}`}>
{msgMfa.text}
</p>
)}
</div>
</div>
) : (
<div className="text-center w-full animate-fade-in">
<div className="bg-white p-3 rounded-2xl inline-block mb-4 shadow-xl border-4 border-white">
<QRCodeSVG value={qrUri} size={140} />
</div>
<p className="text-xs text-gray-300 font-bold mb-2">1. Escanea el código</p>
<p className="text-[10px] text-gray-500 mb-4 max-w-[200px] mx-auto">Usa Google Authenticator o Authy en tu celular.</p>
{/* CÓDIGO MANUAL */}
<div className="bg-black/40 border border-white/10 rounded-xl p-3 mb-6 relative group w-full overflow-hidden">
<p className="text-[8px] text-gray-500 uppercase font-bold tracking-widest mb-1">O ingresa el código manual</p>
<div className="flex items-center justify-between gap-2">
<code className="text-blue-400 font-mono text-sm tracking-wider select-all break-all text-left flex-1">{secretKey}</code>
<button
onClick={copyToClipboard}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-all shrink-0"
title="Copiar"
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
</div>
</div>
<p className="text-xs text-gray-300 font-bold mb-2">2. Ingresa el token</p>
<input
type="text"
placeholder="000 000"
maxLength={6}
value={mfaCode}
onChange={e => setMfaCode(e.target.value.replace(/\D/g, ''))}
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-center text-2xl font-black text-white mb-4 tracking-[0.4em] outline-none focus:border-blue-500 transition-colors placeholder:opacity-20"
/>
<div className="flex gap-2">
<button onClick={() => setMfaStep('IDLE')} className="flex-1 bg-white/5 hover:text-white text-gray-500 py-3 rounded-xl text-[10px] font-bold uppercase transition-colors">Cancelar</button>
<button onClick={handleVerifyMfa} disabled={loading || mfaCode.length < 6} className="flex-1 bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-xl text-[10px] font-bold uppercase transition-colors disabled:opacity-50 shadow-lg shadow-blue-600/20">
{loading ? '...' : 'Activar'}
</button>
</div>
</div>
)}
</section>
</div>
{showPasswordModal && (
<ChangePasswordModal onClose={() => setShowPasswordModal(false)} />
)}
</>
);
}

View File

@@ -0,0 +1,85 @@
import { useEffect } from 'react';
interface Props {
isOpen: boolean;
title: string;
message: React.ReactNode;
onConfirm: () => void;
onCancel: () => void;
confirmText?: string;
cancelText?: string;
isDanger?: boolean; // Para pintar el botón de rojo si es eliminar
}
export default function ConfirmationModal({
isOpen,
title,
message,
onConfirm,
onCancel,
confirmText = "Confirmar",
cancelText = "Cancelar",
isDanger = false
}: Props) {
// Cerrar con tecla ESC
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
if (isOpen) window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onCancel]);
if (!isOpen) return null;
return (
<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"
onClick={onCancel}
></div>
{/* Modal Content */}
<div className="relative bg-[#12141a] w-full max-w-md p-8 rounded-[2rem] border border-white/10 shadow-2xl animate-scale-up text-center">
{/* Icono decorativo según tipo */}
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6 text-3xl shadow-lg border border-white/5
${isDanger ? 'bg-red-500/10 text-red-500' : 'bg-blue-600/10 text-blue-400'}
`}>
{isDanger ? '⚠️' : ''}
</div>
<h3 className="text-2xl font-black uppercase tracking-tight text-white mb-4">
{title}
</h3>
<div className="text-sm text-gray-400 leading-relaxed mb-8 whitespace-pre-line">
{message}
</div>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white py-3.5 rounded-xl font-bold uppercase text-[10px] tracking-widest transition-all"
>
{cancelText}
</button>
<button
onClick={onConfirm}
className={`flex-1 text-white py-3.5 rounded-xl font-bold uppercase text-[10px] tracking-widest transition-all shadow-lg
${isDanger
? 'bg-red-600 hover:bg-red-500 shadow-red-600/20'
: 'bg-blue-600 hover:bg-blue-500 shadow-blue-600/20'}
`}
>
{confirmText}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,378 @@
import { useState, useEffect } from 'react';
import VisualCreditCard from './VisualCreditCard';
declare global {
interface Window {
MercadoPago: any;
}
}
interface Props {
amount: number;
onPaymentSuccess: (details: any) => Promise<void>;
onPaymentError: (error: string) => void;
onCancel: () => void;
}
interface IdentificationType {
id: string;
name: string;
}
const FALLBACK_DOC_TYPES = [
{ id: 'DNI', name: 'DNI' }, { id: 'CUIT', name: 'CUIT' }, { id: 'CUIL', name: 'CUIL' }
];
export default function CreditCardForm({ amount, onPaymentSuccess, onPaymentError, onCancel }: Props) {
const [loading, setLoading] = useState(false);
const [docTypes, setDocTypes] = useState<IdentificationType[]>(FALLBACK_DOC_TYPES);
const [mpInstance, setMpInstance] = useState<any>(null);
const [paymentMethod, setPaymentMethod] = useState<{ id: string, name: string, thumbnail: string } | null>(null);
const [isCvvFocused, setIsCvvFocused] = useState(false);
const [formData, setFormData] = useState({
cardNumber: '',
cardholderName: '',
cardExpiration: '',
securityCode: '',
identificationType: 'DNI',
identificationNumber: '',
email: ''
});
// --- PATRÓN SINGLETON PARA MERCADOPAGO ---
// El script de seguridad de MP (Armor) lanza errores de IndexedDB si se reinicializa múltiples veces.
useEffect(() => {
const publicKey = import.meta.env.VITE_MP_PUBLIC_KEY;
if (window.MercadoPago) {
if (!(window as any).mpInstanceGlobal) {
try {
(window as any).mpInstanceGlobal = new window.MercadoPago(publicKey);
} catch (e) {
console.error("Error al crear instancia de MP:", e);
}
}
setMpInstance((window as any).mpInstanceGlobal);
}
}, []);
useEffect(() => {
if (mpInstance) {
mpInstance.getIdentificationTypes()
.then((types: IdentificationType[]) => {
if (types?.length > 0) setDocTypes(types);
})
.catch(() => console.warn("Usando fallback de tipos de documento."));
}
}, [mpInstance]);
const handleBinLookup = async (bin: string) => {
if (bin.length < 6 || !mpInstance) {
setPaymentMethod(null);
return;
}
try {
const response = await mpInstance.getPaymentMethods({ bin });
if (response && response.results && response.results.length > 0) {
setPaymentMethod(response.results[0]);
}
} catch (error) { console.error(error); }
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
let finalValue = value;
if (name === 'cardNumber') {
const cleanValue = value.replace(/[^\d]/g, '');
finalValue = cleanValue.replace(/(.{4})/g, '$1 ').trim();
if (cleanValue.length >= 6) {
handleBinLookup(cleanValue.substring(0, 6));
} else {
setPaymentMethod(null);
}
} else if (name === 'cardExpiration') {
const cleanValue = value.replace(/[^\d]/g, '');
if (cleanValue.length <= 2) {
finalValue = cleanValue;
} else {
finalValue = `${cleanValue.slice(0, 2)}/${cleanValue.slice(2, 4)}`;
}
}
setFormData(prev => ({ ...prev, [name]: finalValue }));
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
alert("Por seguridad, ingresa los datos manualmente.");
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!mpInstance || !paymentMethod) {
onPaymentError("Verifica los datos de la tarjeta.");
return;
}
setLoading(true);
const [expMonth, expYear] = formData.cardExpiration.split('/');
try {
const tokenResponse = await mpInstance.createCardToken({
cardNumber: formData.cardNumber.replace(/\s/g, ''),
cardholderName: formData.cardholderName,
cardExpirationMonth: expMonth,
cardExpirationYear: `20${expYear}`,
securityCode: formData.securityCode,
identificationType: formData.identificationType,
identificationNumber: formData.identificationNumber,
});
if (!tokenResponse?.id) throw new Error("Datos de tarjeta inválidos.");
// 1. Intentamos obtener el issuer desde la respuesta del token
let finalIssuerId = tokenResponse.issuer_id;
// 2. Si no viene en el token, intentamos buscarlo explícitamente
if (!finalIssuerId && mpInstance) {
try {
const bin = formData.cardNumber.replace(/\s/g, '').substring(0, 6);
const issuers = await mpInstance.getIssuers({ paymentMethodId: paymentMethod.id, bin });
if (issuers && issuers.length > 0) {
finalIssuerId = issuers[0].id; // Tomamos el primero encontrado
}
} catch (issuerErr) {
console.warn("No se pudo obtener issuer explícito", issuerErr);
}
}
const issuerIdToSend = finalIssuerId ? String(finalIssuerId) : "";
await onPaymentSuccess({
token: tokenResponse.id,
transactionAmount: amount,
paymentMethodId: paymentMethod.id,
installments: 1,
issuerId: issuerIdToSend,
payerEmail: formData.email
});
} catch (err: any) {
let msg = "Error procesando el pago.";
if (err.cause?.[0]?.code) {
const code = err.cause[0].code;
if (code === "205") msg = "Revisá el número de tarjeta.";
else if (['208', '209'].includes(code)) msg = "Revisá la fecha de vencimiento.";
else if (['212', '213', '214'].includes(code)) msg = "Documento inválido.";
else if (code === "221") msg = "Nombre y apellido requeridos.";
else if (code === "224") msg = "Código de seguridad (CVV) inválido.";
}
onPaymentError(msg);
} finally {
setLoading(false);
}
};
const [expMonth, expYear] = formData.cardExpiration.split('/');
// 🟢 VALIDACIÓN EN TIEMPO REAL
// Verifica que todos los campos tengan contenido válido antes de habilitar el botón
const isFormValid =
formData.cardNumber.replace(/\s/g, '').length >= 15 && // Tarjetas suelen ser 15 o 16 dígitos
formData.cardholderName.trim().length > 2 &&
formData.cardExpiration.length === 5 && // Formato MM/AA completo
formData.securityCode.length >= 3 && // CVV mínimo
formData.identificationNumber.trim().length >= 6 && // DNI razonable
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) && // Regex básico de email
paymentMethod !== null; // Tarjeta reconocida
return (
<div className="flex flex-col-reverse lg:grid lg:grid-cols-12 gap-6 md:gap-8 items-start animate-fade-in">
{/* --- COLUMNA IZQUIERDA: FORMULARIO --- */}
<form id="payment-form" onSubmit={handleSubmit} className="w-full lg:col-span-7 space-y-4 md:space-y-6 order-2 lg:order-1">
<div className="bg-[#161a22] p-5 md:p-8 rounded-[1.5rem] md:rounded-[2rem] border border-white/5 shadow-xl">
<h4 className="text-[10px] md:text-xs font-black uppercase tracking-widest text-gray-400 md:text-gray-500 mb-6 md:mb-8 border-b border-white/5 pb-4 flex items-center gap-2">
<span>💳</span> Datos de Facturación
</h4>
<div className="space-y-5 md:space-y-6">
{/* Número */}
<div>
<div className="flex justify-between items-end mb-1.5 md:mb-2 ml-1">
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 block">Número de Tarjeta</label>
{/* Logo en móvil: al lado del label para no tapar el número */}
{paymentMethod && (
<div className="md:hidden animate-fade-in">
<img src={paymentMethod.thumbnail} alt="card brand" className="h-4 object-contain" />
</div>
)}
</div>
<div className="relative group">
<input
type="text"
name="cardNumber"
placeholder="0000 0000 0000 0000"
maxLength={19}
className="w-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-white font-mono text-base md:text-lg tracking-widest outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/50 transition-all placeholder:text-gray-700 group-hover:border-white/20 text-center"
value={formData.cardNumber} onChange={handleInputChange} onPaste={handlePaste} required
/>
{/* Logo en desktop: dentro del input */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none transition-opacity duration-300 hidden md:block">
{paymentMethod ? (
<img src={paymentMethod.thumbnail} alt="card brand" className="h-6 object-contain" />
) : (
<div className="w-8 h-5 bg-white/5 rounded"></div>
)}
</div>
</div>
</div>
{/* Nombre */}
<div>
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 mb-1.5 md:mb-2 block ml-1">Nombre del Titular</label>
<input
type="text"
name="cardholderName"
placeholder="COMO FIGURA EN LA TARJETA"
className="w-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-white text-xs md:text-sm font-bold uppercase outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 hover:border-white/20"
value={formData.cardholderName} onChange={handleInputChange} required
/>
</div>
{/* Grid Vencimiento + CVV */}
<div className="grid grid-cols-2 gap-4 md:gap-6">
<div>
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 mb-1.5 md:mb-2 block ml-1">Vencimiento</label>
<input
type="text"
name="cardExpiration"
placeholder="MM/AA"
maxLength={5}
className="w-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-white text-center font-mono text-base md:text-lg outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 hover:border-white/20"
value={formData.cardExpiration} onChange={handleInputChange} onPaste={handlePaste} required
/>
</div>
<div>
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 mb-1.5 md:mb-2 block ml-1">CVV / CVC</label>
<div className="relative group">
<input
type="text"
name="securityCode"
placeholder="123"
maxLength={4}
className="w-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-white text-center font-mono text-base md:text-lg tracking-widest outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 group-hover:border-white/20"
value={formData.securityCode} onChange={handleInputChange} onPaste={handlePaste} required
onFocus={() => setIsCvvFocused(true)}
onBlur={() => setIsCvvFocused(false)}
/>
</div>
</div>
</div>
{/* DNI - FILA COMPLETA */}
<div>
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 mb-1.5 md:mb-2 block ml-1">Documento del Titular</label>
<div className="flex gap-2 md:gap-3">
<div className="relative w-24 md:w-32 shrink-0">
<select
name="identificationType"
className="w-full h-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-3 md:px-4 py-3.5 md:py-4 text-[10px] md:text-xs font-bold outline-none focus:border-blue-500 cursor-pointer appearance-none text-center hover:border-white/20 text-white"
value={formData.identificationType}
onChange={handleInputChange}
>
{docTypes.map(d => <option key={d.id} value={d.id} className="bg-gray-900">{d.name}</option>)}
</select>
<span className="absolute right-2 md:right-3 top-1/2 -translate-y-1/2 text-[8px] md:text-[10px] text-gray-500 pointer-events-none"></span>
</div>
<input
type="text"
name="identificationNumber"
placeholder="Nº Documento"
className="flex-1 min-w-0 bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-white text-sm outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 hover:border-white/20"
value={formData.identificationNumber}
onChange={handleInputChange}
required
/>
</div>
</div>
{/* EMAIL - FILA COMPLETA */}
<div>
<label className="text-[9px] md:text-[10px] font-bold uppercase text-gray-400 mb-1.5 md:mb-2 block ml-1">Email para el comprobante</label>
<input
type="email"
name="email"
placeholder="ejemplo@email.com"
className="w-full bg-[#0a0c10] border border-white/10 rounded-xl md:rounded-2xl px-4 md:px-5 py-3.5 md:py-4 text-sm text-white outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 hover:border-white/20"
value={formData.email}
onChange={handleInputChange}
required
/>
</div>
</div>
</div>
</form>
{/* --- COLUMNA DERECHA: VISUAL + BOTONES --- */}
<div className="w-full lg:col-span-5 order-1 lg:order-2 flex flex-col gap-6 md:gap-8 lg:sticky lg:top-8">
{/* Tarjeta Visual */}
<div className="perspective-[1000px] w-full max-w-sm mx-auto lg:max-w-none">
<div className="relative group cursor-default transform transition-transform hover:scale-[1.02] duration-500">
<div className="absolute -inset-1 bg-blue-600/20 rounded-2xl blur-lg opacity-10 group-hover:opacity-30 transition duration-700"></div>
<div className="scale-90 sm:scale-100 origin-center">
<VisualCreditCard
cardNumber={formData.cardNumber}
cardholderName={formData.cardholderName}
cardExpirationMonth={expMonth || ''}
cardExpirationYear={expYear || ''}
cvc={formData.securityCode}
isFlipped={isCvvFocused}
/>
</div>
</div>
<div className="mt-4 md:mt-6 text-center px-4">
<p className="text-gray-500 text-[10px] md:text-xs leading-relaxed">
Revisa que los datos coincidan exactamente con tu tarjeta física para evitar rechazos.
</p>
</div>
</div>
{/* BOTONES DE ACCIÓN */}
<div className="flex flex-col gap-3 w-full max-w-sm mx-auto lg:max-w-none">
<button
type="submit"
form="payment-form"
// 🟢 AHORA SE DESHABILITA SI !isFormValid
disabled={loading || !isFormValid}
className={`w-full text-white py-4 md:py-5 rounded-xl md:rounded-2xl font-black uppercase tracking-widest text-xs md:text-sm shadow-xl transition-all flex items-center justify-center gap-3
${loading || !isFormValid
? 'bg-gray-700 opacity-50 cursor-not-allowed grayscale'
: 'bg-green-600 hover:bg-green-500 shadow-green-900/20 hover:scale-[1.02] active:scale-95'
}`}
>
{loading ? <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div> : 'CONFIRMAR PAGO'}
</button>
<button
type="button"
onClick={onCancel}
disabled={loading}
className="w-full bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white py-3.5 md:py-4 rounded-xl md:rounded-2xl font-bold uppercase tracking-widest text-[10px] md:text-xs transition-all border border-white/5 hover:border-white/10"
>
Cancelar Operación
</button>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,694 @@
// src/components/LoginModal.tsx
import { useState } from 'react';
import { AuthService, type UserSession } from '../services/auth.service';
import { QRCodeSVG } from 'qrcode.react';
interface Props {
onSuccess: (user: UserSession) => void;
onClose: () => void;
}
// --- ICONOS SVG ---
const EyeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const EyeSlashIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
);
const CheckCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-green-500 flex-shrink-0">
<path fillRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
</svg>
);
const XCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-red-500 flex-shrink-0">
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z" clipRule="evenodd" />
</svg>
);
const NeutralCircleIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-gray-600 flex-shrink-0">
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm0 18a8.25 8.25 0 110-16.5 8.25 8.25 0 010 16.5z" clipRule="evenodd" />
</svg>
);
export default function LoginModal({ onSuccess, onClose }: Props) {
// Toggle entre Login y Registro
const [mode, setMode] = useState<'LOGIN' | 'REGISTER'>('LOGIN');
// Estados de Login
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// Para controlar si mostramos la opción de reenviar email no verificado
const [showResend, setShowResend] = useState(false);
const [unverifiedEmail, setUnverifiedEmail] = useState('');
// Estados de recuperación de clave
const [forgotEmail, setForgotEmail] = useState('');
// Estados de Registro
const [regData, setRegData] = useState({
firstName: '',
lastName: '',
email: '',
username: '',
phone: '',
password: '',
confirmPassword: ''
});
// Estados para Migración / Nueva Clave
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showNewPass, setShowNewPass] = useState(false);
const [showConfirmPass, setShowConfirmPass] = useState(false);
// Estados Generales
const [mfaCode, setMfaCode] = useState('');
const [qrData, setQrData] = useState<{ uri: string, secret: string } | null>(null);
const [step, setStep] = useState<'LOGIN' | 'MIGRATE' | 'MFA' | 'MFA_SETUP' | 'FORGOT' | 'MFA_PROMPT'>('LOGIN');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [tempUser, setTempUser] = useState<UserSession | null>(null);
// Validaciones
const activePassword = step === 'MIGRATE' ? newPassword : (mode === 'REGISTER' ? regData.password : '');
const activeConfirm = step === 'MIGRATE' ? confirmPassword : (mode === 'REGISTER' ? regData.confirmPassword : '');
const validations = {
length: activePassword.length >= 8,
upper: /[A-Z]/.test(activePassword),
number: /\d/.test(activePassword),
special: /[\W_]/.test(activePassword),
match: activePassword.length > 0 && activePassword === activeConfirm
};
const handleSafeClose = () => {
if (step === 'MFA_PROMPT' && tempUser) {
onSuccess(tempUser);
} else {
onClose();
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccessMessage('');
setShowResend(false);
setLoading(true);
try {
const res = await AuthService.login(username, password);
if (res.status === 'MIGRATION_REQUIRED') {
setStep('MIGRATE');
} else if (res.status === 'MFA_SETUP_REQUIRED') {
setQrData({ uri: res.qrUri, secret: res.secret });
setStep('MFA_SETUP');
} else if (res.status === 'TOTP_REQUIRED') {
setStep('MFA');
} else if (res.status === 'SUCCESS' && res.user) {
if (res.recommendMfa) {
setTempUser(res.user);
setStep('MFA_PROMPT');
} else {
onSuccess(res.user);
}
}
} catch (err: any) {
const msg = err.response?.data?.message || 'Error al iniciar sesión';
setError(msg);
if (msg.includes("verificar tu email") || msg === "EMAIL_NOT_VERIFIED") {
setShowResend(true);
setUnverifiedEmail(username);
}
} finally {
setLoading(false);
}
};
const handleForgot = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
setSuccessMessage('');
try {
const res = await AuthService.forgotPassword(forgotEmail);
setSuccessMessage(res.message);
} catch (err: any) {
setError(err.response?.data?.message || 'Error al procesar la solicitud.');
} finally {
setLoading(false);
}
};
const handleResendClick = async () => {
if (!unverifiedEmail) return;
setLoading(true);
setError('');
try {
await AuthService.resendVerification(unverifiedEmail);
setSuccessMessage("Correo reenviado. Revisa tu bandeja de entrada.");
setShowResend(false);
} catch (err: any) {
setError(err.response?.data?.message || "Error al reenviar.");
} finally {
setLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccessMessage('');
// Validación de Email básica
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(regData.email)) {
setError('Por favor, ingresa un correo electrónico válido.');
return;
}
// Validación de Username (longitud)
if (regData.username.length < 4) {
setError('El usuario debe tener al menos 4 caracteres.');
return;
}
// Validación de Contraseña (Requisitos)
if (!validations.length || !validations.upper || !validations.number || !validations.special || !validations.match) {
setError('Por favor, verifique los requisitos de la contraseña.');
return;
}
setLoading(true);
try {
await AuthService.register({
username: regData.username,
email: regData.email,
firstName: regData.firstName,
lastName: regData.lastName,
phoneNumber: regData.phone,
password: regData.password
});
setSuccessMessage('¡Cuenta creada con éxito! Revisa tu email para activarla.');
setRegData({ firstName: '', lastName: '', email: '', username: '', phone: '', password: '', confirmPassword: '' });
setTimeout(() => setMode('LOGIN'), 3000);
} catch (err: any) {
setError(err.response?.data?.message || 'Error al crear la cuenta.');
} finally {
setLoading(false);
}
};
const handleVerifyMFA = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const userToVerify = tempUser?.username || username;
const user = await AuthService.verifyMFA(userToVerify, mfaCode);
if (user) {
onSuccess(user);
}
} catch (err: any) {
setError('Código inválido o expirado');
} finally {
setLoading(false);
}
};
const handleMigrate = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validations.length || !validations.upper || !validations.number || !validations.special) {
setError('La contraseña no cumple con los requisitos de seguridad.');
return;
}
if (!validations.match) {
setError('Las contraseñas no coinciden.');
return;
}
setLoading(true);
try {
await AuthService.migratePassword(username, newPassword);
setSuccessMessage("¡Contraseña actualizada con éxito! Por favor, inicie sesión.");
setStep('LOGIN');
setPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err: any) {
setError('Error al actualizar contraseña. Intente nuevamente.');
} finally {
setLoading(false);
}
};
const handleSetupMfaClick = async () => {
setLoading(true);
try {
const data = await AuthService.initMFA();
setQrData({ uri: data.qrUri, secret: data.secret });
setStep('MFA_SETUP');
} catch (err) {
setError("No se pudo iniciar la configuración de seguridad.");
} finally {
setLoading(false);
}
};
const handleSkipMfa = () => {
if (tempUser) onSuccess(tempUser);
};
// --- COMPONENTE DE REQUISITOS (EN 2 COLUMNAS) ---
const RequirementItem = ({ isValid, text }: { isValid: boolean, text: string }) => {
const isNeutral = activePassword.length === 0;
return (
<li className={`flex items-center gap-1.5 text-[10px] transition-colors duration-300 ${isValid ? 'text-green-400' : isNeutral ? 'text-gray-500' : 'text-red-400'}`}>
{isNeutral ? <NeutralCircleIcon /> : isValid ? <CheckCircleIcon /> : <XCircleIcon />}
<span>{text}</span>
</li>
);
};
// COMPONENTE DE LISTA DE REQUISITOS (GRID)
const PasswordRequirements = () => (
<div className="bg-black/20 p-3 rounded-xl border border-white/5 mt-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500 mb-2">Seguridad de la clave:</p>
<ul className="grid grid-cols-2 gap-y-1 gap-x-2">
<RequirementItem isValid={validations.length} text="Mínimo 8 caracteres" />
<RequirementItem isValid={validations.upper} text="1 Mayúscula" />
<RequirementItem isValid={validations.number} text="1 Número" />
<RequirementItem isValid={validations.special} text="1 Símbolo (!@#)" />
<div className="col-span-2">
<RequirementItem isValid={validations.match} text="Las contraseñas coinciden" />
</div>
</ul>
</div>
);
const getTitle = () => {
if (step === 'MIGRATE') return 'Renovar Clave';
if (step === 'MFA' || step === 'MFA_SETUP') return 'Seguridad';
if (step === 'FORGOT') return 'Recuperar Clave';
if (step === 'MFA_PROMPT') return 'Protege tu Cuenta';
return mode === 'LOGIN' ? 'Ingresar' : 'Crear Cuenta';
};
return (
// CAMBIO: max-w-lg (más ancho) y overflow-hidden para evitar scrollbars feos
<div className="glass px-8 py-6 rounded-3xl border border-white/10 shadow-2xl max-w-lg w-full animate-fade-in-up relative overflow-hidden">
{loading && <div className="absolute top-0 left-0 w-full h-1 bg-blue-500 animate-pulse"></div>}
<button
onClick={handleSafeClose}
className="absolute top-5 right-5 text-gray-500 hover:text-white font-bold uppercase text-[20px] tracking-widest flex items-center gap-2 transition-colors z-50"
>
</button>
<div className="text-center mb-6">
<h2 className="text-3xl font-black uppercase tracking-tighter mb-2">{getTitle()}</h2>
{step === 'LOGIN' && (
<div className="inline-flex bg-white/5 rounded-xl p-1 border border-white/5">
<button
type="button"
onClick={() => { setMode('LOGIN'); setError(''); setSuccessMessage(''); setShowResend(false); }}
className={`px-6 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all ${mode === 'LOGIN' ? 'bg-blue-600 text-white shadow-lg' : 'text-gray-500 hover:text-white'}`}
>
Entrar
</button>
<button
type="button"
onClick={() => { setMode('REGISTER'); setError(''); setSuccessMessage(''); setShowResend(false); }}
className={`px-6 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all ${mode === 'REGISTER' ? 'bg-blue-600 text-white shadow-lg' : 'text-gray-500 hover:text-white'}`}
>
Registrarse
</button>
</div>
)}
</div>
{error && (
<div className="bg-red-500/20 text-red-300 p-3 rounded-xl mb-6 text-xs font-bold border border-red-500/20 flex items-center gap-2 animate-shake">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5 flex-shrink-0">
<path fillRule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clipRule="evenodd" />
</svg>
{error}
</div>
)}
{successMessage && (
<div className="bg-green-500/20 text-green-300 p-3 rounded-xl mb-6 text-xs font-bold border border-green-500/20 flex items-center gap-2 animate-fade-in-up">
<CheckCircleIcon />
{successMessage}
</div>
)}
{/* --- FORMULARIO LOGIN --- */}
{step === 'LOGIN' && mode === 'LOGIN' && (
<form onSubmit={handleLogin} className="space-y-5">
<div>
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-1.5 ml-1">Usuario</label>
<input
required
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-white outline-none focus:border-blue-500 transition-all placeholder:text-gray-600 focus:bg-white/10"
placeholder="Tu nombre de usuario"
/>
</div>
<div>
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-1.5 ml-1">Contraseña</label>
<input
required
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-white outline-none focus:border-blue-500 transition-all placeholder:text-gray-600 focus:bg-white/10"
placeholder="••••••••"
/>
<div className="text-right mt-2">
<button
type="button"
onClick={() => { setStep('FORGOT'); setError(''); setSuccessMessage(''); }}
className="text-[10px] text-gray-500 hover:text-blue-400 font-bold uppercase tracking-widest transition-colors"
>
¿Olvidaste tu contraseña?
</button>
</div>
</div>
<button disabled={loading} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-bold uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:scale-100">
{loading ? 'Verificando...' : 'Entrar'}
</button>
{showResend && (
<div className="bg-amber-500/10 p-4 rounded-xl border border-amber-500/20 text-center animate-fade-in">
<p className="text-[10px] text-amber-200 mb-3">
¿No recibiste el correo de activación?
</p>
<button
type="button"
onClick={handleResendClick}
disabled={loading}
className="bg-amber-500/20 hover:bg-amber-500/30 text-amber-400 text-xs font-bold py-2 px-4 rounded-lg uppercase tracking-widest transition-all w-full"
>
{loading ? 'Enviando...' : 'Reenviar Email'}
</button>
</div>
)}
<p className="text-[10px] text-gray-500 text-center italic mt-2">
Plataforma Motores V2 Acceso Unificado
</p>
</form>
)}
{/* --- FORMULARIO OLVIDÉ CLAVE --- */}
{step === 'FORGOT' && (
<form onSubmit={handleForgot} className="space-y-5">
<div className="bg-blue-600/10 p-5 rounded-2xl border border-blue-500/20 mb-2">
<p className="text-xs text-blue-200 font-medium leading-relaxed text-center">
Ingresa tu email o usuario. Te enviaremos un enlace para restablecer tu clave.
</p>
</div>
<div>
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-1.5 ml-1">Email / Usuario</label>
<input required type="text" value={forgotEmail} onChange={e => setForgotEmail(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-white outline-none focus:border-blue-500 transition-all placeholder:text-gray-600"
placeholder="ejemplo@email.com" />
</div>
<button disabled={loading} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-bold uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 disabled:opacity-50">
{loading ? 'Enviando...' : 'Enviar Enlace'}
</button>
<button type="button" onClick={() => { setStep('LOGIN'); setError(''); setSuccessMessage(''); }} className="w-full text-[10px] text-gray-500 font-bold uppercase tracking-widest hover:text-white transition-all mt-4">
Volver al Login
</button>
</form>
)}
{/* --- FORMULARIO REGISTRO --- */}
{step === 'LOGIN' && mode === 'REGISTER' && (
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-1">Nombre</label>
<input required type="text" value={regData.firstName} onChange={e => setRegData({ ...regData, firstName: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-sm outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-1">Apellido</label>
<input required type="text" value={regData.lastName} onChange={e => setRegData({ ...regData, lastName: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-sm outline-none focus:border-blue-500" />
</div>
</div>
<div>
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-1">Email</label>
<input required type="email" value={regData.email} onChange={e => setRegData({ ...regData, email: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-sm outline-none focus:border-blue-500" placeholder="email@ejemplo.com" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-1">Usuario</label>
<input
required
type="text"
value={regData.username}
onChange={e => {
const cleanValue = e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '');
setRegData({ ...regData, username: cleanValue });
}}
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-sm outline-none focus:border-blue-500 placeholder:text-gray-700"
placeholder="solo letras y números"
/>
<p className="text-[8px] text-gray-600 mt-1 ml-1">
* Sin espacios.
</p>
</div>
<div>
<label className="text-[9px] font-black uppercase tracking-widest text-gray-500 block mb-1">Teléfono</label>
<input required type="tel" value={regData.phone} onChange={e => setRegData({ ...regData, phone: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-white text-sm outline-none focus:border-blue-500" />
</div>
</div>
{/* PASSWORD Y CONFIRMACIÓN */}
<div className="space-y-3 pt-2 border-t border-white/5">
<div className="relative">
<input required type={showNewPass ? "text" : "password"} value={regData.password} onChange={e => setRegData({ ...regData, password: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-3 pr-10 py-2 text-white text-sm outline-none focus:border-blue-500 placeholder:text-gray-600" placeholder="Contraseña" />
<button type="button" onClick={() => setShowNewPass(!showNewPass)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
{showNewPass ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
<div className="relative">
<input required type={showConfirmPass ? "text" : "password"} value={regData.confirmPassword} onChange={e => setRegData({ ...regData, confirmPassword: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-3 pr-10 py-2 text-white text-sm outline-none focus:border-blue-500 placeholder:text-gray-600" placeholder="Confirmar Contraseña" />
<button type="button" onClick={() => setShowConfirmPass(!showConfirmPass)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
{showConfirmPass ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
{/* LISTA DE REQUISITOS EN GRID */}
<PasswordRequirements />
</div>
<button disabled={loading} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-bold uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 hover:scale-[1.02] active:scale-95 disabled:opacity-50">
{loading ? 'Registrando...' : 'Crear Cuenta'}
</button>
</form>
)}
{/* --- MFA SETUP --- */}
{step === 'MFA_SETUP' && qrData && (
<form onSubmit={handleVerifyMFA} className="space-y-6">
<div className="bg-blue-600/10 p-6 rounded-[2rem] border border-blue-500/20 text-center">
<p className="text-xs text-blue-300 font-bold uppercase tracking-widest mb-4">Configuración de Seguridad</p>
<div className="bg-white p-3 rounded-2xl inline-block mb-4 shadow-xl">
<QRCodeSVG value={qrData.uri} size={160} />
</div>
<p className="text-[10px] text-gray-400 mb-2 max-w-[200px] mx-auto">Escanea con Google Authenticator o Authy</p>
<div className="bg-black/30 p-2 rounded-lg border border-white/5">
<code className="text-[12px] text-blue-400 break-all font-mono tracking-widest">{qrData.secret}</code>
</div>
</div>
<div>
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-2 text-center">Código de verificación</label>
<input
required
type="text"
maxLength={6}
placeholder="000000"
value={mfaCode}
onChange={e => setMfaCode(e.target.value.replace(/\D/g, ''))}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-4 text-center text-4xl font-black tracking-[0.5em] text-white outline-none focus:border-blue-500 transition-all placeholder:opacity-10"
/>
</div>
<button disabled={loading} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-bold uppercase transition-all shadow-lg shadow-blue-600/20">
{loading ? 'Activando...' : 'Activar y Entrar'}
</button>
</form>
)}
{/* --- MFA LOGIN --- */}
{step === 'MFA' && (
<form onSubmit={handleVerifyMFA} className="space-y-6">
<div className="bg-blue-600/10 p-6 rounded-[2rem] border border-blue-500/20 text-center">
<div className="w-16 h-16 bg-blue-500/20 rounded-2xl flex items-center justify-center mx-auto mb-4 text-3xl">🛡</div>
<p className="text-xs text-blue-300 font-bold uppercase tracking-widest">Autenticación de 2 Factores</p>
<p className="text-[10px] text-blue-200/60 mt-2">
Tu cuenta está protegida. Ingresa el código temporal de tu aplicación.
</p>
</div>
<div>
<input
required
type="text"
maxLength={6}
placeholder="000 000"
value={mfaCode}
onChange={e => setMfaCode(e.target.value.replace(/\D/g, ''))}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-4 text-center text-3xl font-black tracking-[0.2em] text-white outline-none focus:border-blue-500 transition-all placeholder:opacity-10"
/>
</div>
<button disabled={loading} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-bold uppercase transition-all shadow-lg shadow-blue-600/20">
{loading ? 'Verificando...' : 'Confirmar Acceso'}
</button>
<button type="button" onClick={() => { setStep('LOGIN'); setPassword(''); }} className="w-full text-[10px] text-gray-500 font-bold uppercase tracking-widest hover:text-white transition-all">
Volver al login
</button>
</form>
)}
{/* --- MIGRACIÓN PASSWORD --- */}
{step === 'MIGRATE' && (
<form onSubmit={handleMigrate} className="space-y-5">
<div className="bg-amber-500/10 p-5 rounded-[2rem] border border-amber-500/20 mb-2">
<p className="text-xs text-amber-200 font-medium leading-relaxed">
<strong className="block text-amber-400 mb-1 uppercase text-[10px] tracking-widest">Actualización Requerida</strong>
Bienvenido a la nueva plataforma. Por seguridad, detectamos que tu usuario proviene del sistema anterior.
</p>
</div>
<div className="space-y-4">
{/* Nueva Contraseña */}
<div>
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-1.5 ml-1">Nueva Contraseña</label>
<div className="relative">
<input
required
type={showNewPass ? "text" : "password"}
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-4 pr-12 py-3.5 text-white outline-none focus:border-green-500 transition-all placeholder:text-gray-600"
placeholder="Escribe tu nueva clave"
/>
<button
type="button"
onClick={() => setShowNewPass(!showNewPass)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"
>
{showNewPass ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
</div>
{/* Repetir Contraseña */}
<div>
<label className="text-xs font-black uppercase tracking-widest text-gray-500 block mb-1.5 ml-1">Repetir Contraseña</label>
<div className="relative">
<input
required
type={showConfirmPass ? "text" : "password"}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className={`w-full bg-white/5 border rounded-xl pl-4 pr-12 py-3.5 text-white outline-none transition-all placeholder:text-gray-600 ${confirmPassword && confirmPassword !== newPassword ? 'border-red-500/50' : 'border-white/10 focus:border-green-500'
}`}
placeholder="Confirma tu nueva clave"
/>
<button
type="button"
onClick={() => setShowConfirmPass(!showConfirmPass)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"
>
{showConfirmPass ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
</div>
{/* Requisitos visuales reutilizados */}
<PasswordRequirements />
</div>
<button disabled={loading} className="w-full bg-green-600 hover:bg-green-500 text-white py-4 rounded-xl font-bold uppercase tracking-widest transition-all shadow-lg shadow-green-600/20 mt-4 hover:scale-[1.02] active:scale-95">
{loading ? 'Actualizando...' : 'Establecer Nueva Clave'}
</button>
</form>
)}
{/* --- MFA PROMPT --- */}
{step === 'MFA_PROMPT' && (
<div className="space-y-6 text-center animate-fade-in px-2">
<div className="relative w-20 h-20 mx-auto mb-6">
<div className="absolute inset-0 bg-blue-500 rounded-full blur-xl opacity-20 animate-pulse"></div>
<div className="relative bg-gradient-to-br from-blue-600 to-cyan-400 w-full h-full rounded-2xl flex items-center justify-center text-4xl shadow-xl border border-white/10 rotate-3 hover:rotate-6 transition-transform">
🛡
</div>
</div>
<div>
<h3 className="text-xl font-black text-white uppercase tracking-tight mb-2">¡Protege tu Cuenta!</h3>
<p className="text-xs text-gray-400 leading-relaxed font-medium">
Detectamos que no tienes activada la autenticación de dos pasos.
<br /><br />
<span className="text-blue-300">Actívala ahora para evitar accesos no autorizados y asegurar tus publicaciones.</span>
</p>
</div>
<div className="space-y-3 pt-2">
<button
onClick={handleSetupMfaClick}
className="w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-blue-600/20 hover:scale-[1.02] active:scale-95 flex items-center justify-center gap-2"
>
<span>🚀</span> Activar Protección
</button>
<button
onClick={handleSkipMfa}
className="w-full text-gray-500 hover:text-white py-3 font-bold uppercase tracking-widest text-[10px] transition-all hover:bg-white/5 rounded-xl"
>
Recordar más tarde
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
// src/components/MercadoPagoLogo.tsx
export default function MercadoPagoLogo({ className = "h-8" }: { className?: string }) {
return (
<svg
className={className}
id="logos"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1048.82 425.2"
aria-label="Mercado Pago"
>
<defs>
<style>{`.cls-1{fill:#0a0080;}.cls-1,.cls-2,.cls-3{stroke-width:0px;}.cls-2{fill:#fff;}.cls-3{fill:#00bcff;}`}</style>
</defs>
<path className="cls-3" d="m274.38,116.94c-77.83,0-140.91,40.36-140.91,90.15s63.09,94.05,140.91,94.05,140.91-44.27,140.91-94.05-63.09-90.15-140.91-90.15Z" />
<path className="cls-2" d="m228.53,179.22c-.07.14-1.45,1.56-.55,2.71,2.18,2.78,8.91,4.38,15.72,2.85,4.05-.91,9.25-5.04,14.28-9.03,5.45-4.33,10.86-8.67,16.3-10.39,5.76-1.83,9.45-1.05,11.89-.31,2.67.8,5.82,2.56,10.84,6.32,9.45,7.1,47.43,40.26,54,45.99,5.28-2.39,30.47-12.56,62.39-19.6-2.78-17.02-13.01-33.25-28.72-45.99-21.89,9.19-50.42,14.7-76.58,1.93-.13-.05-14.29-6.75-28.25-6.42-20.75.48-29.74,9.46-39.25,18.97l-12.05,12.99Z" />
<path className="cls-2" d="m349.44,220.97c-.45-.4-44.67-39.09-54.69-46.62-5.8-4.35-9.02-5.46-12.41-5.89-1.76-.23-4.2.1-5.9.57-4.66,1.27-10.75,5.34-16.16,9.63-5.6,4.46-10.88,8.66-15.79,9.76-6.26,1.4-13.91-.25-17.4-2.61-1.41-.95-2.41-2.05-2.89-3.16-1.29-2.99,1.09-5.38,1.48-5.78l12.2-13.2c1.42-1.41,2.85-2.83,4.31-4.23-3.94.51-7.58,1.52-11.12,2.5-4.42,1.24-8.68,2.42-12.98,2.42-1.8,0-11.42-1.58-13.25-2.07-11.05-3.02-23.56-5.97-38.04-12.73-17.35,12.91-28.65,28.77-32,46.56,2.49.66,9.02,2.15,10.71,2.52,39.26,8.73,51.49,17.72,53.71,19.6,2.4-2.67,5.87-4.36,9.73-4.36,4.35,0,8.26,2.19,10.64,5.56,2.25-1.78,5.35-3.3,9.36-3.29,1.82,0,3.71.34,5.62.98,4.43,1.52,6.72,4.47,7.9,7.14,1.48-.67,3.31-1.17,5.46-1.16,2.12,0,4.32.48,6.53,1.44,7.24,3.11,8.36,10.22,7.71,15.58.52-.06,1.04-.08,1.56-.08,8.58,0,15.56,6.98,15.56,15.57,0,2.66-.68,5.16-1.86,7.35,2.34,1.31,8.29,4.28,13.52,3.62,4.17-.53,5.76-1.95,6.32-2.76.39-.55.8-1.2.42-1.66l-11.08-12.3s-1.82-1.73-1.22-2.39c.62-.68,1.75.3,2.55.96,5.64,4.71,12.52,11.81,12.52,11.81.12.08.57.98,3.12,1.43,2.19.39,6.07.17,8.76-2.04.67-.56,1.35-1.25,1.93-1.97-.05.04-.09.08-.13.1,2.84-3.63-.32-7.29-.32-7.29l-12.93-14.52s-1.85-1.71-1.22-2.4c.56-.6,1.75.3,2.56.98,4.09,3.42,9.88,9.23,15.42,14.66,1.09.79,5.96,3.8,12.41-.43,3.92-2.57,4.7-5.73,4.59-8.1-.27-3.15-2.73-5.4-2.73-5.4l-17.66-17.76s-1.87-1.59-1.21-2.4c.54-.68,1.75.3,2.55.96,5.62,4.71,20.86,18.68,20.86,18.68.22.15,5.48,3.9,11.99-.24,2.33-1.49,3.81-3.73,3.94-6.34.22-4.52-2.96-7.2-2.96-7.2Z" />
<path className="cls-2" d="m263.76,243.48c-2.74-.03-5.74,1.6-6.13,1.36-.22-.14.17-1.24.42-1.88.27-.63,3.87-11.48-4.92-15.25-6.73-2.89-10.85.36-12.26,1.83-.37.38-.54.35-.58-.13-.14-1.96-1.01-7.24-6.82-9.02-8.3-2.54-13.64,3.25-14.99,5.35-.61-4.73-4.61-8.4-9.5-8.41-5.32,0-9.64,4.3-9.65,9.63,0,5.32,4.31,9.64,9.64,9.64,2.59,0,4.93-1.03,6.66-2.69.06.05.08.14.05.32-.41,2.39-1.15,11.04,7.92,14.57,3.64,1.41,6.73.36,9.29-1.43.76-.54.89-.31.78.41-.33,2.23.09,6.99,6.77,9.7,5.08,2.07,8.09-.04,10.07-1.87.86-.78,1.09-.65,1.14.56.24,6.44,5.59,11.56,12.09,11.57,6.7,0,12.13-5.41,12.13-12.1,0-6.7-5.42-12.06-12.12-12.13Z" />
<path className="cls-1" d="m274.35,113.21c-79.31,0-143.6,42.18-143.6,93.92,0,1.34-.02,5.03-.02,5.5,0,54.9,56.19,99.35,143.6,99.35s143.61-44.45,143.61-99.34v-5.51c0-51.74-64.29-93.92-143.59-93.92Zm137.12,83.51c-31.21,6.94-54.49,17.01-60.32,19.61-13.62-11.89-45.1-39.26-53.63-45.66-4.87-3.67-8.2-5.6-11.12-6.47-1.31-.4-3.12-.85-5.45-.85-2.17,0-4.5.39-6.93,1.17-5.51,1.75-11,6.11-16.31,10.33l-.27.22c-4.95,3.93-10.06,8-13.93,8.86-1.69.38-3.43.58-5.16.58-4.34,0-8.23-1.26-9.69-3.12-.24-.31-.08-.81.48-1.52l.07-.1,11.99-12.91c9.39-9.39,18.25-18.25,38.66-18.72.34-.01.68-.02,1.02-.02,12.7.01,25.4,5.69,26.83,6.36,11.91,5.81,24.21,8.76,36.56,8.77,12.85,0,26.11-3.17,40.05-9.58,14.56,12.24,24.21,26.99,27.15,43.06Zm-137.1-77.97c42.1,0,79.76,12.07,105.09,31.07-12.24,5.3-23.91,7.97-35.17,7.97-11.52-.01-23.03-2.78-34.21-8.23-.59-.28-14.61-6.89-29.2-6.9-.38,0-.77,0-1.15.01-17.14.4-26.8,6.49-33.29,11.82-6.31.16-11.76,1.68-16.61,3.03-4.33,1.2-8.06,2.24-11.7,2.24-1.5,0-4.2-.14-4.44-.15-4.18-.13-25.18-5.28-41.95-11.61,25.27-17.96,61.89-29.26,102.64-29.26Zm-107.61,33.01c17.51,7.16,38.76,12.7,45.48,13.13,1.87.12,3.87.34,5.87.34,4.46,0,8.91-1.25,13.21-2.45,2.54-.71,5.35-1.49,8.3-2.05-.79.77-1.58,1.56-2.37,2.35l-12.17,13.17c-.96.97-3.04,3.55-1.67,6.73.54,1.28,1.65,2.51,3.2,3.55,2.9,1.95,8.1,3.28,12.92,3.28,1.83,0,3.57-.18,5.15-.54,5.11-1.14,10.46-5.41,16.13-9.92,4.52-3.59,10.94-8.15,15.86-9.49,1.38-.37,3.06-.61,4.42-.61.41,0,.79.02,1.14.07,3.24.41,6.38,1.51,11.99,5.72,10,7.51,54.22,46.2,54.65,46.58.03.02,2.85,2.46,2.65,6.5-.11,2.26-1.36,4.26-3.54,5.65-1.89,1.2-3.83,1.81-5.8,1.81-2.96,0-4.99-1.39-5.13-1.48-.16-.13-15.31-14.03-20.89-18.7-.89-.74-1.75-1.4-2.62-1.4-.47,0-.88.2-1.16.55-.88,1.08.1,2.58,1.26,3.56l17.7,17.8s2.21,2.06,2.45,4.79c.14,2.95-1.27,5.42-4.2,7.34-2.09,1.38-4.2,2.07-6.27,2.07-2.72,0-4.63-1.24-5.05-1.53l-2.54-2.5c-4.64-4.57-9.43-9.29-12.94-12.21-.86-.71-1.77-1.37-2.64-1.37-.43,0-.82.16-1.12.48-.4.44-.68,1.24.32,2.57.4.55.89,1,.89,1l12.91,14.51c.1.13,2.66,3.17.29,6.19l-.46.58c-.39.42-.8.82-1.2,1.16-2.2,1.81-5.14,2-6.31,2-.63,0-1.22-.05-1.75-.15-1.27-.23-2.13-.58-2.55-1.07l-.16-.16c-.7-.73-7.21-7.38-12.6-11.87-.71-.6-1.6-1.34-2.51-1.34-.45,0-.85.18-1.17.52-1.06,1.17.54,2.91,1.22,3.55l11.01,12.15c-.01.11-.15.36-.41.74-.4.55-1.73,1.88-5.73,2.38-.48.06-.98.09-1.46.09-4.12,0-8.52-2-10.79-3.2,1.03-2.18,1.57-4.58,1.57-6.98,0-9.07-7.36-16.44-16.43-16.45-.19,0-.4,0-.59.01.29-4.14-.29-11.98-8.34-15.43-2.32-1-4.63-1.52-6.87-1.52-1.76,0-3.45.3-5.04.91-1.67-3.24-4.44-5.6-8.04-6.83-2-.69-3.98-1.04-5.9-1.04-3.35,0-6.44.99-9.19,2.94-2.64-3.28-6.62-5.22-10.81-5.22-3.67,0-7.2,1.47-9.81,4.06-3.43-2.62-17.03-11.26-53.44-19.53-1.74-.39-5.69-1.52-8.17-2.25,3.41-16.34,13.8-31.27,29.2-43.52Zm67.54,94.78l-.39-.35h-.4c-.32,0-.66.13-1.11.45-1.86,1.31-3.63,1.94-5.44,1.94-1,0-2.02-.2-3.04-.59-8.44-3.29-7.78-11.25-7.36-13.65.06-.49-.06-.86-.37-1.12l-.6-.49-.56.53c-1.65,1.59-3.8,2.45-6.06,2.45-4.83,0-8.77-3.93-8.76-8.77,0-4.83,3.94-8.76,8.78-8.75,4.37,0,8.09,3.28,8.64,7.65l.3,2.35,1.29-1.99c.14-.23,3.69-5.59,10.2-5.58,1.24,0,2.52.2,3.81.6,5.19,1.58,6.07,6.29,6.2,8.25.09,1.14.91,1.2,1.06,1.2.45,0,.78-.28,1.01-.53.98-1.02,3.11-2.72,6.45-2.72,1.53,0,3.15.37,4.83,1.09,8.25,3.54,4.51,14.02,4.47,14.13-.71,1.74-.74,2.5-.07,2.95l.32.15h.24c.37,0,.83-.16,1.6-.42,1.12-.39,2.81-.97,4.4-.97h0c6.21.07,11.26,5.13,11.26,11.26,0,6.2-5.06,11.24-11.27,11.24-6.07,0-11.01-4.73-11.23-10.74-.02-.52-.07-1.88-1.23-1.88-.47,0-.89.29-1.36.72-1.34,1.24-3.04,2.49-5.52,2.49-1.13,0-2.35-.26-3.64-.79-6.41-2.6-6.5-7-6.24-8.77.07-.47.09-.96-.23-1.35Zm40.07,48.88c-76.26,0-138.08-39.55-138.08-88.33,0-1.96.14-3.91.33-5.84.61.15,6.67,1.59,7.92,1.88,37.19,8.26,49.48,16.85,51.56,18.48-.7,1.69-1.07,3.51-1.07,5.35,0,7.69,6.25,13.95,13.93,13.95.86,0,1.72-.08,2.56-.24,1.16,5.66,4.86,9.95,10.51,12.15,1.65.63,3.32.96,4.97.96,1.06,0,2.13-.13,3.17-.39,1.05,2.65,3.39,5.96,8.65,8.09,1.84.74,3.68,1.13,5.47,1.13,1.46,0,2.89-.26,4.25-.76,2.52,6.13,8.51,10.2,15.19,10.2,4.43,0,8.68-1.8,11.78-4.99,2.65,1.48,8.25,4.15,13.91,4.16.73,0,1.41-.05,2.11-.13,5.62-.71,8.23-2.91,9.43-4.62.22-.3.41-.62.58-.95,1.32.38,2.78.69,4.46.7,3.07,0,6.01-1.05,8.99-3.21,2.93-2.11,5.01-5.14,5.31-7.72,0-.03,0-.07.01-.11.99.2,2,.3,3.01.3,3.16,0,6.27-.98,9.24-2.93,5.73-3.75,6.72-8.66,6.63-11.87,1.01.21,2.03.32,3.05.32,2.96,0,5.88-.89,8.65-2.66,3.55-2.27,5.69-5.75,6.02-9.79.21-2.75-.47-5.53-1.91-7.91,9.58-4.13,31.48-12.12,57.27-17.93.11,1.46.17,2.93.17,4.41,0,48.78-61.82,88.33-138.07,88.33Z" />
<g>
<path className="cls-1" d="m910.26,142.12c-5.21-6.54-13.13-9.8-23.75-9.8s-18.53,3.27-23.74,9.8c-5.22,6.53-7.83,14.25-7.83,23.16s2.61,16.81,7.83,23.26c5.21,6.43,13.13,9.65,23.74,9.65s18.54-3.22,23.75-9.65c5.22-6.45,7.82-14.19,7.82-23.26s-2.6-16.63-7.82-23.16Zm-12.92,37.48c-2.53,3.35-6.15,5.04-10.89,5.04s-8.36-1.69-10.91-5.04c-2.55-3.35-3.82-8.13-3.82-14.32s1.27-10.95,3.82-14.29c2.55-3.34,6.19-5.01,10.91-5.01s8.35,1.67,10.89,5.01c2.53,3.34,3.8,8.11,3.8,14.29s-1.27,10.97-3.8,14.32Z" />
<path className="cls-1" d="m776.98,136.65c-5.29-2.68-11.34-4.03-18.15-4.03-10.47,0-17.86,2.73-22.17,8.18-2.71,3.49-4.22,7.95-4.58,13.37h15.65c.38-2.4,1.15-4.29,2.31-5.69,1.61-1.89,4.36-2.84,8.23-2.84,3.46,0,6.08.48,7.88,1.45,1.78.96,2.68,2.72,2.68,5.26,0,2.09-1.16,3.61-3.49,4.61-1.3.57-3.46,1.04-6.48,1.42l-5.55.68c-6.3.8-11.08,2.13-14.32,3.99-5.92,3.41-8.88,8.93-8.88,16.55,0,5.87,1.83,10.41,5.52,13.61,3.67,3.21,8.34,4.55,13.98,4.81,35.37,1.59,34.98-18.64,35.3-22.84v-23.27c0-7.47-2.65-12.55-7.93-15.25Zm-8.22,35.32c-.11,5.42-1.66,9.15-4.64,11.2-2.99,2.05-6.24,3.07-9.78,3.07-2.24,0-4.14-.63-5.7-1.85-1.56-1.23-2.34-3.24-2.34-6.01,0-3.1,1.28-5.39,3.83-6.88,1.51-.87,3.99-1.61,7.45-2.2l3.69-.69c1.84-.35,3.28-.73,4.34-1.13,1.07-.38,2.1-.9,3.13-1.55v6.03Z" />
<path className="cls-1" d="m696.32,146.48c4.05,0,7.01,1.25,8.94,3.75,1.31,1.84,2.13,3.93,2.45,6.24h17.45c-.95-8.81-4.03-14.95-9.24-18.43-5.22-3.47-11.9-5.21-20.07-5.21-9.61,0-17.15,2.95-22.61,8.84-5.46,5.9-8.2,14.15-8.2,24.75,0,9.38,2.47,17.04,7.42,22.93,4.95,5.89,12.66,8.84,23.14,8.84s18.42-3.53,23.76-10.61c3.35-4.38,5.23-9.03,5.62-13.94h-17.39c-.36,3.25-1.37,5.9-3.06,7.94-1.67,2.03-4.5,3.06-8.5,3.06-5.63,0-9.47-2.57-11.5-7.72-1.12-2.75-1.69-6.38-1.69-10.91s.57-8.54,1.69-11.43c2.12-5.39,6.05-8.1,11.79-8.1Z" />
<path className="cls-1" d="m660.36,132.83c-35.85,0-33.72,31.73-33.72,31.73v32.24h16.27v-30.23c0-4.96.63-8.62,1.86-11.01,2.23-4.23,6.6-6.35,13.1-6.35.49,0,1.13.03,1.92.07.79.04,1.69.11,2.73.23v-16.55c-.72-.05-1.19-.07-1.39-.1-.21-.02-.46-.03-.77-.03Z" />
<path className="cls-1" d="m613.6,144.85c-2.81-4.16-6.38-7.21-10.68-9.15-4.31-1.92-9.15-2.88-14.52-2.88-9.06,0-16.42,2.85-22.1,8.56-5.67,5.72-8.52,13.92-8.52,24.63,0,11.43,3.15,19.67,9.44,24.74,6.28,5.06,13.54,7.61,21.76,7.61,9.96,0,17.71-3.01,23.24-9.02,2.99-3.16,4.86-6.29,5.65-9.38h-17.26c-.68.98-1.41,1.81-2.22,2.46-2.3,1.89-5.42,2.47-9.09,2.47-3.47,0-6.2-.52-8.66-2.07-4.06-2.5-6.35-6.72-6.59-12.91h45.01c.06-5.34-.11-9.43-.54-12.27-.74-4.84-2.4-9.1-4.92-12.77Zm-39.15,14.38c.58-4.02,2.03-7.2,4.3-9.56,2.29-2.35,5.5-3.53,9.65-3.53,3.81,0,7.01,1.11,9.59,3.34,2.57,2.22,4,5.48,4.3,9.75h-27.83Z" />
<path className="cls-1" d="m525.46,132.61c-7.55,0-14.08,3.31-18.47,8.61-4.17-5.3-10.59-8.61-18.48-8.61-15.89,0-26.13,11.67-26.13,27.12v37.06h14.87v-37.41c0-6.83,4.62-11.55,11.27-11.55,9.8,0,10.81,8.13,10.81,11.55v37.41h14.87v-37.41c0-6.83,4.73-11.55,11.26-11.55,9.8,0,10.93,8.13,10.93,11.55v37.41h14.85v-37.06c0-15.93-9.56-27.12-25.79-27.12Z" />
<path className="cls-1" d="m833.71,124.7l-.02,17.43c-1.81-2.92-4.17-5.2-7.08-6.83-2.9-1.64-6.23-2.47-9.98-2.47-8.13,0-14.6,3.03-19.46,9.06-4.86,6.05-7.29,14.77-7.29,25.31,0,9.15,2.47,16.65,7.4,22.49,4.93,5.83,14.6,8.39,23.19,8.39,29.95,0,29.6-25.68,29.6-25.68v-59.11s-16.37-1.75-16.37,11.41Zm-3.13,55.04c-2.37,3.4-5.86,5.1-10.43,5.1s-7.98-1.72-10.23-5.13c-2.25-3.43-3.37-8.41-3.37-14.11,0-5.3,1.1-9.72,3.31-13.29,2.21-3.57,5.67-5.36,10.4-5.36,3.1,0,5.82.98,8.17,2.94,3.81,3.25,5.73,9.09,5.73,16.64,0,5.4-1.2,9.81-3.58,13.21Z" />
</g>
<path className="cls-1" d="m496.75,221.66c-13.4-.63-20.16,2.56-24.57,5.93-6.09,4.65-9.8,11.53-9.8,22.52v56.51h7.88c2.11,0,4.22-.73,5.77-2.16,1.74-1.6,2.61-3.56,2.61-5.86v-21.12c1.92,3.31,4.45,5.74,7.65,7.32,3.03,1.41,6.53,2.12,10.51,2.12,7.49,0,13.64-2.98,18.41-8.97,4.78-6.15,7.17-14.15,7.17-24.06s-2.26-16.97-7.68-23.57c-4.38-5.34-11.04-8.35-17.94-8.66Zm5.55,46.38c-2.39,3.31-5.66,4.96-9.8,4.96-4.46,0-7.89-1.64-10.28-4.96-2.39-2.99-3.59-7.45-3.59-13.45,0-6.43,1.11-11.16,3.34-14.15,2.4-3.29,5.75-4.96,10.05-4.96s7.89,1.66,10.28,4.96c2.4,3.31,3.59,8.02,3.59,14.15,0,5.68-1.19,10.14-3.59,13.45Z" />
<path className="cls-1" d="m636.47,227.49c-5.53-4.19-11.18-6.38-20.89-6.12-9.86.27-17.03,3.03-21.49,9.07-4.46,6.05-6.68,13.95-6.68,23.68,0,8.33,1.68,15.04,5.04,20.17,3.37,5.1,7.4,8.6,12.1,10.47,4.68,1.89,9.42,2.28,14.2,1.19,4.77-1.11,8.57-3.84,11.39-8.24v3.99c-.32,5.03-1.53,8.8-3.63,11.32-2.13,2.5-4.47,4.04-7.06,4.59-2.56.54-5.16.24-7.73-.95-2.59-1.17-4.5-2.87-5.75-5.06h-17.14c4.44,13.34,12.41,19.23,26.77,20.27,23.16,1.67,30.54-17.94,30.52-28.52v-33.25c0-10.99-3.58-18.03-9.63-22.63Zm-6.81,32.66c-.63,3.68-1.64,6.4-3.06,8.12-2.97,4.08-7.6,5.53-13.84,4.37-6.27-1.19-9.4-7.2-9.4-18.03,0-5.03.93-9.51,2.82-13.45,1.88-3.91,5.47-5.89,10.79-5.89,3.91,0,6.89,1.42,8.92,4.24,2.04,2.83,3.34,6.05,3.88,9.67.55,3.61.5,7.27-.12,10.96Z" />
<path className="cls-1" d="m573.49,225.84c-5.29-2.67-11.34-4.03-18.15-4.03-10.47,0-17.85,2.73-22.15,8.19-2.7,3.48-4.22,7.94-4.58,13.36h15.65c.38-2.39,1.15-4.29,2.3-5.68,1.61-1.89,4.36-2.85,8.23-2.85,3.47,0,6.09.48,7.88,1.45,1.78.96,2.67,2.72,2.67,5.26,0,2.08-1.16,3.62-3.49,4.6-1.3.57-3.46,1.04-6.48,1.42l-5.54.67c-6.3.8-11.09,2.13-14.31,3.99-5.93,3.41-8.88,8.92-8.88,16.54,0,5.87,1.83,10.41,5.52,13.61,3.67,3.21,8.34,4.55,13.99,4.81,35.36,1.58,34.96-18.64,35.28-22.84v-23.27c0-7.46-2.63-12.54-7.92-15.24Zm-8.22,35.31c-.1,5.43-1.66,9.15-4.63,11.2-2.98,2.05-6.24,3.07-9.78,3.07-2.24,0-4.13-.63-5.7-1.85-1.56-1.23-2.34-3.23-2.34-6,0-3.1,1.28-5.39,3.83-6.87,1.52-.87,3.99-1.61,7.45-2.2l3.7-.68c1.84-.35,3.29-.72,4.33-1.12,1.07-.39,2.11-.91,3.14-1.56v6.03Z" />
<path className="cls-1" d="m707.61,230.97c-5.22-6.54-13.14-9.81-23.76-9.81s-18.52,3.26-23.73,9.81c-5.22,6.53-7.83,14.24-7.83,23.15s2.61,16.8,7.83,23.25c5.21,6.42,13.13,9.64,23.73,9.64s18.53-3.22,23.76-9.64c5.21-6.45,7.81-14.19,7.81-23.25s-2.6-16.62-7.81-23.15Zm-12.93,37.46c-2.53,3.36-6.15,5.05-10.87,5.05s-8.36-1.69-10.91-5.05c-2.56-3.35-3.83-8.12-3.83-14.31s1.27-10.95,3.83-14.29c2.54-3.34,6.18-5.01,10.91-5.01s8.35,1.67,10.87,5.01c2.53,3.34,3.79,8.1,3.79,14.29s-1.26,10.96-3.79,14.31Z" />
</svg>
);
}

View File

@@ -0,0 +1,689 @@
import { useState, useEffect, useRef } from 'react';
import { AdsV2Service } from '../services/ads.v2.service';
import { AdminService } from '../services/admin.service';
import { ChatService, type ChatMessage } from '../services/chat.service';
import { getImageUrl, formatCurrency, parseUTCDate } from '../utils/app.utils';
import { AuthService } from '../services/auth.service';
import {
VEHICLE_TYPES,
AUTO_SEGMENTS,
MOTO_SEGMENTS,
AUTO_TRANSMISSIONS,
MOTO_TRANSMISSIONS,
FUEL_TYPES,
VEHICLE_CONDITIONS,
STEERING_TYPES
} from '../constants/vehicleOptions';
interface Props {
adSummary: any;
onClose: () => void;
onApprove: (id: number) => void;
}
export default function ModerationModal({ adSummary, onClose, onApprove }: Props) {
const [fullAd, setFullAd] = useState<any>(null);
const [brandName, setBrandName] = useState('');
const [brands, setBrands] = useState<{ id: number, name: string }[]>([]);
const [activePhoto, setActivePhoto] = useState(0);
// Chat State
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [newMessage, setNewMessage] = useState('');
const [sending, setSending] = useState(false);
// Edit State
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<any>(null);
// Moderation State
const [showRejectReason, setShowRejectReason] = useState(false);
const [rejectReason, setRejectReason] = useState('');
// Photo Management State
const [photosToDelete, setPhotosToDelete] = useState<number[]>([]);
const [newPhotos, setNewPhotos] = useState<File[]>([]);
const adminUser = AuthService.getCurrentUser();
const scrollRef = useRef<HTMLDivElement>(null);
// 🟢 HELPER ROBUSTO PARA IDs: Busca en todas las variantes de casing posibles
const getAdId = () => {
if (!adSummary) return 0;
return Number(adSummary.adID || adSummary.AdID || adSummary.adId || fullAd?.adID || 0);
};
const getSellerId = () => {
if (!adSummary) return 0;
return Number(adSummary.userID || adSummary.UserID || adSummary.userId || fullAd?.userID || 0);
};
useEffect(() => {
const fetchData = async () => {
const currentAdId = getAdId();
if (!currentAdId) {
console.error("No se pudo determinar el AdID del resumen:", adSummary);
return;
}
try {
const adDetail = await AdsV2Service.getById(currentAdId);
// Normalización para asegurar nombres en minúscula (camelCase)
const normalizedAd = {
...adDetail,
fuelType: adDetail.fuelType || adDetail.FuelType || '',
transmission: adDetail.transmission || adDetail.Transmission || '',
color: adDetail.color || adDetail.Color || '',
segment: adDetail.segment || adDetail.Segment || '',
condition: adDetail.condition || adDetail.Condition || '',
steering: adDetail.steering || adDetail.Steering || '',
location: adDetail.location || adDetail.Location || '',
doorCount: adDetail.doorCount || adDetail.DoorCount
};
setFullAd(normalizedAd);
setEditData(normalizedAd);
// ARGAR MARCAS Y SETEAR NOMBRE ACTUAL
if (adDetail.vehicleTypeID) {
const brandsList = await AdsV2Service.getBrands(adDetail.vehicleTypeID);
setBrands(brandsList);
const currentBrand = brandsList.find((b: any) => b.id === adDetail.brandID);
if (currentBrand) setBrandName(currentBrand.name);
}
if (adminUser) loadChat();
} catch (err) {
console.error("Error cargando datos del aviso:", err);
}
};
fetchData();
const interval = setInterval(loadChat, 5000);
return () => clearInterval(interval);
}, [adSummary, adminUser]);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages]);
const loadChat = async () => {
if (!adminUser) return;
const currentAdId = getAdId();
const sellerId = getSellerId();
if (!currentAdId || !sellerId) return;
try {
const msgs = await ChatService.getConversation(currentAdId, adminUser.id, sellerId);
setMessages(msgs);
// Marcar como leídos los mensajes que el Admin recibió y no ha leído
const unreadMessages = msgs.filter(m => m.receiverID === adminUser.id && !m.isRead);
if (unreadMessages.length > 0) {
// Disparamos la actualización para cada mensaje no leído
unreadMessages.forEach(m => {
if (m.messageID) {
ChatService.markAsRead(m.messageID).catch(err => console.error("Error marcando leído:", err));
}
});
}
} catch (e) {
console.error(e);
}
};
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !adminUser) return;
const currentAdId = getAdId();
const sellerId = getSellerId();
// 🟢 VALIDACIÓN PREVIA AL ENVÍO
if (!currentAdId || !sellerId) {
console.error("Datos faltantes para enviar mensaje:", { currentAdId, sellerId, adSummary });
alert("Error: Faltan datos del aviso o vendedor para iniciar el chat.");
return;
}
setSending(true);
const payload = {
adID: currentAdId,
senderID: adminUser.id,
receiverID: sellerId,
messageText: newMessage
};
// Debug: Ver qué se envía exactamente
console.log("Enviando mensaje:", payload);
try {
await ChatService.sendMessage(payload);
setNewMessage('');
loadChat();
} catch (error: any) {
console.log("DETALLE DEL ERROR:", error.response?.data?.errors);
console.error("Error al enviar mensaje:", error.response?.data || error);
alert("Error enviando mensaje. Revisa la consola.");
} finally {
setSending(false);
}
};
// --- Lógica de Fotos ---
const handleNewPhoto = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
const totalPhotos = (fullAd.photos.length - photosToDelete.length) + newPhotos.length + files.length;
if (totalPhotos > 5) {
alert("El límite total es de 5 fotos.");
return;
}
setNewPhotos([...newPhotos, ...files]);
}
};
const handleSaveChanges = async () => {
if (!window.confirm("¿Confirmar todos los cambios (textos y fotos)?")) return;
const currentAdId = getAdId();
try {
// 1. Actualizar Textos
const payload = {
vehicleTypeID: editData.vehicleTypeID,
brandID: editData.brandID,
modelID: editData.modelID,
versionName: editData.versionName,
year: Number(editData.year) || 0,
km: Number(editData.km) || 0,
price: Number(editData.price) || 0,
currency: editData.currency,
description: editData.description,
isFeatured: editData.isFeatured,
contactPhone: editData.contactPhone,
contactEmail: editData.contactEmail,
displayContactInfo: editData.displayContactInfo,
fuelType: editData.fuelType,
color: editData.color,
segment: editData.segment,
location: editData.location,
condition: editData.condition,
doorCount: editData.doorCount ? parseInt(editData.doorCount) : undefined,
transmission: editData.transmission,
steering: editData.steering
};
await AdsV2Service.update(currentAdId, payload);
// 2. Borrar Fotos Marcadas
for (const photoId of photosToDelete) {
await AdsV2Service.deletePhoto(photoId);
}
// 3. Subir Nuevas Fotos
if (newPhotos.length > 0) {
await AdsV2Service.uploadPhotos(currentAdId, newPhotos);
}
// 4. Refrescar Todo
const updatedAd = await AdsV2Service.getById(currentAdId);
setFullAd(updatedAd);
setEditData(updatedAd);
// Resetear estados temporales
setPhotosToDelete([]);
setNewPhotos([]);
setIsEditing(false);
alert("Aviso corregido exitosamente.");
} catch (error: any) {
console.error(error);
const serverMsg = error.response?.data?.message || (typeof error.response?.data === 'string' ? error.response.data : '');
alert(`Error al guardar cambios: ${serverMsg}`);
}
};
if (!fullAd) return (
<div className="fixed inset-0 z-[1000] bg-black/90 flex items-center justify-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500"></div>
</div>
);
return (
<div className="fixed inset-0 z-[999999] flex items-start md:items-center justify-center bg-black/95 backdrop-blur-xl p-4 md:p-10 pt-24 md:pt-16 overflow-hidden">
<style>{`
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #333; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #555; }
`}</style>
<div className="w-full max-w-[1440px] max-h-[85vh] md:max-h-[85vh] bg-[#0a0c10] border border-white/10 rounded-[1.5rem] shadow-[0_0_100px_rgba(0,0,0,0.8)] flex flex-col overflow-hidden relative">
{/* HEADER */}
<div className="px-5 py-3 md:px-6 md:py-4 border-b border-white/10 flex justify-between items-center bg-[#12141a] shrink-0">
<div className="flex flex-col md:flex-row md:items-center md:gap-4 shrink-0">
<div className="flex items-center gap-2">
<span className="bg-amber-500/10 text-amber-500 border border-amber-500/20 px-2 py-0.5 rounded-lg text-[8px] md:text-[10px] font-black uppercase tracking-widest shrink-0">
En Revisión
</span>
<h2 className="text-[11px] md:text-sm font-bold text-gray-400 shrink-0">ID #{fullAd.adID}</h2>
</div>
<p className="text-[9px] md:text-[10px] text-gray-500 md:mt-0">
Usuario: <span className="text-white font-bold">{adSummary.userName}</span>
</p>
</div>
<div className="flex items-center gap-2 md:gap-3">
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="bg-white/5 hover:bg-white/10 text-gray-300 px-3 md:px-4 py-1.5 md:py-2 rounded-xl text-[9px] md:text-[10px] font-black uppercase tracking-widest border border-white/10 transition-all flex items-center gap-1.5"
>
<span className="text-xs"></span> <span className="hidden md:inline">Editar</span>
</button>
) : (
<div className="flex gap-2">
<button onClick={() => { setIsEditing(false); setEditData(fullAd); setPhotosToDelete([]); setNewPhotos([]); }} className="text-red-400 hover:text-white px-3 py-2 text-[9px] md:text-[10px] font-black uppercase tracking-widest">
Cancelar
</button>
<button onClick={handleSaveChanges} className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 md:py-2 rounded-xl text-[9px] md:text-[10px] font-black uppercase tracking-widest shadow-lg shadow-blue-600/20 transition-all">
Guardar
</button>
</div>
)}
<button onClick={onClose} className="w-8 h-8 md:w-9 md:h-9 rounded-full bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-all text-base md:text-lg font-bold shrink-0"></button>
</div>
</div>
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden min-h-0">
{/* IZQUIERDA: DETALLES (MÁS GRANDE) */}
<div className="flex-[1.5] overflow-y-auto p-6 lg:p-10 border-r border-white/10 custom-scrollbar bg-gradient-to-b from-[#0a0c10] to-[#0f1115]">
{/* --- SECCIÓN GALERÍA --- */}
<div className="mb-10">
{!isEditing ? (
// MODO LECTURA: Galería Normal
<>
<div className="aspect-video w-full rounded-2xl overflow-hidden bg-black border border-white/10 mb-4 relative shadow-2xl group flex items-center justify-center">
<img src={getImageUrl(fullAd.photos?.[activePhoto]?.filePath)} className="max-h-full max-w-full object-contain transition-transform duration-700 group-hover:scale-105" alt="Review" />
</div>
<div className="flex gap-3 overflow-x-auto pb-4 scrollbar-hide no-scrollbar">
{fullAd.photos?.map((p: any, idx: number) => (
<button key={p.photoID} onClick={() => setActivePhoto(idx)} className={`w-24 h-16 rounded-xl overflow-hidden border-2 transition-all shrink-0 ${activePhoto === idx ? 'border-blue-500 opacity-100 shadow-lg scale-105' : 'border-white/10 opacity-40 hover:opacity-100'}`}>
<img src={getImageUrl(p.filePath)} className="w-full h-full object-cover" alt="" />
</button>
))}
</div>
</>
) : (
// MODO EDICIÓN: Gestión de Fotos
<div className="bg-white/5 p-5 rounded-2xl border border-blue-500/30">
<h4 className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-4">Gestión de Fotos</h4>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
{/* Fotos Existentes */}
{fullAd.photos.filter((p: any) => !photosToDelete.includes(p.photoID)).map((p: any) => (
<div key={p.photoID} className="relative group aspect-square rounded-xl overflow-hidden border border-white/10">
<img src={getImageUrl(p.filePath)} className="w-full h-full object-cover opacity-70 group-hover:opacity-100 transition-opacity" alt="" />
<button
onClick={() => setPhotosToDelete([...photosToDelete, p.photoID])}
className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center text-red-500 font-bold transition-opacity"
>
🗑
</button>
</div>
))}
{/* Fotos Nuevas */}
{newPhotos.map((file: File, idx: number) => (
<div key={idx} className="relative group aspect-square rounded-xl overflow-hidden border border-green-500/50">
<img src={URL.createObjectURL(file)} className="w-full h-full object-cover" alt="new" />
<button
onClick={() => setNewPhotos(newPhotos.filter((_: File, i: number) => i !== idx))}
className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white font-bold transition-opacity"
>
</button>
<span className="absolute bottom-1 right-1 bg-green-500 text-black text-[8px] font-bold px-1.5 rounded">NUEVA</span>
</div>
))}
{/* Botón Agregar */}
<label className="aspect-square rounded-xl border-2 border-dashed border-white/20 hover:border-blue-500 hover:bg-blue-500/10 flex flex-col items-center justify-center cursor-pointer transition-all">
<span className="text-2xl mb-1">+</span>
<span className="text-[8px] font-bold uppercase text-gray-400">Agregar</span>
<input type="file" multiple accept="image/*" className="hidden" onChange={handleNewPhoto} />
</label>
</div>
<p className="text-[9px] text-gray-500 mt-3 text-center italic">Máximo 5 fotos en total.</p>
</div>
)}
</div>
<div className="space-y-10">
<div className="flex flex-col sm:flex-row gap-8 sm:items-end border-b border-white/5 pb-10">
<div className="flex-1">
<label className="text-[10px] text-gray-500 font-black uppercase mb-3 block tracking-[0.2em]">Título / Versión</label>
{isEditing ? (
<div className="flex gap-2 items-center">
<select
value={editData.brandID}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const newId = Number(e.target.value);
setEditData({ ...editData, brandID: newId });
const b = brands.find((x: any) => x.id === newId);
if (b) setBrandName(b.name);
}}
className="bg-white/5 border border-blue-500/50 rounded-xl px-4 py-3 text-lg font-black text-blue-500 outline-none focus:bg-white/10 appearance-none cursor-pointer text-center min-w-[140px]"
>
{brands.map((b: any) => (
<option key={b.id} value={b.id} className="bg-[#1a1d24] text-white">
{b.name}
</option>
))}
</select>
<input
type="text"
value={editData.versionName}
onChange={e => setEditData({ ...editData, versionName: e.target.value })}
className="flex-1 bg-white/5 border border-blue-500/50 rounded-xl px-4 py-3 text-lg font-black text-white outline-none focus:bg-white/10"
placeholder="Modelo/Versión"
/>
</div>
) : (
<div>
<span className="text-blue-400 font-black uppercase text-[12px] block mb-2 tracking-[0.3em]">{brandName}</span>
<h1 className="text-4xl md:text-5xl font-black uppercase tracking-tighter text-white leading-none">{fullAd.versionName}</h1>
</div>
)}
</div>
<div className="sm:text-right">
<label className="text-[10px] text-gray-500 font-black uppercase mb-3 block tracking-[0.2em]">Precio Final</label>
{isEditing ? (
<div className="flex items-center gap-2 bg-white/5 border border-blue-500/50 rounded-xl px-4 py-2">
<span className="text-blue-400 font-black text-lg">{editData.currency}</span>
<input type="number" value={editData.price} onChange={e => setEditData({ ...editData, price: e.target.value })} className="bg-transparent border-none py-1 text-2xl font-black text-white text-right outline-none w-40" />
</div>
) : (
<p className="text-4xl md:text-5xl text-white font-black tracking-tighter">{formatCurrency(fullAd.price, fullAd.currency)}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<InputOrBadge label="Año" isEditing={isEditing} value={editData.year} onChange={(v: string) => setEditData({ ...editData, year: v })} type="number" />
<InputOrBadge label="Kilómetros" isEditing={isEditing} value={editData.km} onChange={(v: string) => setEditData({ ...editData, km: v })} type="number" />
{/* Nuevos Campos */}
{isEditing ? (
<div className="bg-white/5 p-3 rounded-xl border border-white/10">
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1 block">Combustible</label>
<select value={editData.fuelType || ''} onChange={e => setEditData({ ...editData, fuelType: e.target.value })} className="w-full bg-black/20 text-white text-xs p-1 rounded outline-none border border-white/10">
<option value="">Seleccionar</option>
{FUEL_TYPES.map(o => <option key={o} value={o} className="bg-gray-900">{o}</option>)}
</select>
</div>
) : (
<DataBadge label="Combustible" value={editData.fuelType} />
)}
{isEditing ? (
<div className="bg-white/5 p-3 rounded-xl border border-white/10">
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1 block">Transmisión</label>
<select value={editData.transmission || ''} onChange={e => setEditData({ ...editData, transmission: e.target.value })} className="w-full bg-black/20 text-white text-xs p-1 rounded outline-none border border-white/10">
<option value="">Seleccionar</option>
{(fullAd.vehicleTypeID === VEHICLE_TYPES.MOTOS ? MOTO_TRANSMISSIONS : AUTO_TRANSMISSIONS).map(o => (
<option key={o} value={o} className="bg-gray-900">{o}</option>
))}
</select>
</div>
) : (
<DataBadge label="Transmisión" value={editData.transmission} />
)}
<InputOrBadge label="Color" isEditing={isEditing} value={editData.color} onChange={(v: string) => setEditData({ ...editData, color: v })} />
<InputOrBadge label="Ubicación" isEditing={isEditing} value={editData.location} onChange={(v: string) => setEditData({ ...editData, location: v })} />
{isEditing ? (
<div className="bg-white/5 p-3 rounded-xl border border-white/10">
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1 block">Segmento</label>
<select value={editData.segment || ''} onChange={e => setEditData({ ...editData, segment: e.target.value })} className="w-full bg-black/20 text-white text-xs p-1 rounded outline-none border border-white/10">
<option value="">Seleccionar</option>
{(fullAd.vehicleTypeID === VEHICLE_TYPES.MOTOS ? MOTO_SEGMENTS : AUTO_SEGMENTS).map(o => (
<option key={o} value={o} className="bg-gray-900">{o}</option>
))}
</select>
</div>
) : (
<DataBadge label="Segmento" value={editData.segment} />
)}
{isEditing ? (
<div className="bg-white/5 p-3 rounded-xl border border-white/10">
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1 block">Estado</label>
<select value={editData.condition || ''} onChange={e => setEditData({ ...editData, condition: e.target.value })} className="w-full bg-black/20 text-white text-xs p-1 rounded outline-none border border-white/10">
<option value="">Seleccionar</option>
{VEHICLE_CONDITIONS.map(o => <option key={o} value={o} className="bg-gray-900">{o}</option>)}
</select>
</div>
) : (
<DataBadge label="Estado" value={editData.condition} />
)}
{(isEditing || editData.doorCount) && fullAd.vehicleTypeID !== VEHICLE_TYPES.MOTOS && (
<InputOrBadge label="Puertas" isEditing={isEditing} value={editData.doorCount || ''} onChange={(v: string) => setEditData({ ...editData, doorCount: v })} type="number" />
)}
{fullAd.vehicleTypeID !== VEHICLE_TYPES.MOTOS && (
isEditing ? (
<div className="bg-white/5 p-3 rounded-xl border border-white/10">
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1 block">Dirección</label>
<select value={editData.steering || ''} onChange={e => setEditData({ ...editData, steering: e.target.value })} className="w-full bg-black/20 text-white text-xs p-1 rounded outline-none border border-white/10">
<option value="">Seleccionar</option>
{STEERING_TYPES.map(o => <option key={o} value={o} className="bg-gray-900">{o}</option>)}
</select>
</div>
) : (
<DataBadge label="Dirección" value={editData.steering} />
)
)}
<DataBadge label="Categoría" value={fullAd.vehicleTypeID === 1 ? 'Automóvil' : 'Moto'} />
<DataBadge label="Destacado" value={fullAd.isFeatured ? 'SÍ ⭐' : 'NO'} highlight={fullAd.isFeatured} />
</div>
<div className="bg-white/5 p-5 rounded-2xl border border-white/5">
<label className="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-2 block">Descripción</label>
{isEditing ? (
<textarea value={editData.description} onChange={e => setEditData({ ...editData, description: e.target.value })} className="w-full h-24 bg-black/20 border border-blue-500/50 rounded-xl p-3 text-xs text-white outline-none focus:bg-black/40 resize-none leading-relaxed" />
) : (
<p className="text-xs text-gray-300 leading-relaxed whitespace-pre-line font-light">{fullAd.description}</p>
)}
</div>
<div className="bg-blue-900/10 p-5 rounded-2xl border border-blue-500/20">
<h4 className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-4 flex items-center gap-2">
👤 Datos de Contacto
<span className={`px-2 py-0.5 rounded text-[8px] border ${fullAd.displayContactInfo ? 'bg-green-500/20 text-green-400 border-green-500/30' : 'bg-red-500/20 text-red-400 border-red-500/30'}`}>
{fullAd.displayContactInfo ? 'VISIBLE AL PÚBLICO' : 'OCULTO'}
</span>
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span className="text-[9px] uppercase text-gray-500 font-bold block mb-1">Email</span>
{isEditing ? (
<input type="text" value={editData.contactEmail} onChange={e => setEditData({ ...editData, contactEmail: e.target.value })} className="w-full bg-black/20 border border-blue-500/50 rounded-lg p-2 text-xs text-white outline-none focus:bg-black/40" />
) : (
<span className="text-white text-sm font-medium break-all">{fullAd.contactEmail}</span>
)}
</div>
<div>
<span className="text-[9px] uppercase text-gray-500 font-bold block mb-1">Teléfono</span>
{isEditing ? (
<input type="text" value={editData.contactPhone} onChange={e => setEditData({ ...editData, contactPhone: e.target.value })} className="w-full bg-black/20 border border-blue-500/50 rounded-lg p-2 text-xs text-white outline-none focus:bg-black/40" />
) : (
<span className="text-white text-sm font-medium">{fullAd.contactPhone}</span>
)}
</div>
</div>
</div>
</div>
</div>
{/* DERECHA: CHAT Y ACCIONES (NUEVO COMPORTAMIENTO VERTICAL EN MÓVIL) */}
<div className="w-full lg:w-[420px] h-[350px] lg:h-full bg-[#161a22] flex flex-col border-l border-white/10 shrink-0">
<div className="p-5 bg-[#1a1d24] border-b border-white/5 shadow-sm z-10 shrink-0 flex justify-between items-center">
<h3 className="text-[10px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-2">
<span className="text-blue-400">💬</span> Chat con Vendedor
</h3>
{messages.some(m => !m.isRead && m.receiverID === adminUser?.id) && (
<span className="bg-blue-500 text-white text-[8px] px-2 py-0.5 rounded-full animate-pulse">NUEVOS</span>
)}
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto p-5 space-y-4 custom-scrollbar bg-[#161a22] min-h-0">
{messages.length === 0 && (
<div className="h-full flex flex-col items-center justify-center opacity-20 p-10 text-center">
<span className="text-4xl mb-3">💬</span>
<p className="text-[10px] uppercase font-black tracking-widest">Sin mensajes aún</p>
</div>
)}
{messages.map((m: ChatMessage) => {
const isAdminMsg = m.senderID === adminUser?.id;
return (
<div key={m.messageID} className={`flex flex-col ${isAdminMsg ? 'items-end' : 'items-start'}`}>
<div className={`max-w-[85%] p-3 rounded-2xl text-xs leading-relaxed shadow-lg ${isAdminMsg ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-[#252830] text-gray-200 rounded-tl-none border border-white/5'}`}>
{m.messageText}
</div>
<span className="text-[8px] text-gray-600 mt-1.5 font-bold px-1 uppercase tracking-tighter">
{isAdminMsg ? 'ADMINISTRADOR' : 'VENDEDOR'} {parseUTCDate(m.sentAt!).toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
)
})}
</div>
{/* PANEL DE ACCIONES Y MENSAJERÍA */}
<div className="shrink-0 bg-[#0a0c10] border-t border-white/10">
{/* Formulario de Mensaje */}
<div className="p-4 border-b border-white/5">
<form onSubmit={handleSendMessage} className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
placeholder="Escribir al vendedor..."
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-xs text-white outline-none focus:border-blue-500 focus:bg-white/10 transition-all"
/>
<button
disabled={sending}
type="submit"
className="bg-blue-600 hover:bg-blue-500 text-white w-11 h-11 rounded-xl transition-all disabled:opacity-50 flex items-center justify-center shadow-lg active:scale-95"
>
<span className="text-lg"></span>
</button>
</form>
</div>
{/* Botonera de Moderación */}
<div className="p-5 space-y-4">
{showRejectReason ? (
<div className="space-y-3 animate-fade-in">
<label className="text-[9px] font-black text-red-400 uppercase tracking-widest block ml-1">Motivo del Rechazo</label>
<textarea
autoFocus
value={rejectReason}
onChange={e => setRejectReason(e.target.value)}
placeholder="Ej: Fotos de baja calidad, precio irreal..."
className="w-full h-24 bg-red-500/5 border border-red-500/30 rounded-xl p-3 text-xs text-white outline-none focus:border-red-500 focus:bg-red-500/10 transition-all resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => setShowRejectReason(false)}
className="flex-1 text-gray-500 hover:text-white text-[10px] font-black uppercase tracking-widest py-3"
>
Cancelar
</button>
<button
disabled={!rejectReason.trim()}
onClick={() => {
// Usamos AdminService directamente
AdminService.rejectAd(getAdId(), rejectReason)
.then(() => {
alert("Aviso rechazado correctamente.");
onClose();
})
.catch((err: any) => alert("Error al rechazar: " + (err.response?.data || "Error desconocido")));
}}
className="flex-[2] bg-red-600 hover:bg-red-500 text-white py-3 rounded-xl font-black uppercase tracking-widest text-[10px] shadow-lg shadow-red-600/20 disabled:opacity-30"
>
Confirmar Rechazo
</button>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setShowRejectReason(true)}
className="bg-white/5 hover:bg-red-600/20 text-gray-500 hover:text-red-400 border border-white/10 hover:border-red-500/30 py-4 rounded-2xl font-black uppercase tracking-widest text-[10px] transition-all flex flex-col items-center gap-1"
>
<span className="text-lg"></span>
Rechazar
</button>
<button
onClick={() => {
if (window.confirm("¿Aprobar este aviso?")) {
onApprove(getAdId());
}
}}
className="bg-green-600 hover:bg-green-500 text-white py-4 rounded-2xl font-black uppercase tracking-widest text-[10px] transition-all shadow-lg shadow-green-600/20 flex flex-col items-center gap-1 active:scale-[0.98]"
>
<span className="text-lg"></span>
Aprobar
</button>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div >
);
}
interface InputBadgeProps {
label: string;
value: string | number;
isEditing: boolean;
onChange: (val: string) => void;
type?: string;
}
function InputOrBadge({ label, value, isEditing, onChange, type = "text" }: InputBadgeProps) {
if (isEditing) {
return (
<div className="p-2.5 rounded-xl border border-blue-500/50 bg-white/5 flex flex-col justify-center">
<span className="text-[8px] font-black uppercase tracking-widest mb-1 text-blue-400">{label}</span>
<input type={type} value={value ?? ''} onChange={e => onChange(e.target.value)} className="bg-transparent text-white text-sm font-bold w-full outline-none" />
</div>
);
}
return <DataBadge label={label} value={value} />;
}
function DataBadge({ label, value, highlight = false }: { label: string, value: string | number, highlight?: boolean }) {
return (
<div className={`p-2.5 rounded-xl border flex flex-col justify-center ${highlight ? 'bg-blue-600/10 border-blue-500/30' : 'bg-white/5 border-white/5'}`}>
<span className={`text-[8px] font-black uppercase tracking-widest mb-1 ${highlight ? 'text-blue-400' : 'text-gray-500'}`}>{label}</span>
<span className="text-white text-sm font-bold truncate">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState, useEffect, useRef } from 'react';
interface Option {
id: number | string;
name: string;
}
interface Props {
options: Option[];
value: string | number;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}
export default function SearchableSelect({ options, value, onChange, placeholder = "Seleccionar...", disabled = false }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const wrapperRef = useRef<HTMLDivElement>(null);
// Cerrar al hacer clic fuera
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);
}, []);
// Encontrar la opción seleccionada para mostrar su nombre
const selectedOption = options.find(o => String(o.id) === String(value));
const filteredOptions = options.filter(option =>
option.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="relative w-full" ref={wrapperRef}>
<button
type="button"
disabled={disabled}
onClick={() => {
if (!disabled) {
setIsOpen(!isOpen);
setSearch(''); // Limpiar búsqueda al abrir
}
}}
className={`w-full bg-white/5 border rounded-xl px-4 py-3 text-left text-sm flex justify-between items-center transition-all ${isOpen ? 'border-blue-500 ring-1 ring-blue-500' : 'border-white/10 hover:border-white/20'
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={selectedOption ? 'text-white' : 'text-gray-500'}>
{selectedOption ? selectedOption.name : placeholder}
</span>
<span className="text-gray-500 text-xs"></span>
</button>
{isOpen && (
<div className="absolute z-50 w-full mt-2 bg-[#1a1d24] border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-fade-in">
{/* Buscador interno */}
<div className="p-2 border-b border-white/5 sticky top-0 bg-[#1a1d24]">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar..."
className="w-full bg-black/20 border border-white/5 rounded-lg px-3 py-2 text-xs text-white outline-none focus:border-blue-500/50"
autoFocus
/>
</div>
<ul className="max-h-60 overflow-y-auto scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<li
key={option.id}
onClick={() => {
onChange(String(option.id));
setIsOpen(false);
}}
className={`px-4 py-3 text-sm cursor-pointer hover:bg-blue-600 hover:text-white transition-colors border-b border-white/5 last:border-0 ${String(option.id) === String(value) ? 'bg-blue-600/20 text-blue-400 font-bold' : 'text-gray-300'
}`}
>
{option.name}
</li>
))
) : (
<li className="px-4 py-3 text-xs text-gray-500 text-center italic">
No hay resultados
</li>
)}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,151 @@
import { useState, useEffect } from 'react';
import { AdminService } from '../services/admin.service';
interface Props {
userId: number;
onClose: () => void;
onUpdate: () => void;
}
export default function UserModal({ userId, onClose, onUpdate }: Props) {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editData, setEditData] = useState({
userName: '',
firstName: '',
lastName: '',
phoneNumber: '',
userType: 1
});
useEffect(() => {
loadUser();
}, [userId]);
const loadUser = async () => {
try {
const res = await AdminService.getUserById(userId);
setUser(res);
setEditData({
userName: res.userName || '',
firstName: res.firstName || '',
lastName: res.lastName || '',
phoneNumber: res.phoneNumber || '',
userType: res.userType
});
} catch (err) {
alert('Error al cargar datos del usuario');
onClose();
} finally {
setLoading(false);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await AdminService.updateUser(userId, editData);
alert('Usuario actualizado correctamente');
onUpdate();
onClose();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al actualizar usuario');
} finally {
setSaving(false);
}
};
if (loading) return null;
return (
<div className="fixed inset-0 z-[2000] flex items-start md:items-center justify-center bg-black/80 backdrop-blur-sm p-4 pt-24 md:pt-8 animate-fade-in overflow-hidden">
<div className="absolute inset-0" onClick={onClose}></div>
<div className="relative w-full max-w-2xl max-h-[85vh] md:max-h-[90vh] bg-[#0a0c10] border border-white/10 rounded-[2rem] md:rounded-[2.5rem] shadow-2xl flex flex-col overflow-hidden animate-scale-up">
<div className="px-6 md:px-8 py-5 md:py-6 border-b border-white/10 bg-[#12141a] flex justify-between items-center shrink-0">
<div>
<h2 className="text-xl font-black uppercase tracking-tight text-white mb-1">Editar Usuario</h2>
<p className="text-[9px] md:text-[10px] text-gray-500 uppercase tracking-widest font-bold">ID #{userId} {user.email}</p>
</div>
<button onClick={onClose} className="w-9 h-9 md:w-10 md:h-10 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-all shrink-0"></button>
</div>
<form onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 space-y-6 custom-scrollbar">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 md:gap-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Nombre de Usuario</label>
<input
type="text"
value={editData.userName}
onChange={e => setEditData({ ...editData, userName: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
required
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Tipo de Usuario</label>
<select
value={editData.userType}
onChange={e => setEditData({ ...editData, userType: parseInt(e.target.value) })}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium appearance-none cursor-pointer"
>
<option value={1} className="bg-gray-900">Particular (User)</option>
<option value={3} className="bg-gray-900">Administrador</option>
</select>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Nombre</label>
<input
type="text"
value={editData.firstName}
onChange={e => setEditData({ ...editData, firstName: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Apellido</label>
<input
type="text"
value={editData.lastName}
onChange={e => setEditData({ ...editData, lastName: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
/>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Teléfono</label>
<input
type="text"
value={editData.phoneNumber}
onChange={e => setEditData({ ...editData, phoneNumber: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
/>
</div>
</div>
<div className="pt-6 border-t border-white/5 flex flex-col md:flex-row gap-3 md:gap-4 shrink-0">
<button
type="button"
onClick={onClose}
className="order-2 md:order-1 flex-1 bg-white/5 hover:bg-white/10 text-gray-400 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
className="order-1 md:order-2 flex-1 md:flex-[2] bg-blue-600 hover:bg-blue-500 text-white py-4 px-12 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95 disabled:opacity-50"
>
{saving ? 'Guardando...' : 'Guardar Cambios'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
// src/components/VisualCreditCard.tsx
interface Props {
cardNumber: string;
cardholderName: string;
cardExpirationMonth: string;
cardExpirationYear: string;
cvc: string;
isFlipped: boolean;
}
const getCardBrand = (cardNumber: string) => {
const firstDigit = cardNumber.charAt(0);
if (firstDigit === '4') return 'VISA';
if (firstDigit === '5') return 'MASTER';
return null;
};
export default function VisualCreditCard({ cardNumber, cardholderName, cardExpirationMonth, cardExpirationYear, cvc, isFlipped }: Props) {
const brand = getCardBrand(cardNumber.replace(/\s/g, ''));
// Máscara simple para mostrar solo los últimos 4 si hay datos, o placeholder
const formattedNumber = cardNumber || "#### #### #### ####";
return (
<div className="w-full max-w-sm mx-auto aspect-[1.586] perspective-[1200px]">
<div
className={`relative w-full h-full transition-all duration-700 [transform-style:preserve-3d] shadow-2xl rounded-2xl ${isFlipped ? '[transform:rotateY(180deg)]' : ''
}`}
>
{/* --- CARA FRONTAL --- */}
<div
className="absolute inset-0 w-full h-full [backface-visibility:hidden] rounded-2xl p-6 flex flex-col justify-between bg-[#161a22] border border-white/10 shadow-inner overflow-hidden"
style={{ transform: 'translateZ(1px)' }}
>
{/* Decoración de fondo */}
<div className="absolute top-0 right-0 -mr-10 -mt-10 w-40 h-40 bg-white/5 rounded-full blur-3xl"></div>
<div className="absolute bottom-0 left-0 -ml-10 -mb-10 w-40 h-40 bg-blue-500/10 rounded-full blur-3xl"></div>
<div className="flex justify-between items-start relative z-10">
{/* Chip */}
<div className="w-12 h-9 bg-yellow-500 rounded-md border border-yellow-600/50 flex items-center justify-center overflow-hidden">
<div className="w-full h-px bg-yellow-600/50 absolute top-1/3"></div>
<div className="w-full h-px bg-yellow-600/50 absolute bottom-1/3"></div>
<div className="h-full w-px bg-yellow-600/50 absolute left-1/3"></div>
<div className="h-full w-px bg-yellow-600/50 absolute right-1/3"></div>
</div>
{/* Marca */}
{brand ? (
<div className="text-2xl font-black text-white italic tracking-tighter opacity-90">
{brand === 'VISA' && 'VISA'}
{brand === 'MASTER' && (
<div className="flex relative">
<div className="w-8 h-8 bg-red-500/90 rounded-full"></div>
<div className="w-8 h-8 bg-yellow-500/90 rounded-full -ml-3"></div>
</div>
)}
</div>
) : (
<span className="text-white/20 font-black text-lg tracking-widest uppercase">Brand</span>
)}
</div>
{/* NÚMERO DE TARJETA - Ajustado tamaño para evitar truncate */}
<div className="text-white font-mono text-[19px] sm:text-[21px] tracking-[0.12em] text-center drop-shadow-md relative z-10 w-full mt-2 whitespace-nowrap">
{formattedNumber}
</div>
<div className="flex justify-between items-end text-gray-300 text-[9px] uppercase font-bold tracking-widest relative z-10">
<div className="flex flex-col">
<span className="text-[7px] text-gray-500 mb-0.5">Titular</span>
<span className="text-xs sm:text-sm text-white tracking-wider truncate max-w-[150px] sm:max-w-[180px]">
{cardholderName || 'NOMBRE APELLIDO'}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-[7px] text-gray-500 mb-0.5">Vence</span>
<span className="text-xs sm:text-sm text-white">
{cardExpirationMonth || 'MM'}/{cardExpirationYear || 'AA'}
</span>
</div>
</div>
</div>
{/* --- CARA TRASERA --- */}
<div
className="absolute inset-0 w-full h-full [backface-visibility:hidden] [transform:rotateY(180deg)] rounded-2xl overflow-hidden bg-[#161a22] border border-white/10"
style={{ transform: 'rotateY(180deg) translateZ(1px)' }}
>
{/* Banda Magnética */}
<div className="w-full h-12 bg-black mt-6 relative">
<div className="w-full h-full bg-gray-900 opacity-90"></div>
</div>
<div className="px-6 mt-6">
<div className="flex items-center gap-4">
<div className="flex-1 h-10 bg-gray-300/20 rounded opacity-50"></div>
<div className="w-16 h-10 bg-white text-black font-mono font-bold text-lg flex items-center justify-center italic rounded transform -skew-x-6 border-2 border-gray-300 tracking-widest">
{cvc || '***'}
</div>
</div>
<div className="mt-6 flex justify-between items-center">
<div className="h-2 w-24 bg-white/20 rounded-full"></div>
<div className="text-[8px] text-white uppercase tracking-widest">Código de Seguridad</div>
</div>
</div>
<div className="absolute bottom-6 right-6 opacity-30">
{brand === 'VISA' && <span className="text-white font-black italic text-xl [transform:scale(-1,1)]">VISA</span>}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
export const AD_STATUSES = {
DRAFT: 1,
PAYMENT_PENDING: 2,
MODERATION_PENDING: 3,
ACTIVE: 4,
REJECTED: 5,
PAUSED: 6,
SOLD: 7,
EXPIRED: 8,
DELETED: 9,
RESERVED: 10
};
// Estilos de alto contraste para sobreponer en fotos
export const STATUS_CONFIG: Record<number, { label: string; color: string; bg: string; border: string; icon: string }> = {
[AD_STATUSES.ACTIVE]: {
label: 'Activo',
color: 'text-white',
bg: 'bg-green-600/90',
border: 'border-green-400/50',
icon: '✅'
},
[AD_STATUSES.PAUSED]: {
label: 'Pausado',
color: 'text-white',
bg: 'bg-amber-600/90',
border: 'border-amber-400/50',
icon: '⏸️'
},
[AD_STATUSES.RESERVED]: {
label: 'Reservado',
color: 'text-white',
bg: 'bg-blue-600/90',
border: 'border-blue-400/50',
icon: '🔐'
},
[AD_STATUSES.SOLD]: {
label: 'Vendido',
color: 'text-white',
bg: 'bg-gray-800/90',
border: 'border-gray-500/50',
icon: '🤝'
},
[AD_STATUSES.MODERATION_PENDING]: {
label: 'En Revisión',
color: 'text-white',
bg: 'bg-indigo-600/90',
border: 'border-indigo-400/50',
icon: '⏳'
},
[AD_STATUSES.REJECTED]: {
label: 'Rechazado',
color: 'text-white',
bg: 'bg-red-600/90',
border: 'border-red-400/50',
icon: '✕'
},
[AD_STATUSES.DRAFT]: {
label: 'Borrador',
color: 'text-white',
bg: 'bg-gray-600/90',
border: 'border-gray-400/50',
icon: '📝'
},
[AD_STATUSES.DELETED]: {
label: 'Eliminar',
color: 'text-white',
bg: 'bg-red-700/90',
border: 'border-red-500/50',
icon: '🗑️'
},
[AD_STATUSES.EXPIRED]: {
label: 'Vencido',
color: 'text-white',
bg: 'bg-stone-700/90',
border: 'border-stone-500/50',
icon: '⏰'
},
[AD_STATUSES.PAYMENT_PENDING]: {
label: 'Pago Pendiente',
color: 'text-white',
bg: 'bg-yellow-600/90',
border: 'border-yellow-400/50',
icon: '💳'
}
};

View File

@@ -0,0 +1,76 @@
export const VEHICLE_TYPES = {
AUTOS: 1,
MOTOS: 2
};
export const AUTO_SEGMENTS = [
'Hatchback',
'Sedán',
'SUV',
'Crossover',
'Pick-up',
'Coupé',
'Convertible',
'Minivan',
'Familiar / Rural',
'Todoterreno',
'Van (Comercial)'
];
export const MOTO_SEGMENTS = [
'Calle / Naked',
'Clásica / Colección',
'Triciclo / Cuatriciclo',
'Custom / Chopper',
'Enduro (Off-Road-Motocross) / Cross / Trial',
'Pista / Carenadas / Sport',
'Ciclomotor / Scooter',
'Touring / Trail',
'Kartings'
];
export const AUTO_TRANSMISSIONS = [
'Manual',
'Automática',
'Continuamente Variable (CVT)',
'Doble Embrague (DSG o DGT)',
'Manual Automatizada (AMT)',
'Manual Secuencial',
'Electrónica Variable (EVT)',
'Hidráulica',
'Sin Especificar'
];
export const MOTO_TRANSMISSIONS = [
'Manual',
'Automática',
'Sin Transmisión (Eléctrica)',
'Sin Especificar'
];
export const FUEL_TYPES = [
'Nafta',
'Diesel',
'GNC',
'Nafta/GNC',
'Eléctrico',
'Hidrógeno',
'Sin Especificar'
];
export const VEHICLE_CONDITIONS = [
'0 Kilómetro',
'Excelente',
'Muy Bueno',
'Bueno',
'Regular',
'Para Repuestos'
];
export const STEERING_TYPES = [
'Asistida',
'Hidráulica',
'Eléctrica',
'Mecánica',
'Sin Especificar'
];

View File

@@ -0,0 +1,92 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { AuthService, type UserSession } from '../services/auth.service';
import { ChatService } from '../services/chat.service';
interface AuthContextType {
user: UserSession | null;
loading: boolean;
unreadCount: number;
login: (user: UserSession) => void;
logout: () => void;
refreshSession: () => Promise<void>;
fetchUnreadCount: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserSession | null>(null);
const [loading, setLoading] = useState(true);
const [unreadCount, setUnreadCount] = useState(0);
const fetchUnreadCount = async () => {
const currentUser = AuthService.getCurrentUser();
if (currentUser) {
try {
const count = await ChatService.getUnreadCount(currentUser.id);
setUnreadCount(count);
} catch {
setUnreadCount(0);
}
}
};
// Verificar sesión al cargar la app (Solo una vez)
useEffect(() => {
const initAuth = async () => {
try {
const sessionUser = await AuthService.checkSession();
if (sessionUser) {
setUser(sessionUser);
await fetchUnreadCount(); // <--- 5. LLAMAR AL CARGAR LA APP
} else {
setUser(null);
setUnreadCount(0);
}
} catch (error) {
setUser(null);
setUnreadCount(0);
} finally {
setLoading(false);
}
};
initAuth();
}, []);
const login = (userData: UserSession) => {
setUser(userData);
localStorage.setItem('userProfile', JSON.stringify(userData));
fetchUnreadCount();
};
const logout = () => {
AuthService.logout();
setUser(null);
setUnreadCount(0);
localStorage.removeItem('userProfile');
};
const refreshSession = async () => {
const sessionUser = await AuthService.checkSession();
setUser(sessionUser);
if (sessionUser) {
await fetchUnreadCount();
}
};
return (
<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');
}
return context;
}

127
Frontend/src/index.css Normal file
View File

@@ -0,0 +1,127 @@
@import "tailwindcss";
@theme {
--color-brand-primary: #00e5ff;
--color-brand-secondary: #0051ff;
--color-dark-bg: #0a0c10;
--color-dark-card: #161a22;
--animate-fade-in-up: fade-in-up 0.5s ease-out;
--animate-glow: glow 2s infinite alternate;
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes glow {
from {
box-shadow: 0 0 5px rgba(0, 229, 255, 0.2);
}
to {
box-shadow: 0 0 20px rgba(0, 229, 255, 0.6);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scale-up {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out forwards;
}
.animate-scale-up {
animation: scale-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
}
:root {
background-color: var(--color-dark-bg);
color: white;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
body {
margin: 0;
padding: 0;
}
/* Glassmorphism utility */
.glass {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0) 100%);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
}
.glass-card:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--color-brand-primary);
transform: translateY(-5px);
}
.text-gradient {
background: linear-gradient(to right, #00e5ff, #0051ff);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* FIX PARA AUTOCOMPLETE EN TEMA OSCURO */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
/* Pintamos el fondo usando una sombra interior sólida del color de tu input (#0a0c10) */
-webkit-box-shadow: 0 0 0 30px #0a0c10 inset !important;
/* Forzamos el color del texto a blanco */
-webkit-text-fill-color: white !important;
/* Mantenemos el color del cursor */
caret-color: white !important;
/* Forzamos que el borde se mantenga (opcional si usas border en tailwind) */
transition: background-color 5000s ease-in-out 0s;
}
/* Ocultar scrollbar manteniendo funcionalidad */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}

10
Frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,968 @@
import { useState, useEffect } from 'react';
import { AdminService } from '../services/admin.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 AdDetailsModal from '../components/AdDetailsModal';
import { Link } from 'react-router-dom';
type TabType = 'stats' | 'ads' | 'moderation' | 'transactions' | 'users' | 'audit';
export default function AdminPage() {
const [activeTab, setActiveTab] = useState<TabType>('stats');
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
// Estado para el modal de detalle técnico
const [selectedAdDetail, setSelectedAdDetail] = useState<any>(null);
// Estados para modales y selección
const [selectedAd, setSelectedAd] = useState<any>(null);
const [selectedUser, setSelectedUser] = useState<number | null>(null);
// Estados para filtros de Usuarios
const [userSearch, setUserSearch] = useState('');
const [userPage, setUserPage] = useState(1);
// Estado para filtros de Avisos
const [adsFilters, setAdsFilters] = useState({
q: '',
statusId: '',
page: 1
});
// Filtros de Auditoría
const [auditFilters, setAuditFilters] = useState({
actionType: '',
entity: '',
userId: '',
fromDate: new Date().toISOString().split('T')[0],
toDate: new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().split('T')[0],
page: 1
});
// Filtros de Transacciones
const [transactionFilters, setTransactionFilters] = useState({
status: '',
userSearch: '',
fromDate: new Date().toISOString().split('T')[0],
toDate: new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().split('T')[0],
page: 1
});
const handleRepublish = async (id: number) => {
if (!confirm('¿Estás seguro de que deseas republicar este aviso por 30 días más?')) return;
try {
await AdminService.republishAd(id);
alert('Aviso republicado con éxito.');
// Recargamos los datos de la pestaña actual para ver el cambio de estado
loadData();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al republicar el aviso.');
}
};
useEffect(() => {
loadData();
}, [activeTab]);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const handleTabChange = (tab: TabType) => {
if (tab === activeTab) return;
setLoading(true);
setData(null);
setActiveTab(tab);
setIsMobileMenuOpen(false);
};
const handleAuditFilter = () => {
if (auditFilters.toDate <= auditFilters.fromDate) {
alert("La fecha 'Hasta' debe ser al menos un día posterior a la fecha 'Desde'");
return;
}
setAuditFilters({ ...auditFilters, page: 1 });
loadData();
};
const handleTransactionFilter = () => {
if (transactionFilters.toDate <= transactionFilters.fromDate) {
alert("La fecha 'Hasta' debe ser al menos un día posterior a la fecha 'Desde'");
return;
}
setTransactionFilters({ ...transactionFilters, page: 1 });
loadData();
};
const loadData = async (pageOverride?: number, searchOverride?: string) => {
try {
let res;
const currentPage = pageOverride ?? userPage;
const currentSearch = searchOverride ?? userSearch;
switch (activeTab) {
case 'stats': res = await AdminService.getStats(); break;
case 'moderation': res = await AdminService.getPendingAds(); break;
case 'transactions': res = await AdminService.getTransactions(transactionFilters); break;
case 'users': res = await AdminService.getUsers(currentSearch, currentPage); break;
case 'audit': res = await AdminService.getAuditLogs({ ...auditFilters, userId: auditFilters.userId ? parseInt(auditFilters.userId) : undefined }); break;
case 'ads':
res = await AdminService.getAllAds({
q: adsFilters.q,
statusId: adsFilters.statusId ? parseInt(adsFilters.statusId) : undefined,
page: adsFilters.page
});
break;
}
setData(res);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleApprove = async (id: number) => {
if (!confirm('¿Aprobar este aviso?')) return;
try {
await AdminService.approveAd(id);
loadData();
} catch (err) { alert('Error al aprobar'); }
};
const handleToggleBlock = async (userId: number) => {
if (!confirm('¿Estás seguro de cambiar el estado de bloqueo de este usuario?')) return;
try {
await AdminService.toggleBlockUser(userId);
loadData();
} catch (err: any) {
alert(err.response?.data || 'Error al cambiar estado del usuario');
}
};
return (
<div className="container mx-auto px-4 md:px-6 py-8 md:py-12">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 md:mb-12 gap-6">
<div>
<h1 className="text-3xl md:text-5xl font-black tracking-tighter uppercase mb-2">Panel de <span className="text-blue-500">Control</span></h1>
<p className="text-[10px] md:text-xs text-gray-500 font-bold tracking-widest uppercase">Administración Central Motores Argentinos</p>
</div>
<div className="relative w-full md:w-auto">
<div className="md:hidden w-full group">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
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'}
</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 => (
<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'}
</button>
))}
</div>
)}
</div>
{/* 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 => (
<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'}
</button>
))}
</div>
</div>
</div>
{loading || !data ? (
<div className="flex justify-center p-40">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
) : (
<div className="animate-fade-in">
{/* === DASHBOARD REDISEÑADO === */}
{activeTab === 'stats' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Tarjeta de Acción: Moderación */}
<div
onClick={() => handleTabChange('moderation')}
className="col-span-1 md:col-span-2 glass p-8 rounded-[2.5rem] border border-amber-500/20 bg-gradient-to-br from-amber-500/5 to-transparent relative overflow-hidden group cursor-pointer hover:border-amber-500/40 transition-all"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-amber-500/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
<div className="flex justify-between items-start relative z-10">
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-3xl"></span>
<h3 className="text-sm font-black uppercase tracking-widest text-amber-400">Pendientes de Revisión</h3>
</div>
<p className="text-5xl font-black text-white tracking-tighter mb-1">{data.pendingAds}</p>
<p className="text-xs text-gray-400 font-medium">Avisos esperando aprobación manual</p>
</div>
<div className="w-10 h-10 rounded-full border border-amber-500/30 flex items-center justify-center text-amber-400 group-hover:bg-amber-500 group-hover:text-white transition-all">
</div>
</div>
</div>
{/* Tarjeta de Acción: Mensajes */}
<div
className="col-span-1 md:col-span-2 glass p-8 rounded-[2.5rem] border border-blue-500/20 bg-gradient-to-br from-blue-500/5 to-transparent relative overflow-hidden group transition-all"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
<div className="flex justify-between items-start relative z-10">
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-3xl">💬</span>
<h3 className="text-sm font-black uppercase tracking-widest text-blue-400">Mensajes Sin Leer</h3>
</div>
<div className="flex items-baseline gap-2">
<p className="text-5xl font-black text-white tracking-tighter mb-1">{data.unreadMessages}</p>
{data.unreadMessages > 0 && <span className="text-[10px] bg-red-500 text-white px-2 py-0.5 rounded-full font-bold animate-pulse">NUEVOS</span>}
</div>
<p className="text-xs text-gray-400 font-medium">Interacciones pendientes de respuesta</p>
</div>
</div>
</div>
{/* Métricas Informativas (Fila Inferior) */}
<DashboardMiniCard label="Total Avisos Histórico" value={data.totalAds} icon="🚗" />
<DashboardMiniCard label="Avisos Activos Hoy" value={data.activeAds} icon="✅" color="green" />
<DashboardMiniCard label="Usuarios Registrados" value={data.totalUsers} icon="👥" />
<DashboardMiniCard label="Versión Sistema" value="v2.1.0" icon="🖥️" color="gray" />
</div>
)}
{/* VISTA DE GESTIÓN DE AVISOS */}
{activeTab === 'ads' && data.ads && (
<div className="space-y-8">
{/* Filtros */}
<div className="flex flex-col md:flex-row gap-4 items-center justify-between bg-white/5 p-5 md:p-6 rounded-2xl md:rounded-[2rem] border border-white/5 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 por auto, marca, usuario..."
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-xl md:rounded-2xl 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">🔍</span>
</div>
<div className="w-full md:w-64">
<select
value={adsFilters.statusId}
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 key={id} value={id} className="bg-gray-900">{config.label}</option>
))}
</select>
</div>
</div>
<button
onClick={() => { setAdsFilters({ ...adsFilters, page: 1 }); loadData(); }}
className="w-full md:w-auto bg-blue-600 hover:bg-blue-500 text-white px-8 py-3 md:py-4 rounded-xl md:rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95"
>
Filtrar
</button>
</div>
{/* Lista de Avisos (Escritorio / Tabla) */}
<div className="hidden md:block glass rounded-[2.5rem] overflow-hidden border border-white/5">
<table className="w-full text-left">
<thead className="bg-white/5">
<tr>
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-gray-500">Aviso</th>
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-gray-500">Usuario</th>
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-gray-500">Estado</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 statusConfig = STATUS_CONFIG[ad.statusID] || { label: 'Desc.', bg: 'bg-gray-500', color: 'text-white' };
return (
<tr key={ad.adID} className="hover:bg-white/5 transition-colors">
<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="" />
<div>
<span className="text-sm font-black text-white uppercase tracking-tight block mb-1">{ad.title}</span>
<span className="text-xs text-gray-500 font-medium">ID: #{ad.adID} {parseUTCDate(ad.createdAt).toLocaleDateString()}</span>
</div>
</div>
</td>
<td className="px-8 py-5">
<div className="flex flex-col">
<span className="text-sm font-bold text-white">{ad.userName}</span>
<span className="text-xs text-gray-500">{ad.userEmail}</span>
</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>
</td>
<td className="px-8 py-5 text-right">
<div className="flex flex-col items-end gap-2">
<button
onClick={() => setSelectedAdDetail(ad)}
className="bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest border border-white/10 transition-all flex items-center gap-2 w-full justify-center"
>
<span></span> Info Técnica
</button>
<Link
to={`/publicar?edit=${ad.adID}`}
className="bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest border border-white/10 transition-all flex items-center gap-2 w-full justify-center"
>
<span></span> Editar
</Link>
{ad.statusID === 8 && (
<button
onClick={() => handleRepublish(ad.adID)}
className="bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 hover:text-amber-300 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest border border-amber-500/20 transition-all flex items-center gap-2 w-full justify-center"
>
<span>🔄</span> Republicar
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 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' };
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">
<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">
<span className="text-sm font-black text-white uppercase tracking-tight block truncate mb-1">{ad.title}</span>
<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>
</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">ID / Fecha</span>
<span className="text-[10px] text-white font-medium">#{ad.adID} {parseUTCDate(ad.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex flex-col">
<span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Usuario</span>
<span className="text-[10px] text-white font-medium truncate">{ad.userName}</span>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => setSelectedAdDetail(ad)}
className="flex-1 bg-white/5 py-3 rounded-xl border border-white/10 text-[9px] font-black uppercase tracking-widest text-gray-300 active:scale-95 transition-all text-center"
>
Info
</button>
<Link
to={`/publicar?edit=${ad.adID}`}
className="flex-1 bg-white/5 py-3 rounded-xl border border-white/10 text-[9px] font-black uppercase tracking-widest text-gray-300 active:scale-95 transition-all text-center"
>
Editar
</Link>
{ad.statusID === 8 && (
<button
onClick={() => handleRepublish(ad.adID)}
className="flex-1 bg-amber-500/10 py-3 rounded-xl border border-amber-500/20 text-[9px] font-black uppercase tracking-widest text-amber-400 active:scale-95 transition-all text-center"
>
Republicar
</button>
)}
</div>
</div>
);
})}
</div>
{data.ads.length === 0 && (
<div className="p-12 md:p-20 text-center glass rounded-[2.5rem] border border-white/5">
<p className="text-gray-500 font-bold uppercase tracking-widest">No se encontraron avisos.</p>
</div>
)}
{/* Paginación */}
{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"
>
Anterior
</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">
Página {data.page} de {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"
>
Siguiente
</button>
</div>
)}
</div>
)}
{/* VISTA MODERACIÓN */}
{activeTab === 'moderation' && Array.isArray(data) && (
<div className="space-y-6">
{data.length === 0 ? (
<div className="glass p-20 text-center rounded-[2.5rem] border-dashed border-2 border-white/5">
<p className="text-gray-500 font-bold uppercase tracking-widest">No hay avisos pendientes de moderación</p>
</div>
) : (
data.map((ad: any) => (
<div key={ad.adID} className="glass p-6 rounded-3xl border border-white/5 flex flex-col md:flex-row gap-8 items-center group hover:border-blue-500/30 transition-all">
<img src={ad.thumbnail?.startsWith('http') ? ad.thumbnail : `${import.meta.env.VITE_STATIC_BASE_URL}${ad.thumbnail}`} className="w-40 h-24 object-cover rounded-2xl shadow-xl" alt="" />
<div className="flex-1">
<h3 className="text-xl font-bold uppercase tracking-tight">{ad.versionName}</h3>
<p className="text-sm text-gray-500 font-medium">Publicado por {ad.userName} ({ad.email}) {parseUTCDate(ad.createdAt).toLocaleDateString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</p>
<p className="text-blue-400 font-black mt-1 text-lg">{ad.currency} {ad.price.toLocaleString()}</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setSelectedAd(ad)}
className="bg-blue-600 hover:bg-blue-500 text-white px-8 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20"
>
Revisar & Moderar
</button>
</div>
</div>
))
)}
</div>
)}
{/* VISTA TRANSACTIONS */}
{activeTab === 'transactions' && data.transactions && (
<div className="space-y-8">
{/* Filtros de Transacciones */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 bg-white/5 p-5 md:p-6 rounded-2xl md:rounded-[2rem] border border-white/5 backdrop-blur-xl">
<div className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Estado</label>
<select
value={transactionFilters.status}
onChange={e => setTransactionFilters({ ...transactionFilters, status: 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-white">Todos</option>
<option value="APPROVED" className="bg-gray-900 text-white">Aprobado</option>
<option value="PENDING" className="bg-gray-900 text-white">Pendiente</option>
<option value="REJECTED" className="bg-gray-900 text-white">Rechazado</option>
</select>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Usuario</label>
<input
type="text"
placeholder="Email o usuario..."
value={transactionFilters.userSearch}
onChange={e => setTransactionFilters({ ...transactionFilters, userSearch: 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 className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Desde</label>
<input
type="date"
value={transactionFilters.fromDate}
onClick={(e) => e.currentTarget.showPicker()}
onChange={e => {
const newFrom = e.target.value;
const nextDay = new Date(new Date(newFrom + 'T12:00:00').setDate(new Date(newFrom + 'T12:00:00').getDate() + 1)).toISOString().split('T')[0];
let newTo = transactionFilters.toDate;
if (newTo <= newFrom) newTo = nextDay;
setTransactionFilters({ ...transactionFilters, fromDate: newFrom, toDate: newTo });
}}
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 [color-scheme:dark] cursor-pointer"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Hasta</label>
<input
type="date"
value={transactionFilters.toDate}
onClick={(e) => e.currentTarget.showPicker()}
min={new Date(new Date(transactionFilters.fromDate + 'T12:00:00').setDate(new Date(transactionFilters.fromDate + 'T12:00:00').getDate() + 1)).toISOString().split('T')[0]}
onChange={e => setTransactionFilters({ ...transactionFilters, toDate: 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 [color-scheme:dark] cursor-pointer"
/>
</div>
<div className="flex items-end">
<button
onClick={handleTransactionFilter}
className="w-full bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95"
>
Filtrar
</button>
</div>
</div>
<div className="hidden md:block glass rounded-[2.5rem] overflow-hidden border border-white/5">
<table className="w-full text-left">
<thead className="bg-white/5">
<tr>
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Fecha</th>
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Usuario</th>
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Detalle</th>
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Operación</th>
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Monto</th>
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Estado</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{data.transactions.map((tx: any) => (
<tr key={tx.transactionID} className="hover:bg-white/5 transition-colors">
<td className="px-8 py-4 text-xs font-medium text-gray-400">
<div className="flex flex-col">
<span>{parseUTCDate(tx.createdAt).toLocaleDateString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span>
<span className="text-[10px] opacity-50">{parseUTCDate(tx.createdAt).toLocaleTimeString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span>
</div>
</td>
<td className="px-8 py-4">
<div className="flex flex-col">
<span className="text-xs font-black text-white uppercase tracking-tight">{tx.userName}</span>
<span className="text-[10px] text-gray-500 font-medium">{tx.userEmail}</span>
</div>
</td>
<td className="px-8 py-4">
<span className="text-xs text-blue-300 font-bold uppercase tracking-tight">{tx.adTitle}</span>
<span className="text-[9px] text-gray-600 block">ID: {tx.adID}</span>
</td>
<td className="px-8 py-4 text-xs font-black uppercase text-white">{tx.operationCode}</td>
<td className="px-8 py-4 text-xs font-black text-green-400">${tx.amount.toLocaleString()}</td>
<td className="px-8 py-4">
<span className={`px-3 py-1 rounded-full text-[8px] font-black uppercase tracking-tighter ${tx.status === 'APPROVED' ? 'bg-green-500/20 text-green-500 border border-green-500/20' : tx.status === 'REJECTED' ? 'bg-red-500/20 text-red-500 border border-red-500/20' : 'bg-amber-500/20 text-amber-500 border border-amber-500/20'}`}>
{tx.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Transacciones (Móvil) */}
<div className="md:hidden space-y-4">
{data.transactions.map((tx: any) => (
<div key={tx.transactionID} className="glass p-5 rounded-3xl border border-white/5 space-y-4 shadow-xl">
<div className="flex justify-between items-start">
<div className="flex flex-col">
<span className="text-[8px] font-black uppercase tracking-widest text-gray-500">Operación</span>
<span className="text-xs font-black text-white uppercase leading-none">{tx.operationCode}</span>
</div>
<span className={`px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-tighter ${tx.status === 'APPROVED' ? 'bg-green-500/20 text-green-500 border border-green-500/20' : tx.status === 'REJECTED' ? 'bg-red-500/20 text-red-500 border border-red-500/20' : 'bg-amber-500/20 text-amber-500 border border-amber-500/20'}`}>
{tx.status}
</span>
</div>
<div className="py-3 border-y border-white/5 space-y-2">
<p className="text-[11px] font-bold text-blue-300 uppercase leading-tight truncate">{tx.adTitle}</p>
<div className="flex justify-between items-center text-[10px]">
<span className="text-gray-500">{parseUTCDate(tx.createdAt).toLocaleDateString()}</span>
<span className="text-green-400 font-black tracking-widest">${tx.amount.toLocaleString()}</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-600/20 flex items-center justify-center text-[10px] text-blue-400 font-black">
{tx.userName[0].toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-[10px] text-white font-bold leading-none">{tx.userName}</span>
<span className="text-[9px] text-gray-500 font-medium truncate max-w-[150px]">{tx.userEmail}</span>
</div>
</div>
</div>
))}
</div>
{data.transactions.length === 0 && (
<div className="p-12 text-center glass rounded-3xl border border-white/5">
<p className="text-gray-500 font-bold uppercase tracking-widest">Sin transacciones</p>
</div>
)}
{/* Paginación Transacciones */}
{data.total > data.pageSize && (
<div className="flex justify-center gap-4 mt-8">
<button
disabled={transactionFilters.page === 1}
onClick={() => { const p = transactionFilters.page - 1; setTransactionFilters({ ...transactionFilters, 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"
>
Anterior
</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 font-mono">
{data.page} / {Math.ceil(data.total / data.pageSize)}
</div>
<button
disabled={transactionFilters.page >= Math.ceil(data.total / data.pageSize)}
onClick={() => { const p = transactionFilters.page + 1; setTransactionFilters({ ...transactionFilters, 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"
>
Siguiente
</button>
</div>
)}
</div>
)}
{activeTab === 'users' && data.users && (
<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-white/5 backdrop-blur-xl">
<div className="relative w-full md:max-w-md group">
<input
type="text"
placeholder="Buscar por email, nombre o usuario..."
value={userSearch}
onChange={e => setUserSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && loadData(1)}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-12 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>
<button
onClick={() => { setUserPage(1); loadData(1); }}
className="w-full md:w-auto bg-blue-600 hover:bg-blue-500 text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95"
>
Buscar Usuarios
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.users.map((u: any) => (
<div key={u.userID} className={`glass p-8 rounded-[2rem] border transition-all ${u.isBlocked ? 'border-red-500/50 bg-red-900/10' : 'border-white/5 hover:border-blue-500/20'}`}>
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-blue-600/20 rounded-full flex items-center justify-center text-xl text-blue-400 font-bold">
{u.userName ? u.userName[0].toUpperCase() : '?'}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-black uppercase tracking-tight text-white mb-0.5 truncate">{u.userName}</h4>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest">{u.userType === 3 ? '🛡️ Administrador' : '👤 Particular'}</p>
</div>
<button onClick={() => setSelectedUser(u.userID)} className="w-8 h-8 rounded-lg bg-white/5 hover:bg-blue-600/20 text-gray-400 hover:text-blue-400 flex items-center justify-center transition-all border border-white/5"></button>
</div>
<div className="space-y-2 text-xs font-medium text-gray-400">
<p className="flex justify-between gap-2 overflow-hidden"><span>Email:</span> <span className="text-white truncate">{u.email}</span></p>
<p className="flex justify-between gap-2"><span>Nombre:</span> <span className="text-white truncate">{u.firstName} {u.lastName}</span></p>
<p className="flex justify-between"><span>Registro:</span> <span className="text-white">{parseUTCDate(u.createdAt).toLocaleDateString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span></p>
</div>
<div className="mt-4 pt-4 border-t border-white/5 flex justify-between items-center">
<span className={`text-[10px] font-black uppercase tracking-widest ${u.isBlocked ? 'text-red-400' : 'text-green-400'}`}>
{u.isBlocked ? 'BLOQUEADO' : 'ACTIVO'}
</span>
<button
onClick={() => handleToggleBlock(u.userID)}
className={`px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all ${u.isBlocked ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}
>
{u.isBlocked ? 'Desbloquear' : 'Bloquear'}
</button>
</div>
</div>
))}
</div>
{data.total > data.pageSize && (
<div className="flex justify-center gap-4 mt-8">
<button
disabled={userPage === 1}
onClick={() => { const p = userPage - 1; setUserPage(p); loadData(p); }}
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
>
Anterior
</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">
Página {userPage} de {Math.ceil(data.total / data.pageSize)}
</div>
<button
disabled={userPage >= Math.ceil(data.total / data.pageSize)}
onClick={() => { const p = userPage + 1; setUserPage(p); loadData(p); }}
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
>
Siguiente
</button>
</div>
)}
</div>
)}
{activeTab === 'audit' && data.logs && (
<div className="space-y-8">
{/* Filtros de Auditoría */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-4 bg-white/5 p-5 md:p-6 rounded-2xl md:rounded-[2rem] border border-white/5 backdrop-blur-xl">
<div className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Evento</label>
<input
type="text"
placeholder="Ej: AD_CREATED"
value={auditFilters.actionType}
onChange={e => setAuditFilters({ ...auditFilters, actionType: 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 className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Entidad</label>
<select
value={auditFilters.entity}
onChange={e => setAuditFilters({ ...auditFilters, entity: 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-white">Todas</option>
<option value="Ad" className="bg-gray-900 text-white">Avisos</option>
<option value="User" className="bg-gray-900 text-white">Usuarios</option>
<option value="Transaction" className="bg-gray-900 text-white">Pagos</option>
</select>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">ID Usuario</label>
<input
type="number"
placeholder="ID..."
value={auditFilters.userId}
onChange={e => setAuditFilters({ ...auditFilters, userId: 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 className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Desde</label>
<input
type="date"
value={auditFilters.fromDate}
onClick={(e) => e.currentTarget.showPicker()}
onChange={e => {
const newFrom = e.target.value;
const nextDay = new Date(new Date(newFrom + 'T12:00:00').setDate(new Date(newFrom + 'T12:00:00').getDate() + 1)).toISOString().split('T')[0];
let newTo = auditFilters.toDate;
if (newTo <= newFrom) newTo = nextDay;
setAuditFilters({ ...auditFilters, fromDate: newFrom, toDate: newTo });
}}
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 [color-scheme:dark] cursor-pointer"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-2">Hasta</label>
<input
type="date"
value={auditFilters.toDate}
onClick={(e) => e.currentTarget.showPicker()}
min={new Date(new Date(auditFilters.fromDate + 'T12:00:00').setDate(new Date(auditFilters.fromDate + 'T12:00:00').getDate() + 1)).toISOString().split('T')[0]}
onChange={e => setAuditFilters({ ...auditFilters, toDate: 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 [color-scheme:dark] cursor-pointer"
/>
</div>
<div className="flex items-end">
<button
onClick={handleAuditFilter}
className="w-full bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95"
>
Filtrar
</button>
</div>
</div>
<div className="hidden md:block glass rounded-[2.5rem] overflow-hidden border border-white/5">
<table className="w-full text-left">
<thead className="bg-white/5">
<tr>
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Fecha</th>
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Acción</th>
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Entidad</th>
<th className="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-gray-500">Detalles</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{data.logs.map((log: any) => (
<tr key={log.auditLogID} className="hover:bg-white/5 transition-colors">
<td className="px-8 py-4 text-xs font-medium text-gray-400 whitespace-nowrap">
<div className="flex flex-col">
<span>{parseUTCDate(log.createdAt).toLocaleDateString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span>
<span className="text-[10px] opacity-50">{parseUTCDate(log.createdAt).toLocaleTimeString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', hour12: false })}</span>
</div>
</td>
<td className="px-8 py-4">
<span className={`px-2 py-1 rounded-lg text-[8px] font-black uppercase tracking-tighter shadow-sm
${log.action.includes('SUCCESS') || log.action.includes('APPROVED') || log.action.includes('CREATED') ? 'bg-green-500/10 text-green-400 border border-green-500/20' :
log.action.includes('REJECTED') || log.action.includes('DELETED') || log.action.includes('BLOCKED') ? 'bg-red-500/10 text-red-400 border border-red-500/20' :
'bg-blue-500/10 text-blue-400 border border-blue-500/20'}`}>
{log.action}
</span>
</td>
<td className="px-8 py-4">
<div className="flex flex-col">
<span className="text-xs font-black text-gray-300 uppercase tracking-tight">{log.entity}</span>
<span className="text-[10px] text-blue-500 font-bold">ID: #{log.entityID}</span>
</div>
</td>
<td className="px-8 py-4">
<div className="max-w-md">
<p className="text-xs text-gray-400 leading-relaxed">{log.details}</p>
{log.userID > 0 && <span className="text-[9px] text-gray-600 font-bold uppercase mt-1 block">Por {log.userName} (ID: {log.userID})</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Auditoría (Móvil) */}
<div className="md:hidden space-y-4">
{data.logs.map((log: any) => (
<div key={log.auditLogID} className="glass p-5 rounded-3xl border border-white/5 space-y-4 shadow-xl">
<div className="flex justify-between items-start">
<div className="flex flex-col">
<span className="text-[8px] font-black uppercase tracking-widest text-gray-500">{log.entity} ID: #{log.entityID}</span>
<span className={`inline-block mt-1 px-2 py-1 rounded-lg text-[8px] font-black uppercase tracking-tighter
${log.action.includes('SUCCESS') || log.action.includes('APPROVED') || log.action.includes('CREATED') ? 'bg-green-500/10 text-green-400 border border-green-500/20' :
log.action.includes('REJECTED') || log.action.includes('DELETED') || log.action.includes('BLOCKED') ? 'bg-red-500/10 text-red-400 border border-red-500/20' :
'bg-blue-500/10 text-blue-400 border border-blue-500/20'}`}>
{log.action}
</span>
</div>
<div className="text-right">
<p className="text-[10px] text-white font-medium">{parseUTCDate(log.createdAt).toLocaleDateString()}</p>
<p className="text-[8px] text-gray-600 uppercase font-black">{parseUTCDate(log.createdAt).toLocaleTimeString()}</p>
</div>
</div>
<div className="p-3 bg-white/5 rounded-2xl">
<p className="text-[10px] text-gray-400 leading-relaxed italic">"{log.details}"</p>
</div>
{log.userID > 0 ? (
<div className="flex items-center gap-2 pt-1">
<span className="text-[8px] font-black text-gray-500 uppercase">Ejecutado por:</span>
<span className="text-[9px] text-blue-400 font-bold uppercase tracking-tight">{log.userName}</span>
</div>
) : (
<span className="text-[8px] text-indigo-400 font-black uppercase tracking-widest">🤖 SISTEMA</span>
)}
</div>
))}
</div>
{data.logs.length === 0 && (
<div className="p-12 text-center glass rounded-3xl border border-white/5">
<p className="text-gray-500 font-bold uppercase tracking-widest">Sin registros</p>
</div>
)}
{/* Paginación Auditoría */}
{data.total > data.pageSize && (
<div className="flex justify-center gap-4 mt-8">
<button
disabled={auditFilters.page === 1}
onClick={() => { const p = auditFilters.page - 1; setAuditFilters({ ...auditFilters, 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"
>
Anterior
</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 font-mono">
{data.page} / {Math.ceil(data.total / data.pageSize)}
</div>
<button
disabled={auditFilters.page >= Math.ceil(data.total / data.pageSize)}
onClick={() => { const p = auditFilters.page + 1; setAuditFilters({ ...auditFilters, 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"
>
Siguiente
</button>
</div>
)}
</div>
)}
</div>
)}
{/* MODAL DETALLE TÉCNICO */}
{selectedAdDetail && (
<AdDetailsModal
ad={selectedAdDetail}
onClose={() => setSelectedAdDetail(null)}
/>
)}
{/* MODAL DE MODERACIÓN */}
{selectedAd && (
<ModerationModal
adSummary={selectedAd}
onClose={() => setSelectedAd(null)}
onApprove={(id: number) => {
handleApprove(id);
setSelectedAd(null);
}}
/>
)}
{/* MODAL DE USUARIO */}
{selectedUser && (
<UserModal
userId={selectedUser}
onClose={() => setSelectedUser(null)}
onUpdate={loadData}
/>
)}
</div>
);
}
// Componente para tarjetas pequeñas del dashboard
function DashboardMiniCard({ label, value, icon, color = 'blue' }: { label: string, value: any, icon: string, color?: string }) {
const colors: any = {
blue: 'border-white/5',
green: 'border-green-500/20 bg-green-500/5',
gray: 'border-white/5 opacity-60'
};
return (
<div className={`glass p-6 rounded-3xl border flex items-center gap-4 ${colors[color]}`}>
<div className="text-2xl">{icon}</div>
<div>
<p className="text-2xl font-black text-white leading-none">{value}</p>
<p className="text-[9px] font-black uppercase tracking-widest text-gray-500 mt-1">{label}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,346 @@
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';
export default function ExplorarPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [listings, setListings] = useState<AdListingDto[]>([]);
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 [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';
useEffect(() => {
if (c !== 'ALL') {
const typeId = c === 'EAUTOS' ? 1 : 2;
AdsV2Service.getBrands(typeId).then(setBrands);
} else {
setBrands([]);
}
}, [c]);
useEffect(() => {
if (brandId) {
AdsV2Service.getModels(Number(brandId)).then(setModels);
} else {
setModels([]);
}
}, [brandId]);
const [showMobileFilters, setShowMobileFilters] = useState(false);
useEffect(() => {
const fetchListings = async () => {
setLoading(true);
setError(null);
try {
const data = await AdsV2Service.getAll({
q,
c: c === 'ALL' ? undefined : c,
minPrice: minPrice ? Number(minPrice) : undefined,
maxPrice: maxPrice ? Number(maxPrice) : undefined,
currency: currencyFilter || undefined,
minYear: minYear ? Number(minYear) : undefined,
maxYear: maxYear ? Number(maxYear) : undefined,
brandId: brandId ? Number(brandId) : undefined,
modelId: modelId ? Number(modelId) : undefined,
fuel: fuel || undefined,
transmission: transmission || undefined
});
setListings(data);
} catch (err) {
setError("Error al cargar los avisos");
} finally {
setLoading(false);
}
};
fetchListings();
if (showMobileFilters) setShowMobileFilters(false);
}, [searchParams]);
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');
setSearchParams(newParams);
};
const clearFilters = () => {
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);
setSearchParams(newParams);
};
const handleCategoryFilter = (cat: string) => {
const newParams = new URLSearchParams();
if (q) newParams.set('q', q);
if (cat !== 'ALL') newParams.set('c', cat);
setSearchParams(newParams);
};
return (
<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'}`}
>
<span>🔍 FILTRAR</span>
</button>
{/* Sidebar Filters - NATURAL FLOW (NO STICKY, NO SCROLL INTERNO) */}
<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="
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>
<div className="flex items-center gap-2">
<button
onClick={clearFilters}
className="text-[10px] font-black uppercase tracking-widest text-white/50 hover:text-white px-3 py-2 rounded-lg border border-white/10 hover:bg-white/5 transition-all"
>
Limpiar
</button>
<button
onClick={() => setShowMobileFilters(false)}
className="md:hidden bg-red-500/10 text-red-400 hover:bg-red-500 hover:text-white px-3 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all border border-red-500/20"
>
Cerrar
</button>
</div>
</div>
<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>
<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>
</select>
</div>
{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>
<SearchableSelect
options={brands}
value={brandId}
onChange={(val) => setBrandId(val)}
placeholder="Todas las marcas"
/>
</div>
{brandId && (
<div className="animate-fade-in">
<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)}
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>
))}
</select>
</div>
)}
</div>
)}
<div>
<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)}
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>
</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" />
</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" />
</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>))}
</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>
))}
</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" />
</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" />
</div>
<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>)}
</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>
))}
</select>
</div>
</div>
{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" />
</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" />
</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">
Aplicar Filtros
</button>
</div>
</aside>
<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>
<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>
</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">
{listings.length} vehículos
</span>
</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>
) : 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>
) : 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>
</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">
<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" />
{/* --- BLOQUE PARA EL BADGE --- */}
<div className="absolute top-4 left-4 z-10">
<AdStatusBadge statusId={car.statusId || 4} />
</div>
{car.isFeatured && (
<div className="absolute top-4 right-4 bg-blue-600 text-white px-4 py-1.5 rounded-full text-[8px] font-black uppercase tracking-widest shadow-lg animate-glow">
Destacado
</div>
)}
</div>
<div className="p-4 md:p-8 flex-1 flex flex-col">
<h3 className="text-2xl font-bold text-white group-hover:text-blue-400 transition-colors uppercase tracking-tight truncate mb-2">
{car.brandName} {car.versionName}
</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>
</div>
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div >
);
}

View File

@@ -0,0 +1,212 @@
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 [featuredAds, setFeaturedAds] = useState<AdListingDto[]>([]);
const [loading, setLoading] = useState(true);
// --- ESTADOS PARA SUGERENCIAS ---
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const searchWrapperRef = useRef<HTMLDivElement>(null);
// Cargar destacados
useEffect(() => {
AdsV2Service.getAll({ isFeatured: true })
.then(data => {
setFeaturedAds(data.slice(0, 3));
})
.catch(err => console.error("Error cargando destacados:", err))
.finally(() => setLoading(false));
}, []);
// --- LÓGICA PARA BUSCAR SUGERENCIAS ---
useEffect(() => {
if (query.length < 2) {
setSuggestions([]);
return;
}
const timer = setTimeout(() => {
AdsV2Service.getSearchSuggestions(query)
.then(setSuggestions)
.catch(console.error);
}, 300); // Espera 300ms antes de buscar
return () => clearTimeout(timer);
}, [query]);
// --- LÓGICA PARA CERRAR SUGERENCIAS AL HACER CLIC FUERA ---
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (searchWrapperRef.current && !searchWrapperRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Función de búsqueda actualizada para aceptar un término opcional
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}`;
navigate(`/explorar?q=${searchTerm}${categoryParam}`);
};
// Función para manejar el clic en una sugerencia
const handleSuggestionClick = (suggestion: string) => {
setQuery(suggestion);
setSuggestions([]);
handleSearch(suggestion); // Realizar la búsqueda inmediatamente
};
return (
<div className="flex flex-col gap-5 pb-10 md:pb-20">
{/* Hero Section */}
<section className="relative min-h-[60vh] md:h-[80vh] flex items-center justify-center overflow-hidden rounded-2xl md:rounded-3xl mx-2 md:mx-4 mt-2 md:mt-4 shadow-2xl">
<div className="absolute inset-0 z-0">
<img
src="./bg-car.jpg"
className="w-full h-full object-cover opacity-40"
alt="Hero background"
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#0a0c10]/50 to-[#0a0c10]"></div>
</div>
<div className="relative z-10 text-center px-4 max-w-4xl animate-fade-in-up">
{/* Título optimizado para móvil */}
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-6xl xl:text-7xl font-black mb-4 md:mb-6 tracking-tighter leading-tight">
ENCUENTRA 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 plataforma más avanzada para la compra y venta de Automóviles y Motos en Argentina.
Integración total con medios impresos y digitales.
</p>
{/* --- CONTENEDOR DEL BUSCADOR CON ref y onFocus --- */}
<div className="relative max-w-3xl mx-auto" ref={searchWrapperRef}>
{/* 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'}`}
>
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'}`}
>
🚗 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'}`}
>
🏍 Motos
</button>
</div>
<div className="glass p-2 md:p-2 rounded-xl md:rounded-2xl flex flex-col md:flex-row gap-2 shadow-2xl">
<input
type="text"
placeholder="Marca o modelo..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setShowSuggestions(true)}
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
onClick={() => handleSearch()}
className="bg-blue-600 hover:bg-blue-500 text-white px-6 md:px-10 py-3 md:py-4 rounded-lg md:rounded-xl font-bold text-sm md:text-base transition-all transform hover:scale-105 shadow-lg shadow-blue-600/20"
>
BUSCAR
</button>
</div>
{/* --- DROPDOWN DE SUGERENCIAS --- */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full w-full mt-2 bg-[#1a1d24]/90 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl overflow-hidden z-20 animate-fade-in">
<ul>
{suggestions.map((s, i) => (
<li
key={i}
onClick={() => handleSuggestionClick(s)}
className="px-6 py-3 text-left text-white hover:bg-blue-600 cursor-pointer transition-colors border-b border-white/5 last:border-0"
>
{s}
</li>
))}
</ul>
</div>
)}
</div>
</div>
</section>
{/* Featured Grid Dinámica */}
<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>
</div>
<Link to="/explorar" className="text-blue-400 hover:text-white transition text-sm md:text-base">Ver todos </Link>
</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>
) : 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>
</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">
<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}`}
/>
<div className="absolute top-4 left-4 z-10">
<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 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>
</div>
</div>
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<h3 className="text-2xl font-bold text-white group-hover:text-blue-400 transition-colors uppercase tracking-tight truncate w-full">
{car.brandName} {car.versionName}
</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>
</div>
</div>
</Link>
))}
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,607 @@
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';
type TabType = 'avisos' | 'favoritos' | 'mensajes';
export default function MisAvisosPage() {
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 [selectedChat, setSelectedChat] = useState<{ adId: number, name: string, otherUserId: 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
});
// 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') {
alert("¡Pago confirmado! El aviso pasará a moderación.");
cargarAvisos(user!.id);
} 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 {
alert("El pago sigue pendiente de aprobación por la tarjeta.");
}
} catch (e) {
alert("Error verificando el pago.");
}
};
useEffect(() => {
if (user) {
cargarMensajes(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 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?';
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.';
}
// 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?';
}
// 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?';
}
// 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.';
}
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 }); // Cerrar modal primero
await AdsV2Service.changeStatus(adId, newStatus);
if (user) cargarAvisos(user.id);
} catch (error) {
alert('Error al actualizar estado');
}
};
const cargarAvisos = async (userId: number) => {
setLoading(true);
try {
const data = await AdsV2Service.getAll({ userId });
setAvisos(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const cargarFavoritos = async (userId: number) => {
setLoading(true);
try {
const data = await AdsV2Service.getFavorites(userId);
setFavoritos(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const cargarMensajes = async (userId: number) => {
try {
const data = await ChatService.getInbox(userId);
setMensajes(data);
} catch (error) {
console.error(error);
}
};
// Marcar como leídos en DB
const openChatForAd = async (adId: number, adTitle: string) => {
if (!user) return;
const relatedMsg = mensajes.find(m => m.adID === adId);
if (relatedMsg) {
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);
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
));
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()
);
// Espera a que TODAS las llamadas al backend terminen
await Promise.all(markAsReadPromises);
// 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
}
}
// Abrir el modal de chat
setSelectedChat({ adId, name: adTitle, otherUserId: otherId });
} else {
alert("No tienes mensajes activos para este aviso.");
}
};
const handleRemoveFavorite = async (adId: number) => {
if (!user) return;
try {
await AdsV2Service.removeFavorite(user.id, adId);
cargarFavoritos(user.id);
} catch (error) {
console.error(error);
}
};
const handleCloseChat = () => {
setSelectedChat(null);
if (user) {
cargarMensajes(user.id); // Recarga la lista de mensajes por si llegaron nuevos mientras estaba abierto
}
};
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">
<span className="text-7xl mb-8 block">🔒</span>
<h2 className="text-5xl font-black mb-4 uppercase tracking-tighter">Área Privada</h2>
<p className="text-gray-400 mb-10 text-lg italic">
Para gestionar tus publicaciones, primero debes 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>
</div>
);
}
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>
<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>
</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 => (
<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'}`}
>
{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' && (
<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="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' && (
<div className="space-y-6">
{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>
</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);
return (
// 'relative z-index' dinámico
// Esto permite que el dropdown se salga de la tarjeta sin cortarse.
// Usamos un z-index decreciente para que los dropdowns de arriba tapen a las tarjetas de abajo.
<div
key={av.id}
className="glass p-6 rounded-[2.5rem] flex flex-col md:flex-row items-center gap-8 border border-white/5 hover:border-blue-500/20 transition-all relative"
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}`} />
<div className="absolute top-3 left-3 bg-black/60 backdrop-blur-md px-2 py-1 rounded-lg border border-white/10">
<span className="text-[9px] font-bold text-white">#{av.id}</span>
</div>
</div>
<div className="flex-1 w-full text-center md:text-left">
<div className="mb-3">
<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>
</div>
<div className="flex flex-wrap gap-3 justify-center md:justify-start">
<div className="bg-white/5 border border-white/5 px-3 py-1.5 rounded-lg flex items-center gap-2">
<span className="text-[10px] text-gray-500 font-bold uppercase">Año</span>
<span className="text-xs text-white font-bold">{av.year}</span>
</div>
<div className="bg-white/5 border border-white/5 px-3 py-1.5 rounded-lg flex items-center gap-2">
<span className="text-[10px] text-gray-500 font-bold uppercase">Visitas</span>
<span className="text-xs text-white font-bold">{av.viewsCounter || 0}</span>
</div>
{av.isFeatured && (
<div className="bg-blue-600/20 border border-blue-500/30 px-3 py-1.5 rounded-lg">
<span className="text-[9px] text-blue-300 font-black uppercase tracking-widest"> Destacado</span>
</div>
)}
</div>
</div>
<div className="w-full md:w-auto flex flex-col gap-3 min-w-[180px]">
{/* CASO 1: BORRADOR (1) -> Botón de Pagar */}
{av.statusId === AD_STATUSES.DRAFT && (
<Link
to={`/publicar?edit=${av.id}`}
className="bg-blue-600 hover:bg-blue-500 text-white text-xs font-black uppercase tracking-widest rounded-xl px-4 py-3 text-center shadow-lg shadow-blue-600/20 transition-all"
>
Continuar Pago
</Link>
)}
{/* CASO 2: PAGO PENDIENTE (2) -> Botón de Verificar */}
{av.statusId === AD_STATUSES.PAYMENT_PENDING && (
<div className="flex flex-col gap-2">
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-400 px-4 py-2 rounded-xl text-center">
<span className="block text-[10px] font-black uppercase tracking-widest"> Pago Pendiente</span>
</div>
<button
onClick={() => handleVerifyPayment(av.id)}
className="bg-white/5 hover:bg-white/10 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-2.5 rounded-xl border border-white/10 transition-all hover:border-white/20 flex items-center justify-center gap-2"
>
🔄 Verificar Ahora
</button>
</div>
)}
{/* CASO 3: EN REVISIÓN (3) -> Cartel informativo */}
{av.statusId === AD_STATUSES.MODERATION_PENDING && (
<div className="bg-blue-500/10 border border-blue-500/20 text-blue-300 px-4 py-3 rounded-xl text-center">
<span className="block text-[10px] font-black uppercase tracking-widest"> En Revisión</span>
<span className="text-[8px] opacity-70">No editable</span>
</div>
)}
{/* CASO 4: VENCIDO (8) -> Botón de Republicar */}
{av.statusId === AD_STATUSES.EXPIRED && (
<div className="flex flex-col gap-2">
<div className="bg-gray-500/10 border border-gray-500/20 text-gray-400 px-4 py-2 rounded-xl text-center">
<span className="block text-[10px] font-black uppercase tracking-widest"> Finalizado</span>
</div>
<Link
to={`/publicar?edit=${av.id}`}
className="bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-2.5 rounded-xl border border-transparent shadow-lg shadow-blue-600/20 transition-all flex items-center justify-center gap-2"
>
🔄 Republicar
</Link>
</div>
)}
{/* CASO 5: ACTIVOS/PAUSADOS/OTROS (StatusDropdown) */}
{av.statusId !== AD_STATUSES.DRAFT &&
av.statusId !== AD_STATUSES.PAYMENT_PENDING &&
av.statusId !== AD_STATUSES.MODERATION_PENDING &&
av.statusId !== AD_STATUSES.EXPIRED && (
<StatusDropdown
currentStatus={av.statusId || AD_STATUSES.ACTIVE}
onChange={(newStatus) => initiateStatusChange(av.id, newStatus)}
/>
)}
{/* BOTONES COMUNES (Siempre visibles) */}
<div className="grid grid-cols-1 gap-2 mt-1">
<Link
to={`/vehiculo/${av.id}`}
className="bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white border border-white/5 px-4 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest text-center transition-all flex items-center justify-center gap-2"
>
<span>👁 Ver Detalle</span>
</Link>
{hasMessages && (
<button
onClick={() => openChatForAd(av.id, `${av.brandName} ${av.versionName}`)}
className="relative bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white border border-white/5 px-4 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all"
>
💬 Mensajes
{hasUnread && (
<span className="absolute top-3 right-3 w-2 h-2 bg-red-500 rounded-full animate-pulse shadow-lg shadow-red-500/50"></span>
)}
</button>
)}
</div>
</div>
</div>
);
})
)}
</div>
)}
{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>
</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 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>
</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>
</div>
</div>
))
)}
</div>
)}
{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>
</div>
) : (
Object.values(mensajes.reduce((acc: any, curr) => {
const key = curr.adID;
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;
return acc;
}, {})).map((item: any) => {
const aviso = avisos.find(a => a.id === item.msg.adID);
const tituloAviso = aviso ? `${aviso.brandName} ${aviso.versionName}` : `Aviso #${item.msg.adID}`;
return (
<div
key={item.msg.adID}
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="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>
</div>
<p className="text-sm text-gray-400 line-clamp-1">
{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>
)}
</>
)}
</div>
{selectedChat && user && (
<ChatModal
isOpen={!!selectedChat}
onClose={handleCloseChat}
adId={selectedChat.adId}
adTitle={selectedChat.name}
sellerId={selectedChat.otherUserId}
currentUserId={user.id}
/>
)}
{/* 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>
);
}
// 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">
{ALLOWED_STATUSES.map((statusId) => {
const config = STATUS_CONFIG[statusId];
// PROTECCIÓN CONTRA EL ERROR DE "UNDEFINED"
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>
);
}
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>
<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>
);
}

View File

@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react';
import { ProfileService } from '../services/profile.service';
import { useAuth } from '../context/AuthContext';
export default function PerfilPage() {
const { user, refreshSession } = useAuth();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
phoneNumber: ''
});
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
try {
const data = await ProfileService.getProfile();
setFormData({
firstName: data.firstName || '',
lastName: data.lastName || '',
phoneNumber: data.phoneNumber || ''
});
} catch (err) {
console.error("Error loading profile", err);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await ProfileService.updateProfile(formData);
alert('Perfil actualizado con éxito');
if (refreshSession) refreshSession();
} catch (err) {
alert('Error al actualizar el perfil');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center p-40">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="container mx-auto px-6 py-12 max-w-4xl">
<div className="mb-12">
<h1 className="text-5xl font-black tracking-tighter uppercase mb-2">Mi <span className="text-blue-500">Perfil</span></h1>
<p className="text-gray-500 font-bold tracking-widest uppercase text-xs">Gestiona tu información personal</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Sidebar Info */}
<div className="lg:col-span-1 space-y-6">
<div className="glass p-8 rounded-[2rem] border border-white/5 text-center">
<div className="w-24 h-24 bg-blue-600/20 rounded-full flex items-center justify-center text-4xl text-blue-400 font-bold mx-auto mb-4 border border-blue-500/20 shadow-lg shadow-blue-500/10">
{user?.username?.[0].toUpperCase()}
</div>
<h2 className="text-xl font-black text-white uppercase tracking-tight">{user?.username}</h2>
<p className="text-xs text-gray-500 font-medium mb-6">{user?.email}</p>
<div className="flex flex-col gap-2">
<span className={`px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest ${user?.userType === 3 ? 'bg-amber-500/10 text-amber-500 border border-amber-500/20' : 'bg-blue-600/10 text-blue-400 border border-blue-600/20'}`}>
{user?.userType === 3 ? '🛡️ Administrador' : '👤 Usuario Particular'}
</span>
</div>
</div>
</div>
{/* Edit Form */}
<div className="lg:col-span-2">
<form onSubmit={handleSubmit} className="glass p-8 rounded-[2.5rem] border border-white/5 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Nombre</label>
<input
type="text"
value={formData.firstName}
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
placeholder="Tu nombre"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Apellido</label>
<input
type="text"
value={formData.lastName}
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
placeholder="Tu apellido"
/>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Teléfono de Contacto</label>
<input
type="text"
value={formData.phoneNumber}
onChange={e => setFormData({ ...formData, phoneNumber: e.target.value })}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium"
placeholder="Ej: +54 9 11 1234 5678"
/>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 ml-1">Email <span className="text-[8px] text-gray-600 font-normal">(No editable)</span></label>
<input
type="email"
value={user?.email}
disabled
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-gray-500 outline-none cursor-not-allowed font-medium"
/>
</div>
</div>
<div className="pt-6 border-t border-white/5">
<button
type="submit"
disabled={saving}
className="w-full md:w-auto bg-blue-600 hover:bg-blue-500 text-white py-4 px-12 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95 disabled:opacity-50"
>
{saving ? 'Guardando...' : 'Guardar Cambios'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More