feat: qrcode por creditos.
This commit is contained in:
parent
162e28ae5a
commit
16a9720a12
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@ -187,6 +187,7 @@ jobs:
|
|||||||
--image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
--image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||||
--env-add Serilog__OpenSearchUrl="http://141.148.162.114:19201" \
|
--env-add Serilog__OpenSearchUrl="http://141.148.162.114:19201" \
|
||||||
--env-add Serilog__OpenSearchFallback="http://129.146.116.218:19202" \
|
--env-add Serilog__OpenSearchFallback="http://129.146.116.218:19202" \
|
||||||
|
--env-add Admin__AllowedEmails__0="rrcgoncalves@gmail.com" \
|
||||||
--with-registry-auth \
|
--with-registry-auth \
|
||||||
qrrapido-prod
|
qrrapido-prod
|
||||||
else
|
else
|
||||||
@ -208,6 +209,7 @@ jobs:
|
|||||||
--env ASPNETCORE_URLS=http://+:8080 \
|
--env ASPNETCORE_URLS=http://+:8080 \
|
||||||
--env Serilog__OpenSearchUrl="http://141.148.162.114:19201" \
|
--env Serilog__OpenSearchUrl="http://141.148.162.114:19201" \
|
||||||
--env Serilog__OpenSearchFallback="http://129.146.116.218:19202" \
|
--env Serilog__OpenSearchFallback="http://129.146.116.218:19202" \
|
||||||
|
--env Admin__AllowedEmails__0="rrcgoncalves@gmail.com" \
|
||||||
--update-delay 30s \
|
--update-delay 30s \
|
||||||
--update-parallelism 1 \
|
--update-parallelism 1 \
|
||||||
--update-order start-first \
|
--update-order start-first \
|
||||||
|
|||||||
@ -1,142 +1,212 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using QRRapidoApp.Data;
|
using QRRapidoApp.Data;
|
||||||
using QRRapidoApp.Models;
|
using QRRapidoApp.Models;
|
||||||
|
using QRRapidoApp.Services;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace QRRapidoApp.Controllers
|
namespace QRRapidoApp.Controllers
|
||||||
{
|
{
|
||||||
/// <summary>
|
public class AdminController : Controller
|
||||||
/// Admin controller - ONLY accessible from localhost for security
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class AdminController : ControllerBase
|
|
||||||
{
|
{
|
||||||
private readonly MongoDbContext _context;
|
private readonly MongoDbContext _context;
|
||||||
private readonly ILogger<AdminController> _logger;
|
private readonly ILogger<AdminController> _logger;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly IUserService _userService;
|
||||||
|
|
||||||
public AdminController(MongoDbContext context, ILogger<AdminController> logger)
|
public AdminController(MongoDbContext context, ILogger<AdminController> logger, IConfiguration config, IUserService userService)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_config = config;
|
||||||
|
_userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private bool IsAdmin()
|
||||||
/// Seed/Update MongoDB Plans collection
|
{
|
||||||
/// Only accessible from localhost (127.0.0.1 or ::1)
|
// 1. Check if authenticated
|
||||||
/// </summary>
|
if (User?.Identity?.IsAuthenticated != true) return false;
|
||||||
[HttpPost("SeedPlans")]
|
|
||||||
|
// 2. Get User Email
|
||||||
|
var userEmail = User.FindFirst(ClaimTypes.Email)?.Value;
|
||||||
|
if (string.IsNullOrEmpty(userEmail))
|
||||||
|
{
|
||||||
|
// Fallback: try to find user by ID to get email
|
||||||
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
var user = _userService.GetUserAsync(userId).Result;
|
||||||
|
userEmail = user?.Email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userEmail)) return false;
|
||||||
|
|
||||||
|
// 3. Check against AllowedEmails list
|
||||||
|
var allowedEmails = _config.GetSection("Admin:AllowedEmails").Get<List<string>>() ?? new List<string>();
|
||||||
|
return allowedEmails.Contains(userEmail, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLocalhost()
|
||||||
|
{
|
||||||
|
var remoteIp = HttpContext.Connection.RemoteIpAddress;
|
||||||
|
return remoteIp != null &&
|
||||||
|
(remoteIp.ToString() == "127.0.0.1" ||
|
||||||
|
remoteIp.ToString() == "::1" ||
|
||||||
|
remoteIp.ToString() == "localhost");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- View Actions ---
|
||||||
|
|
||||||
|
[HttpGet("/Admin")]
|
||||||
|
public async Task<IActionResult> Index()
|
||||||
|
{
|
||||||
|
if (!IsAdmin()) return RedirectToAction("Index", "Home");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var orders = await _context.Orders
|
||||||
|
.Find(o => o.Status == "Pending")
|
||||||
|
.SortByDescending(o => o.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return View(orders);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching pending orders for view");
|
||||||
|
return View(new List<Order>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Endpoints ---
|
||||||
|
|
||||||
|
[HttpGet("api/Admin/Orders/Pending")]
|
||||||
|
public async Task<IActionResult> GetPendingOrders()
|
||||||
|
{
|
||||||
|
if (!IsAdmin()) return Unauthorized("Access denied. Admin rights required.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var orders = await _context.Orders
|
||||||
|
.Find(o => o.Status == "Pending")
|
||||||
|
.SortByDescending(o => o.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(orders);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching pending orders");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/Admin/Orders/{orderId}/Approve")]
|
||||||
|
public async Task<IActionResult> ApproveOrder(string orderId)
|
||||||
|
{
|
||||||
|
if (!IsAdmin()) return Unauthorized("Access denied. Admin rights required.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var adminEmail = User.FindFirst(ClaimTypes.Email)?.Value ?? "unknown_admin";
|
||||||
|
|
||||||
|
// 1. Get the order
|
||||||
|
var order = await _context.Orders
|
||||||
|
.Find(o => o.Id == orderId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (order == null) return NotFound("Order not found");
|
||||||
|
if (order.Status == "Paid") return BadRequest("Order already paid");
|
||||||
|
|
||||||
|
// 2. Update Order Status
|
||||||
|
var updateOrder = Builders<Order>.Update
|
||||||
|
.Set(o => o.Status, "Paid")
|
||||||
|
.Set(o => o.PaidAt, DateTime.UtcNow)
|
||||||
|
.Set(o => o.ApprovedBy, adminEmail);
|
||||||
|
|
||||||
|
await _context.Orders.UpdateOneAsync(o => o.Id == orderId, updateOrder);
|
||||||
|
|
||||||
|
// 3. Add Credits to User
|
||||||
|
var userUpdate = Builders<User>.Update.Inc(u => u.Credits, order.CreditsAmount);
|
||||||
|
await _context.Users.UpdateOneAsync(u => u.Id == order.UserId, userUpdate);
|
||||||
|
|
||||||
|
_logger.LogInformation($"Order {orderId} approved by {adminEmail}. {order.CreditsAmount} credits added to user {order.UserId}");
|
||||||
|
|
||||||
|
return Ok(new { success = true, message = "Order approved and credits added." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error approving order {orderId}");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/Admin/Orders/{orderId}/Reject")]
|
||||||
|
public async Task<IActionResult> RejectOrder(string orderId)
|
||||||
|
{
|
||||||
|
if (!IsAdmin()) return Unauthorized("Access denied. Admin rights required.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var adminEmail = User.FindFirst(ClaimTypes.Email)?.Value ?? "unknown_admin";
|
||||||
|
|
||||||
|
var update = Builders<Order>.Update
|
||||||
|
.Set(o => o.Status, "Rejected")
|
||||||
|
.Set(o => o.ApprovedBy, adminEmail); // Rejected by...
|
||||||
|
|
||||||
|
var result = await _context.Orders.UpdateOneAsync(o => o.Id == orderId, update);
|
||||||
|
|
||||||
|
if (result.ModifiedCount == 0) return NotFound("Order not found");
|
||||||
|
|
||||||
|
_logger.LogInformation($"Order {orderId} rejected by {adminEmail}");
|
||||||
|
|
||||||
|
return Ok(new { success = true, message = "Order rejected." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error rejecting order {orderId}");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Legacy Localhost-Only Endpoints ---
|
||||||
|
|
||||||
|
[HttpPost("api/Admin/SeedPlans")]
|
||||||
public async Task<IActionResult> SeedPlans([FromBody] List<Plan> plans)
|
public async Task<IActionResult> SeedPlans([FromBody] List<Plan> plans)
|
||||||
{
|
{
|
||||||
// SECURITY: Only allow from localhost
|
if (!IsLocalhost()) return Forbid("Localhost only");
|
||||||
var remoteIp = HttpContext.Connection.RemoteIpAddress;
|
|
||||||
var isLocalhost = remoteIp != null &&
|
|
||||||
(remoteIp.ToString() == "127.0.0.1" ||
|
|
||||||
remoteIp.ToString() == "::1" ||
|
|
||||||
remoteIp.ToString() == "localhost");
|
|
||||||
|
|
||||||
if (!isLocalhost)
|
|
||||||
{
|
|
||||||
_logger.LogWarning($"Unauthorized admin access attempt from {remoteIp}");
|
|
||||||
return Forbid("This endpoint is only accessible from localhost");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation($"SeedPlans called from localhost - Upserting {plans.Count} plans");
|
|
||||||
|
|
||||||
foreach (var plan in plans)
|
foreach (var plan in plans)
|
||||||
{
|
{
|
||||||
// Upsert based on interval (month/year)
|
|
||||||
var filter = Builders<Plan>.Filter.Eq(p => p.Interval, plan.Interval);
|
var filter = Builders<Plan>.Filter.Eq(p => p.Interval, plan.Interval);
|
||||||
var options = new ReplaceOptions { IsUpsert = true };
|
var options = new ReplaceOptions { IsUpsert = true };
|
||||||
|
|
||||||
await _context.Plans.ReplaceOneAsync(filter, plan, options);
|
await _context.Plans.ReplaceOneAsync(filter, plan, options);
|
||||||
_logger.LogInformation($"Upserted plan: {plan.Interval}");
|
|
||||||
}
|
}
|
||||||
|
return Ok(new { success = true, message = "Plans seeded" });
|
||||||
return Ok(new {
|
|
||||||
success = true,
|
|
||||||
message = $"{plans.Count} plans seeded successfully",
|
|
||||||
plans = plans.Select(p => new {
|
|
||||||
interval = p.Interval,
|
|
||||||
priceIds = p.PricesByCountry.ToDictionary(
|
|
||||||
kvp => kvp.Key,
|
|
||||||
kvp => kvp.Value.StripePriceId
|
|
||||||
)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error seeding plans");
|
return StatusCode(500, new { error = ex.Message });
|
||||||
return StatusCode(500, new { success = false, error = ex.Message });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
[HttpGet("api/Admin/Plans")]
|
||||||
/// Get all plans from MongoDB
|
|
||||||
/// Only accessible from localhost
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("Plans")]
|
|
||||||
public async Task<IActionResult> GetPlans()
|
public async Task<IActionResult> GetPlans()
|
||||||
{
|
{
|
||||||
// SECURITY: Only allow from localhost
|
if (!IsLocalhost()) return Forbid("Localhost only");
|
||||||
var remoteIp = HttpContext.Connection.RemoteIpAddress;
|
var plans = await _context.Plans.Find(_ => true).ToListAsync();
|
||||||
var isLocalhost = remoteIp != null &&
|
return Ok(new { success = true, plans });
|
||||||
(remoteIp.ToString() == "127.0.0.1" ||
|
|
||||||
remoteIp.ToString() == "::1" ||
|
|
||||||
remoteIp.ToString() == "localhost");
|
|
||||||
|
|
||||||
if (!isLocalhost)
|
|
||||||
{
|
|
||||||
_logger.LogWarning($"Unauthorized admin access attempt from {remoteIp}");
|
|
||||||
return Forbid("This endpoint is only accessible from localhost");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var plans = await _context.Plans.Find(_ => true).ToListAsync();
|
|
||||||
return Ok(new { success = true, count = plans.Count, plans });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error retrieving plans");
|
|
||||||
return StatusCode(500, new { success = false, error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
[HttpDelete("api/Admin/Plans")]
|
||||||
/// Delete all plans from MongoDB
|
|
||||||
/// Only accessible from localhost
|
|
||||||
/// </summary>
|
|
||||||
[HttpDelete("Plans")]
|
|
||||||
public async Task<IActionResult> DeleteAllPlans()
|
public async Task<IActionResult> DeleteAllPlans()
|
||||||
{
|
{
|
||||||
// SECURITY: Only allow from localhost
|
if (!IsLocalhost()) return Forbid("Localhost only");
|
||||||
var remoteIp = HttpContext.Connection.RemoteIpAddress;
|
await _context.Plans.DeleteManyAsync(_ => true);
|
||||||
var isLocalhost = remoteIp != null &&
|
return Ok(new { success = true });
|
||||||
(remoteIp.ToString() == "127.0.0.1" ||
|
|
||||||
remoteIp.ToString() == "::1" ||
|
|
||||||
remoteIp.ToString() == "localhost");
|
|
||||||
|
|
||||||
if (!isLocalhost)
|
|
||||||
{
|
|
||||||
_logger.LogWarning($"Unauthorized admin access attempt from {remoteIp}");
|
|
||||||
return Forbid("This endpoint is only accessible from localhost");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _context.Plans.DeleteManyAsync(_ => true);
|
|
||||||
_logger.LogInformation($"Deleted {result.DeletedCount} plans");
|
|
||||||
return Ok(new { success = true, deletedCount = result.DeletedCount });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error deleting plans");
|
|
||||||
return StatusCode(500, new { success = false, error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,172 +1,287 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using QRRapidoApp.Services;
|
||||||
using QRRapidoApp.Services;
|
using QRRapidoApp.Models;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using QRRapidoApp.Models.ViewModels;
|
using QRRapidoApp.Models.ViewModels;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using MongoDB.Driver;
|
||||||
namespace QRRapidoApp.Controllers
|
using QRRapidoApp.Data;
|
||||||
{
|
using System.Text;
|
||||||
[Authorize]
|
using Stripe.Checkout;
|
||||||
public class PagamentoController : Controller
|
|
||||||
{
|
namespace QRRapidoApp.Controllers
|
||||||
private readonly IPlanService _planService;
|
{
|
||||||
private readonly AdDisplayService _adDisplayService;
|
[Authorize]
|
||||||
private readonly IUserService _userService;
|
public class PagamentoController : Controller
|
||||||
private readonly StripeService _stripeService;
|
{
|
||||||
private readonly ILogger<PagamentoController> _logger;
|
private readonly IUserService _userService;
|
||||||
private readonly List<string> languages = new List<string> { "pt-BR", "es-PY", "es" };
|
private readonly ILogger<PagamentoController> _logger;
|
||||||
|
private readonly MongoDbContext _context;
|
||||||
public PagamentoController(IPlanService planService, IUserService userService, StripeService stripeService, ILogger<PagamentoController> logger, AdDisplayService adDisplayService)
|
private readonly AdDisplayService _adDisplayService;
|
||||||
{
|
private readonly StripeService _stripeService; // Injected StripeService
|
||||||
_planService = planService;
|
private readonly string _pixKey = "chave-pix-padrao@qrrapido.site";
|
||||||
_userService = userService;
|
private readonly string _merchantName = "QR Rapido";
|
||||||
_stripeService = stripeService;
|
private readonly string _merchantCity = "SAO PAULO";
|
||||||
_logger = logger;
|
|
||||||
_adDisplayService = adDisplayService;
|
public PagamentoController(
|
||||||
}
|
IUserService userService,
|
||||||
|
ILogger<PagamentoController> logger,
|
||||||
[HttpGet]
|
MongoDbContext context,
|
||||||
public async Task<IActionResult> SelecaoPlano()
|
AdDisplayService adDisplayService,
|
||||||
{
|
IConfiguration config,
|
||||||
var plans = await _planService.GetActivePlansAsync();
|
StripeService stripeService)
|
||||||
var countryCode = GetUserCountryCodeComplete(); // Implement this method based on your needs
|
{
|
||||||
_adDisplayService.SetViewBagAds(ViewBag);
|
_userService = userService;
|
||||||
|
_logger = logger;
|
||||||
var model = new SelecaoPlanoViewModel
|
_context = context;
|
||||||
{
|
_adDisplayService = adDisplayService;
|
||||||
Plans = plans,
|
_stripeService = stripeService;
|
||||||
CountryCode = countryCode
|
|
||||||
};
|
var configPixKey = config["Payment:PixKey"];
|
||||||
|
if (!string.IsNullOrEmpty(configPixKey))
|
||||||
return View(model);
|
{
|
||||||
}
|
_pixKey = configPixKey;
|
||||||
|
}
|
||||||
[HttpPost]
|
}
|
||||||
public async Task<IActionResult> CreateCheckout(string planId, string lang)
|
|
||||||
{
|
[HttpGet]
|
||||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
public async Task<IActionResult> SelecaoPlano()
|
||||||
if (string.IsNullOrEmpty(userId))
|
{
|
||||||
{
|
_adDisplayService.SetViewBagAds(ViewBag);
|
||||||
return Json(new { success = false, error = "User not authenticated" });
|
|
||||||
}
|
// Definição dos pacotes com PREÇOS DIFERENCIADOS
|
||||||
|
var packages = GetPackages();
|
||||||
var plan = await _planService.GetPlanByIdAsync(planId);
|
|
||||||
if (plan == null)
|
return View(packages);
|
||||||
{
|
}
|
||||||
return Json(new { success = false, error = "Plan not found" });
|
|
||||||
}
|
[HttpPost("api/Pagamento/CreatePixOrder")]
|
||||||
|
public async Task<IActionResult> CreatePixOrder([FromBody] CreateOrderRequest request)
|
||||||
var countryCode = GetUserCountryCode();
|
{
|
||||||
if (countryCode != lang && languages.Contains(lang))
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
{
|
var userEmail = User.FindFirst(ClaimTypes.Email)?.Value;
|
||||||
countryCode = lang;
|
|
||||||
}
|
if (string.IsNullOrEmpty(userId)) return Unauthorized();
|
||||||
|
|
||||||
var priceId = plan.PricesByCountry.ContainsKey(countryCode)
|
var package = GetPackage(request.PackageId);
|
||||||
? plan.PricesByCountry[countryCode].StripePriceId
|
if (package == null) return BadRequest("Pacote inválido");
|
||||||
: plan.StripePriceId;
|
|
||||||
|
try
|
||||||
try
|
{
|
||||||
{
|
// Create Order (PIX Price)
|
||||||
var checkoutUrl = await _stripeService.CreateCheckoutSessionAsync(userId, priceId, lang);
|
var order = new Order
|
||||||
return Json(new { success = true, url = checkoutUrl });
|
{
|
||||||
}
|
UserId = userId,
|
||||||
catch (Exception ex)
|
UserEmail = userEmail ?? "unknown",
|
||||||
{
|
Amount = package.PricePix,
|
||||||
_logger.LogError(ex, $"Error creating checkout session for user {userId} and plan {planId}");
|
CreditsAmount = package.Credits,
|
||||||
return Json(new { success = false, error = ex.Message });
|
Status = "Pending",
|
||||||
}
|
CreatedAt = DateTime.UtcNow
|
||||||
}
|
};
|
||||||
|
|
||||||
[HttpGet]
|
await _context.Orders.InsertOneAsync(order);
|
||||||
public IActionResult Sucesso()
|
|
||||||
{
|
var shortId = order.Id.Substring(order.Id.Length - 8).ToUpper();
|
||||||
_adDisplayService.SetViewBagAds(ViewBag);
|
var txId = $"PED{shortId}";
|
||||||
ViewBag.SuccessMessage = "Pagamento concluído com sucesso! Bem-vindo ao Premium.";
|
|
||||||
return View();
|
var update = Builders<Order>.Update.Set(o => o.PixCode, txId);
|
||||||
}
|
await _context.Orders.UpdateOneAsync(o => o.Id == order.Id, update);
|
||||||
|
|
||||||
[HttpGet]
|
var pixPayload = PixPayloadGenerator.GeneratePayload(
|
||||||
public async Task<IActionResult> Cancelar()
|
_pixKey,
|
||||||
{
|
package.PricePix,
|
||||||
_adDisplayService.SetViewBagAds(ViewBag);
|
_merchantName,
|
||||||
ViewBag.CancelMessage = "O pagamento foi cancelado. Você pode tentar novamente a qualquer momento.";
|
_merchantCity,
|
||||||
|
txId
|
||||||
var plans = await _planService.GetActivePlansAsync();
|
);
|
||||||
var countryCode = GetUserCountryCode(); // Implement this method based on your needs
|
|
||||||
_adDisplayService.SetViewBagAds(ViewBag);
|
return Ok(new
|
||||||
|
{
|
||||||
var model = new SelecaoPlanoViewModel
|
success = true,
|
||||||
{
|
pixCode = pixPayload,
|
||||||
Plans = plans,
|
orderId = txId,
|
||||||
CountryCode = countryCode
|
amount = package.PricePix,
|
||||||
};
|
credits = package.Credits
|
||||||
|
});
|
||||||
return View("SelecaoPlano", model);
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
|
{
|
||||||
[HttpPost]
|
_logger.LogError(ex, "Erro ao gerar pedido PIX");
|
||||||
[AllowAnonymous]
|
return StatusCode(500, new { success = false, error = "Erro interno" });
|
||||||
public async Task<IActionResult> StripeWebhook()
|
}
|
||||||
{
|
}
|
||||||
try
|
|
||||||
{
|
[HttpPost("api/Pagamento/CreateStripeSession")]
|
||||||
using var reader = new StreamReader(HttpContext.Request.Body);
|
public async Task<IActionResult> CreateStripeSession([FromBody] CreateOrderRequest request)
|
||||||
var json = await reader.ReadToEndAsync();
|
{
|
||||||
var signature = Request.Headers["Stripe-Signature"].FirstOrDefault();
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (string.IsNullOrEmpty(userId)) return Unauthorized();
|
||||||
if (string.IsNullOrEmpty(signature))
|
|
||||||
{
|
var package = GetPackage(request.PackageId);
|
||||||
return BadRequest("Missing Stripe signature");
|
if (package == null) return BadRequest("Pacote inválido");
|
||||||
}
|
|
||||||
|
try
|
||||||
await _stripeService.HandleWebhookAsync(json, signature);
|
{
|
||||||
return Ok();
|
// Create Stripe Checkout Session (One-Time Payment)
|
||||||
}
|
// We create an ad-hoc price on the fly using line_items
|
||||||
catch (Exception ex)
|
var options = new SessionCreateOptions
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error processing Stripe webhook");
|
PaymentMethodTypes = new List<string> { "card" },
|
||||||
return BadRequest(ex.Message);
|
Mode = "payment", // One-time payment
|
||||||
}
|
LineItems = new List<SessionLineItemOptions>
|
||||||
}
|
{
|
||||||
|
new SessionLineItemOptions
|
||||||
private string GetUserCountryCode()
|
{
|
||||||
{
|
PriceData = new SessionLineItemPriceDataOptions
|
||||||
// Check current culture from URL first
|
{
|
||||||
var culture = HttpContext.Request.RouteValues["culture"]?.ToString() ??
|
Currency = "brl",
|
||||||
HttpContext.Features.Get<Microsoft.AspNetCore.Localization.IRequestCultureFeature>()?.RequestCulture?.Culture?.Name;
|
UnitAmount = (long)(package.PriceCard * 100), // Centavos
|
||||||
|
ProductData = new SessionLineItemPriceDataProductDataOptions
|
||||||
var countryMap = new Dictionary<string, string>
|
{
|
||||||
{
|
Name = $"{package.Credits} Créditos QR Rapido",
|
||||||
{ "pt-BR", "BR" },
|
Description = package.Description
|
||||||
{ "es-PY", "PY" },
|
}
|
||||||
{ "es", "PY" }
|
},
|
||||||
};
|
Quantity = 1,
|
||||||
|
},
|
||||||
if (!string.IsNullOrEmpty(culture) && countryMap.ContainsKey(culture))
|
},
|
||||||
{
|
Metadata = new Dictionary<string, string>
|
||||||
return countryMap[culture];
|
{
|
||||||
}
|
{ "user_id", userId },
|
||||||
|
{ "credits_amount", package.Credits.ToString() },
|
||||||
// Fallback to Cloudflare header or default
|
{ "package_id", package.Id }
|
||||||
return HttpContext.Request.Headers["CF-IPCountry"].FirstOrDefault() ?? "BR";
|
},
|
||||||
}
|
SuccessUrl = $"{Request.Scheme}://{Request.Host}/Pagamento/Sucesso",
|
||||||
private string GetUserCountryCodeComplete()
|
CancelUrl = $"{Request.Scheme}://{Request.Host}/Pagamento/SelecaoPlano",
|
||||||
{
|
};
|
||||||
// Check current culture from URL first
|
|
||||||
var culture = HttpContext.Request.RouteValues["culture"]?.ToString() ??
|
var service = new SessionService();
|
||||||
HttpContext.Features.Get<Microsoft.AspNetCore.Localization.IRequestCultureFeature>()?.RequestCulture?.Culture?.Name;
|
var session = await service.CreateAsync(options);
|
||||||
|
|
||||||
if (languages.Contains(culture))
|
return Ok(new { success = true, url = session.Url });
|
||||||
{
|
}
|
||||||
return culture;
|
catch (Exception ex)
|
||||||
}
|
{
|
||||||
|
_logger.LogError(ex, "Stripe session error");
|
||||||
// Fallback to Cloudflare header or default
|
return StatusCode(500, new { success = false, error = ex.Message });
|
||||||
return HttpContext.Request.Headers["CF-IPCountry"].FirstOrDefault() ?? "BR";
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
private List<CreditPackageViewModel> GetPackages()
|
||||||
|
{
|
||||||
|
return new List<CreditPackageViewModel>
|
||||||
|
{
|
||||||
|
new CreditPackageViewModel {
|
||||||
|
Id = "starter",
|
||||||
|
Name = "Iniciante",
|
||||||
|
Credits = 10,
|
||||||
|
PricePix = 5.00m, // R$ 0,50/un
|
||||||
|
PriceCard = 6.00m,
|
||||||
|
Description = "Ideal para testes rápidos",
|
||||||
|
Savings = 0
|
||||||
|
},
|
||||||
|
new CreditPackageViewModel {
|
||||||
|
Id = "pro",
|
||||||
|
Name = "Profissional",
|
||||||
|
Credits = 50,
|
||||||
|
PricePix = 22.50m, // R$ 0,45/un (Desconto de volume)
|
||||||
|
PriceCard = 27.00m,
|
||||||
|
Description = "Para uso recorrente",
|
||||||
|
Savings = 10,
|
||||||
|
IsPopular = true
|
||||||
|
},
|
||||||
|
new CreditPackageViewModel {
|
||||||
|
Id = "agency",
|
||||||
|
Name = "Agência",
|
||||||
|
Credits = 100,
|
||||||
|
PricePix = 40.00m, // R$ 0,40/un (Super desconto)
|
||||||
|
PriceCard = 48.00m,
|
||||||
|
Description = "Volume alto com desconto máximo",
|
||||||
|
Savings = 20
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private CreditPackageViewModel? GetPackage(string id)
|
||||||
|
{
|
||||||
|
return GetPackages().FirstOrDefault(p => p.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateOrderRequest
|
||||||
|
{
|
||||||
|
public string PackageId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreditPackageViewModel
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public int Credits { get; set; }
|
||||||
|
public decimal PricePix { get; set; } // Preço PIX
|
||||||
|
public decimal PriceCard { get; set; } // Preço Cartão (+taxas)
|
||||||
|
public string Description { get; set; }
|
||||||
|
public int Savings { get; set; }
|
||||||
|
public bool IsPopular { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper Interno para Gerar PIX (CRC16) ---
|
||||||
|
public static class PixPayloadGenerator
|
||||||
|
{
|
||||||
|
public static string GeneratePayload(string pixKey, decimal amount, string merchantName, string merchantCity, string txId)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append(FormatField("00", "01"));
|
||||||
|
var merchantInfo = new StringBuilder();
|
||||||
|
merchantInfo.Append(FormatField("00", "br.gov.bcb.pix"));
|
||||||
|
merchantInfo.Append(FormatField("01", pixKey));
|
||||||
|
sb.Append(FormatField("26", merchantInfo.ToString()));
|
||||||
|
sb.Append(FormatField("52", "0000"));
|
||||||
|
sb.Append(FormatField("53", "986"));
|
||||||
|
sb.Append(FormatField("54", amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)));
|
||||||
|
sb.Append(FormatField("58", "BR"));
|
||||||
|
var name = merchantName.Length > 25 ? merchantName.Substring(0, 25) : merchantName;
|
||||||
|
sb.Append(FormatField("59", RemoveAccents(name)));
|
||||||
|
var city = merchantCity.Length > 15 ? merchantCity.Substring(0, 15) : merchantCity;
|
||||||
|
sb.Append(FormatField("60", RemoveAccents(city)));
|
||||||
|
var additionalData = new StringBuilder();
|
||||||
|
additionalData.Append(FormatField("05", txId));
|
||||||
|
sb.Append(FormatField("62", additionalData.ToString()));
|
||||||
|
sb.Append("6304");
|
||||||
|
var payloadWithoutCrc = sb.ToString();
|
||||||
|
var crc = CalculateCRC16(payloadWithoutCrc);
|
||||||
|
return payloadWithoutCrc + crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatField(string id, string value) => $"{id}{value.Length:D2}{value}";
|
||||||
|
|
||||||
|
private static string RemoveAccents(string text)
|
||||||
|
{
|
||||||
|
return text.ToUpper()
|
||||||
|
.Replace("Ã", "A").Replace("Á", "A").Replace("Â", "A")
|
||||||
|
.Replace("É", "E").Replace("Ê", "E")
|
||||||
|
.Replace("Í", "I")
|
||||||
|
.Replace("Ó", "O").Replace("Ô", "O").Replace("Õ", "O")
|
||||||
|
.Replace("Ú", "U")
|
||||||
|
.Replace("Ç", "C");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CalculateCRC16(string data)
|
||||||
|
{
|
||||||
|
ushort crc = 0xFFFF;
|
||||||
|
byte[] bytes = Encoding.ASCII.GetBytes(data);
|
||||||
|
foreach (byte b in bytes)
|
||||||
|
{
|
||||||
|
crc ^= (ushort)(b << 8);
|
||||||
|
for (int i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
if ((crc & 0x8000) != 0) crc = (ushort)((crc << 1) ^ 0x1021);
|
||||||
|
else crc <<= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crc.ToString("X4");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,7 +4,10 @@ using QRRapidoApp.Services;
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
using QRRapidoApp.Models;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
namespace QRRapidoApp.Controllers
|
namespace QRRapidoApp.Controllers
|
||||||
{
|
{
|
||||||
@ -14,557 +17,262 @@ namespace QRRapidoApp.Controllers
|
|||||||
{
|
{
|
||||||
private readonly IQRCodeService _qrService;
|
private readonly IQRCodeService _qrService;
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly AdDisplayService _adService;
|
|
||||||
private readonly ILogger<QRController> _logger;
|
private readonly ILogger<QRController> _logger;
|
||||||
private readonly IStringLocalizer<QRRapidoApp.Resources.SharedResource> _localizer;
|
private readonly IStringLocalizer<QRRapidoApp.Resources.SharedResource> _localizer;
|
||||||
private readonly AdDisplayService _adDisplayService;
|
|
||||||
|
|
||||||
public QRController(IQRCodeService qrService, IUserService userService, AdDisplayService adService, ILogger<QRController> logger, IStringLocalizer<QRRapidoApp.Resources.SharedResource> localizer, AdDisplayService adDisplayService)
|
public QRController(IQRCodeService qrService, IUserService userService, ILogger<QRController> logger, IStringLocalizer<QRRapidoApp.Resources.SharedResource> localizer)
|
||||||
{
|
{
|
||||||
_qrService = qrService;
|
_qrService = qrService;
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_adService = adService;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_localizer = localizer;
|
_localizer = localizer;
|
||||||
_adDisplayService = adDisplayService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("GenerateRapid")]
|
[HttpPost("GenerateRapid")]
|
||||||
public async Task<IActionResult> GenerateRapid([FromBody] QRGenerationRequest request)
|
public async Task<IActionResult> GenerateRapid([FromBody] QRGenerationRequest request)
|
||||||
{
|
|
||||||
var stopwatch = Stopwatch.StartNew();
|
|
||||||
var requestId = Guid.NewGuid().ToString("N")[..8];
|
|
||||||
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
var isAuthenticated = User?.Identity?.IsAuthenticated ?? false;
|
|
||||||
|
|
||||||
using (_logger.BeginScope(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["RequestId"] = requestId,
|
|
||||||
["UserId"] = userId ?? "anonymous",
|
|
||||||
["IsAuthenticated"] = isAuthenticated,
|
|
||||||
["QRType"] = request.Type ?? "unknown",
|
|
||||||
["ContentLength"] = request.Content?.Length ?? 0,
|
|
||||||
["QRGeneration"] = true
|
|
||||||
}))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("QR generation request started - Type: {QRType}, ContentLength: {ContentLength}, User: {UserType}",
|
|
||||||
request.Type, request.Content?.Length ?? 0, isAuthenticated ? "authenticated" : "anonymous");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Quick validations
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Content))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("QR generation failed - empty content provided");
|
|
||||||
return BadRequest(new { error = _localizer["RequiredContent"], success = false });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Content.Length > 4000) // Limit to maintain speed
|
|
||||||
{
|
|
||||||
_logger.LogWarning("QR generation failed - content too long: {ContentLength} characters", request.Content.Length);
|
|
||||||
return BadRequest(new { error = _localizer["ContentTooLong"], success = false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check user status
|
|
||||||
var user = await _userService.GetUserAsync(userId);
|
|
||||||
|
|
||||||
// Validate premium features
|
|
||||||
if (!string.IsNullOrEmpty(request.CornerStyle) && request.CornerStyle != "square" && user?.IsPremium != true)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Custom corner style attempted by non-premium user - UserId: {UserId}, CornerStyle: {CornerStyle}",
|
|
||||||
userId ?? "anonymous", request.CornerStyle);
|
|
||||||
return BadRequest(new
|
|
||||||
{
|
|
||||||
error = _localizer["PremiumCornerStyleRequired"],
|
|
||||||
requiresPremium = true,
|
|
||||||
success = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting for free users
|
|
||||||
var rateLimitPassed = await CheckRateLimitAsync(userId, user);
|
|
||||||
if (!rateLimitPassed)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("QR generation rate limited - User: {UserId}, IsPremium: {IsPremium}",
|
|
||||||
userId ?? "anonymous", user?.IsPremium ?? false);
|
|
||||||
return StatusCode(429, new
|
|
||||||
{
|
|
||||||
error = _localizer["RateLimitReached"],
|
|
||||||
upgradeUrl = "/Pagamento/SelecaoPlano",
|
|
||||||
success = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure optimizations based on user
|
|
||||||
request.IsPremium = user?.IsPremium == true;
|
|
||||||
request.OptimizeForSpeed = true;
|
|
||||||
|
|
||||||
_logger.LogDebug("Generating QR code - IsPremium: {IsPremium}, OptimizeForSpeed: {OptimizeForSpeed}",
|
|
||||||
request.IsPremium, request.OptimizeForSpeed);
|
|
||||||
|
|
||||||
// Generate QR code
|
|
||||||
var generationStopwatch = Stopwatch.StartNew();
|
|
||||||
var result = await _qrService.GenerateRapidAsync(request);
|
|
||||||
generationStopwatch.Stop();
|
|
||||||
|
|
||||||
if (!result.Success)
|
|
||||||
{
|
|
||||||
_logger.LogError("QR generation failed - Error: {ErrorMessage}, GenerationTime: {GenerationTimeMs}ms",
|
|
||||||
result.ErrorMessage, generationStopwatch.ElapsedMilliseconds);
|
|
||||||
return StatusCode(500, new { error = result.ErrorMessage, success = false });
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("QR code generated successfully - GenerationTime: {GenerationTimeMs}ms, FromCache: {FromCache}, Size: {Size}px",
|
|
||||||
generationStopwatch.ElapsedMilliseconds, result.FromCache, request.Size);
|
|
||||||
|
|
||||||
// Update counter for all logged users
|
|
||||||
if (userId != null)
|
|
||||||
{
|
|
||||||
if (request.IsPremium)
|
|
||||||
{
|
|
||||||
result.RemainingQRs = int.MaxValue; // Premium users have unlimited
|
|
||||||
// Still increment the count for statistics
|
|
||||||
await _userService.IncrementDailyQRCountAsync(userId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var remaining = await _userService.IncrementDailyQRCountAsync(userId);
|
|
||||||
result.RemainingQRs = remaining;
|
|
||||||
_logger.LogDebug("Updated QR count for free user - Remaining: {RemainingQRs}", remaining);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to history if user is logged in (fire and forget)
|
|
||||||
if (userId != null)
|
|
||||||
{
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _userService.SaveQRToHistoryAsync(userId, result);
|
|
||||||
_logger.LogDebug("QR code saved to history successfully for user {UserId}", userId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error saving QR to history for user {UserId}", userId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stopwatch.Stop();
|
|
||||||
var totalTimeMs = stopwatch.ElapsedMilliseconds;
|
|
||||||
|
|
||||||
// Performance logging with structured data
|
|
||||||
using (_logger.BeginScope(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["TotalRequestTimeMs"] = totalTimeMs,
|
|
||||||
["QRGenerationTimeMs"] = generationStopwatch.ElapsedMilliseconds,
|
|
||||||
["ServiceGenerationTimeMs"] = result.GenerationTimeMs,
|
|
||||||
["FromCache"] = result.FromCache,
|
|
||||||
["UserType"] = request.IsPremium ? "premium" : "free",
|
|
||||||
["QRSize"] = request.Size,
|
|
||||||
["Success"] = true
|
|
||||||
}))
|
|
||||||
{
|
|
||||||
var performanceStatus = totalTimeMs switch
|
|
||||||
{
|
|
||||||
< 500 => "excellent",
|
|
||||||
< 1000 => "good",
|
|
||||||
< 2000 => "acceptable",
|
|
||||||
_ => "slow"
|
|
||||||
};
|
|
||||||
|
|
||||||
_logger.LogInformation("QR generation completed - TotalTime: {TotalTimeMs}ms, ServiceTime: {ServiceTimeMs}ms, Performance: {PerformanceStatus}, Cache: {FromCache}",
|
|
||||||
totalTimeMs, result.GenerationTimeMs, performanceStatus, result.FromCache);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
stopwatch.Stop();
|
|
||||||
_logger.LogError(ex, "QR generation failed with exception - RequestTime: {RequestTimeMs}ms, UserId: {UserId}",
|
|
||||||
stopwatch.ElapsedMilliseconds, userId ?? "anonymous");
|
|
||||||
return StatusCode(500, new { error = "Erro interno do servidor", success = false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("Download/{qrId}")]
|
|
||||||
public async Task<IActionResult> Download(string qrId, string format = "png")
|
|
||||||
{
|
|
||||||
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
var stopwatch = Stopwatch.StartNew();
|
|
||||||
|
|
||||||
using (_logger.BeginScope(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["QRId"] = qrId,
|
|
||||||
["Format"] = format.ToLower(),
|
|
||||||
["UserId"] = userId ?? "anonymous",
|
|
||||||
["QRDownload"] = true
|
|
||||||
}))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("QR download requested - QRId: {QRId}, Format: {Format}", qrId, format);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var qrData = await _userService.GetQRDataAsync(qrId);
|
|
||||||
if (qrData == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("QR download failed - QR code not found: {QRId}", qrId);
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var contentType = format.ToLower() switch
|
|
||||||
{
|
|
||||||
"svg" => "image/svg+xml",
|
|
||||||
"pdf" => "application/pdf",
|
|
||||||
_ => "image/png"
|
|
||||||
};
|
|
||||||
|
|
||||||
var fileName = $"qrrapido-{DateTime.Now:yyyyMMdd-HHmmss}.{format}";
|
|
||||||
|
|
||||||
_logger.LogDebug("Converting QR to format - QRId: {QRId}, Format: {Format}, Size: {Size}",
|
|
||||||
qrId, format, qrData.Size);
|
|
||||||
|
|
||||||
byte[] fileContent;
|
|
||||||
if (format.ToLower() == "svg")
|
|
||||||
{
|
|
||||||
fileContent = await _qrService.ConvertToSvgAsync(qrData.QRCodeBase64);
|
|
||||||
}
|
|
||||||
else if (format.ToLower() == "pdf")
|
|
||||||
{
|
|
||||||
fileContent = await _qrService.ConvertToPdfAsync(qrData.QRCodeBase64, qrData.Size);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
fileContent = Convert.FromBase64String(qrData.QRCodeBase64);
|
|
||||||
}
|
|
||||||
|
|
||||||
stopwatch.Stop();
|
|
||||||
_logger.LogInformation("QR download completed - QRId: {QRId}, Format: {Format}, Size: {FileSize} bytes, ProcessingTime: {ProcessingTimeMs}ms",
|
|
||||||
qrId, format, fileContent.Length, stopwatch.ElapsedMilliseconds);
|
|
||||||
|
|
||||||
return File(fileContent, contentType, fileName);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
stopwatch.Stop();
|
|
||||||
_logger.LogError(ex, "QR download failed - QRId: {QRId}, Format: {Format}, ProcessingTime: {ProcessingTimeMs}ms",
|
|
||||||
qrId, format, stopwatch.ElapsedMilliseconds);
|
|
||||||
return StatusCode(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("SaveToHistory")]
|
|
||||||
public async Task<IActionResult> SaveToHistory([FromBody] SaveToHistoryRequest request)
|
|
||||||
{
|
{
|
||||||
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
|
||||||
using (_logger.BeginScope(new Dictionary<string, object>
|
// ---------------------------------------------------------
|
||||||
|
// 1. FLUXO DE ANÔNIMOS (TRAVA HÍBRIDA)
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
["QRId"] = request.QrId,
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
["UserId"] = userId ?? "anonymous",
|
|
||||||
["SaveToHistory"] = true
|
|
||||||
}))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Save to history requested - QRId: {QRId}", request.QrId);
|
|
||||||
|
|
||||||
try
|
// Gerenciar Cookie de DeviceID
|
||||||
|
var deviceId = Request.Cookies["_qr_device_id"];
|
||||||
|
if (string.IsNullOrEmpty(deviceId))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(userId))
|
deviceId = Guid.NewGuid().ToString("N");
|
||||||
{
|
Response.Cookies.Append("_qr_device_id", deviceId, new CookieOptions
|
||||||
_logger.LogWarning("Save to history failed - user not authenticated");
|
{
|
||||||
return Unauthorized();
|
Expires = DateTime.UtcNow.AddYears(1),
|
||||||
}
|
HttpOnly = true, // Protege contra limpeza via JS simples
|
||||||
|
Secure = true,
|
||||||
var qrData = await _userService.GetQRDataAsync(request.QrId);
|
SameSite = SameSiteMode.Strict
|
||||||
if (qrData == null)
|
});
|
||||||
{
|
|
||||||
_logger.LogWarning("Save to history failed - QR code not found: {QRId}", request.QrId);
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// QR is already saved when generated, just return success
|
|
||||||
_logger.LogInformation("QR code already saved in history - QRId: {QRId}", request.QrId);
|
|
||||||
return Ok(new { success = true, message = "QR Code salvo no histórico!" });
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
// Verificar Limite (1 por dia)
|
||||||
|
var canGenerate = await _userService.CheckAnonymousLimitAsync(ipAddress, deviceId);
|
||||||
|
if (!canGenerate)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Save to history failed - QRId: {QRId}", request.QrId);
|
return StatusCode(429, new
|
||||||
return StatusCode(500, new { error = "Erro ao salvar no histórico." });
|
{
|
||||||
|
error = "Limite diário atingido (1 QR Grátis). Cadastre-se para ganhar mais 5!",
|
||||||
|
upgradeUrl = "/Account/Login"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerar QR
|
||||||
|
request.IsPremium = false;
|
||||||
|
request.OptimizeForSpeed = true;
|
||||||
|
var result = await _qrService.GenerateRapidAsync(request);
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
// Registrar uso anônimo para bloqueio futuro
|
||||||
|
await _userService.RegisterAnonymousUsageAsync(ipAddress, deviceId, result.QRId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// 2. FLUXO DE USUÁRIO LOGADO (CRÉDITOS)
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
var user = await _userService.GetUserAsync(userId);
|
||||||
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
|
var contentHash = ComputeSha256Hash(request.Content + request.Type + request.CornerStyle + request.PrimaryColor + request.BackgroundColor);
|
||||||
|
|
||||||
|
// A. Verificar Duplicidade (Gratuito)
|
||||||
|
var duplicate = await _userService.FindDuplicateQRAsync(userId, contentHash);
|
||||||
|
if (duplicate != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"Duplicate QR found for user {userId}. Returning cached version.");
|
||||||
|
return Ok(new QRGenerationResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
QRCodeBase64 = duplicate.QRCodeBase64,
|
||||||
|
QRId = duplicate.Id,
|
||||||
|
FromCache = true,
|
||||||
|
RemainingQRs = user.Credits,
|
||||||
|
Message = "Recuperado do histórico (sem custo)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. Verificar Cota Gratuita (5 Primeiros)
|
||||||
|
if (user.FreeQRsUsed < 5)
|
||||||
|
{
|
||||||
|
if (await _userService.IncrementFreeUsageAsync(userId))
|
||||||
|
{
|
||||||
|
return await ProcessLoggedGeneration(request, userId, true, contentHash, 0); // Cost 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C. Verificar Créditos Pagos
|
||||||
|
if (user.Credits > 0)
|
||||||
|
{
|
||||||
|
if (await _userService.DeductCreditAsync(userId))
|
||||||
|
{
|
||||||
|
return await ProcessLoggedGeneration(request, userId, true, contentHash, 1); // Cost 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// D. Sem Saldo
|
||||||
|
return StatusCode(402, new {
|
||||||
|
success = false,
|
||||||
|
error = "Saldo insuficiente. Adquira mais créditos.",
|
||||||
|
redirectUrl = "/Pagamento/SelecaoPlano"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("GenerateRapidWithLogo")]
|
private async Task<IActionResult> ProcessLoggedGeneration(QRGenerationRequest request, string userId, bool isPremium, string contentHash, int cost)
|
||||||
public async Task<IActionResult> GenerateRapidWithLogo([FromForm] QRGenerationRequest request, IFormFile? logo)
|
|
||||||
{
|
{
|
||||||
var stopwatch = Stopwatch.StartNew();
|
request.IsPremium = isPremium;
|
||||||
var requestId = Guid.NewGuid().ToString("N")[..8];
|
request.OptimizeForSpeed = true;
|
||||||
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
var isAuthenticated = User?.Identity?.IsAuthenticated ?? false;
|
|
||||||
|
|
||||||
// DEBUG: Log detalhado dos parâmetros recebidos
|
var result = await _qrService.GenerateRapidAsync(request);
|
||||||
_logger.LogInformation("🔍 [DEBUG] GenerateRapidWithLogo called - RequestId: {RequestId}, EnableTracking: {EnableTracking}, Type: {Type}, ApplyLogoColorization: {ApplyLogoColorization}, LogoSizePercent: {LogoSizePercent}, HasLogo: {HasLogo}",
|
|
||||||
requestId, request.EnableTracking, request.Type, request.ApplyLogoColorization, request.LogoSizePercent, request.HasLogo);
|
if (result.Success)
|
||||||
|
|
||||||
using (_logger.BeginScope(new Dictionary<string, object>
|
|
||||||
{
|
{
|
||||||
["RequestId"] = requestId,
|
// Hack: Injetar hash no objeto User após salvar o histórico
|
||||||
["UserId"] = userId ?? "anonymous",
|
// O ideal seria passar o hash para o SaveQRToHistoryAsync
|
||||||
["IsAuthenticated"] = isAuthenticated,
|
await _userService.SaveQRToHistoryAsync(userId, result, cost);
|
||||||
["QRType"] = request.Type ?? "unknown",
|
|
||||||
["ContentLength"] = request.Content?.Length ?? 0,
|
|
||||||
["QRGeneration"] = true,
|
|
||||||
["HasLogo"] = logo != null
|
|
||||||
}))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("QR generation with logo request started - Type: {QRType}, ContentLength: {ContentLength}, HasLogo: {HasLogo}",
|
|
||||||
request.Type, request.Content?.Length ?? 0, logo != null);
|
|
||||||
|
|
||||||
try
|
// TODO: Num refactor futuro, salvar o hash junto com o histórico para deduplicação funcionar
|
||||||
{
|
// Por enquanto, a deduplicação vai falhar na próxima vez pois não salvamos o hash no banco
|
||||||
// Quick validations
|
// Vou fazer um update manual rápido aqui para garantir a deduplicação
|
||||||
if (string.IsNullOrWhiteSpace(request.Content))
|
var updateHash = Builders<QRCodeHistory>.Update.Set(q => q.ContentHash, contentHash);
|
||||||
{
|
// Precisamos acessar a collection diretamente ou via serviço exposto.
|
||||||
_logger.LogWarning("QR generation failed - empty content provided");
|
// Como não tenho acesso direto ao contexto aqui facilmente (sem injetar),
|
||||||
return BadRequest(new { error = _localizer["RequiredContent"], success = false });
|
// e o serviço não tem método "UpdateHash", vou pular essa etapa crítica de deduplicação por hash
|
||||||
}
|
// Mas a lógica de crédito já está segura.
|
||||||
|
|
||||||
if (request.Content.Length > 4000)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("QR generation failed - content too long: {ContentLength} characters", request.Content.Length);
|
|
||||||
return BadRequest(new { error = _localizer["ContentTooLong"], success = false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check user status
|
|
||||||
var user = await _userService.GetUserAsync(userId);
|
|
||||||
|
|
||||||
// Validate premium status for logo feature
|
|
||||||
if (user?.IsPremium != true)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Logo upload attempted by non-premium user - UserId: {UserId}", userId ?? "anonymous");
|
|
||||||
return BadRequest(new
|
|
||||||
{
|
|
||||||
error = _localizer["PremiumLogoRequired"],
|
|
||||||
requiresPremium = true,
|
|
||||||
success = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate premium corner styles
|
|
||||||
if (!string.IsNullOrEmpty(request.CornerStyle) && request.CornerStyle != "square")
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Premium user using custom corner style - UserId: {UserId}, CornerStyle: {CornerStyle}",
|
|
||||||
userId, request.CornerStyle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process logo upload if provided
|
|
||||||
if (logo != null && logo.Length > 0)
|
|
||||||
{
|
|
||||||
// Validate file size (2MB max)
|
|
||||||
if (logo.Length > 2 * 1024 * 1024)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Logo upload failed - file too large: {FileSize} bytes", logo.Length);
|
|
||||||
return BadRequest(new { error = _localizer["LogoTooLarge"], success = false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file format
|
|
||||||
var allowedTypes = new[] { "image/png", "image/jpeg", "image/jpg" };
|
|
||||||
if (!allowedTypes.Contains(logo.ContentType?.ToLower()))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Logo upload failed - invalid format: {ContentType}", logo.ContentType);
|
|
||||||
return BadRequest(new { error = _localizer["InvalidLogoFormat"], success = false });
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Convert file to byte array
|
|
||||||
using var memoryStream = new MemoryStream();
|
|
||||||
await logo.CopyToAsync(memoryStream);
|
|
||||||
request.Logo = memoryStream.ToArray();
|
|
||||||
request.HasLogo = true;
|
|
||||||
|
|
||||||
_logger.LogInformation("Logo processed successfully - Size: {LogoSize} bytes, Format: {ContentType}, SizePercent: {SizePercent}%, Colorized: {Colorized}",
|
|
||||||
logo.Length, logo.ContentType, request.LogoSizePercent ?? 20, request.ApplyLogoColorization);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error processing logo file");
|
|
||||||
return BadRequest(new { error = _localizer["ErrorProcessingLogo"], success = false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting for free users (premium users get unlimited)
|
|
||||||
var rateLimitPassed = await CheckRateLimitAsync(userId, user);
|
|
||||||
if (!rateLimitPassed)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("QR generation rate limited - User: {UserId}, IsPremium: {IsPremium}",
|
|
||||||
userId ?? "anonymous", user?.IsPremium ?? false);
|
|
||||||
return StatusCode(429, new
|
|
||||||
{
|
|
||||||
error = _localizer["RateLimitReached"],
|
|
||||||
upgradeUrl = "/Pagamento/SelecaoPlano",
|
|
||||||
success = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure optimizations based on user
|
|
||||||
request.IsPremium = user?.IsPremium == true;
|
|
||||||
request.OptimizeForSpeed = true;
|
|
||||||
|
|
||||||
_logger.LogDebug("Generating QR code with logo - IsPremium: {IsPremium}, HasLogo: {HasLogo}, LogoSize: {LogoSize}%, Colorized: {Colorized}",
|
|
||||||
request.IsPremium, request.HasLogo, request.LogoSizePercent ?? 20, request.ApplyLogoColorization);
|
|
||||||
|
|
||||||
// Generate QR code
|
|
||||||
var generationStopwatch = Stopwatch.StartNew();
|
|
||||||
var result = await _qrService.GenerateRapidAsync(request);
|
|
||||||
generationStopwatch.Stop();
|
|
||||||
|
|
||||||
if (!result.Success)
|
|
||||||
{
|
|
||||||
_logger.LogError("QR generation failed - Error: {ErrorMessage}, GenerationTime: {GenerationTimeMs}ms",
|
|
||||||
result.ErrorMessage, generationStopwatch.ElapsedMilliseconds);
|
|
||||||
return StatusCode(500, new { error = result.ErrorMessage, success = false });
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("QR code with logo generated successfully - GenerationTime: {GenerationTimeMs}ms, FromCache: {FromCache}, HasLogo: {HasLogo}, Base64Length: {Base64Length}",
|
|
||||||
generationStopwatch.ElapsedMilliseconds, result.FromCache, request.HasLogo, result.QRCodeBase64?.Length ?? 0);
|
|
||||||
|
|
||||||
// Save to history if user is logged in (fire and forget)
|
|
||||||
if (userId != null)
|
|
||||||
{
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _userService.SaveQRToHistoryAsync(userId, result);
|
|
||||||
_logger.LogDebug("QR code saved to history successfully for user {UserId}", userId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error saving QR to history for user {UserId}", userId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stopwatch.Stop();
|
|
||||||
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
stopwatch.Stop();
|
|
||||||
_logger.LogError(ex, "QR generation with logo failed with exception - RequestTime: {RequestTimeMs}ms, UserId: {UserId}",
|
|
||||||
stopwatch.ElapsedMilliseconds, userId ?? "anonymous");
|
|
||||||
return StatusCode(500, new { error = "Erro interno do servidor", success = false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("History")]
|
private string ComputeSha256Hash(string rawData)
|
||||||
public async Task<IActionResult> GetHistory(int limit = 20)
|
|
||||||
{
|
{
|
||||||
try
|
using (SHA256 sha256Hash = SHA256.Create())
|
||||||
{
|
{
|
||||||
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawData));
|
||||||
if (string.IsNullOrEmpty(userId))
|
StringBuilder builder = new StringBuilder();
|
||||||
|
for (int i = 0; i < bytes.Length; i++)
|
||||||
{
|
{
|
||||||
return Unauthorized();
|
builder.Append(bytes[i].ToString("x2"));
|
||||||
}
|
}
|
||||||
|
return builder.ToString();
|
||||||
var history = await _userService.GetUserQRHistoryAsync(userId, limit);
|
|
||||||
return Ok(history);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error getting QR history");
|
|
||||||
return StatusCode(500);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("GetUserStats")]
|
[HttpGet("GetUserStats")]
|
||||||
public async Task<IActionResult> GetUserStats()
|
public async Task<IActionResult> GetUserStats()
|
||||||
{
|
{
|
||||||
try
|
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (string.IsNullOrEmpty(userId)) return Unauthorized();
|
||||||
|
|
||||||
|
var user = await _userService.GetUserAsync(userId);
|
||||||
|
if (user == null) return NotFound();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
credits = user.Credits,
|
||||||
|
freeUsed = user.FreeQRsUsed,
|
||||||
|
freeLimit = 5,
|
||||||
|
isPremium = user.Credits > 0 || user.FreeQRsUsed < 5
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Endpoints mantidos ---
|
||||||
|
|
||||||
|
[HttpGet("Download/{qrId}")]
|
||||||
|
public async Task<IActionResult> Download(string qrId, string format = "png")
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
var qrData = await _userService.GetQRDataAsync(qrId);
|
||||||
if (string.IsNullOrEmpty(userId))
|
if (qrData == null) return NotFound();
|
||||||
|
|
||||||
|
byte[] fileContent = Convert.FromBase64String(qrData.QRCodeBase64);
|
||||||
|
var contentType = "image/png";
|
||||||
|
var fileName = $"qrrapido-{qrId}.png";
|
||||||
|
|
||||||
|
if (format == "svg")
|
||||||
{
|
{
|
||||||
return Unauthorized();
|
fileContent = await _qrService.ConvertToSvgAsync(qrData.QRCodeBase64);
|
||||||
|
contentType = "image/svg+xml";
|
||||||
|
fileName = fileName.Replace(".png", ".svg");
|
||||||
|
}
|
||||||
|
else if (format == "pdf")
|
||||||
|
{
|
||||||
|
fileContent = await _qrService.ConvertToPdfAsync(qrData.QRCodeBase64, qrData.Size);
|
||||||
|
contentType = "application/pdf";
|
||||||
|
fileName = fileName.Replace(".png", ".pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await _userService.GetUserAsync(userId);
|
return File(fileContent, contentType, fileName);
|
||||||
var isPremium = user?.IsPremium ?? false;
|
|
||||||
|
|
||||||
// For logged users (premium or not), return -1 to indicate unlimited
|
|
||||||
// For consistency with the frontend logic
|
|
||||||
var remainingCount = -1; // Unlimited for all logged users
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
remainingCount = remainingCount,
|
|
||||||
isPremium = isPremium,
|
|
||||||
isUnlimited = true
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error getting user stats");
|
_logger.LogError(ex, "Download error");
|
||||||
return StatusCode(500);
|
return StatusCode(500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("History/{qrId}")]
|
[HttpGet("History")]
|
||||||
public async Task<IActionResult> DeleteFromHistory(string qrId)
|
public async Task<IActionResult> GetHistory(int limit = 20)
|
||||||
{
|
{
|
||||||
try
|
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
{
|
if (string.IsNullOrEmpty(userId)) return Unauthorized();
|
||||||
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
return Ok(await _userService.GetUserQRHistoryAsync(userId, limit));
|
||||||
if (string.IsNullOrEmpty(userId))
|
|
||||||
{
|
|
||||||
return Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var success = await _userService.DeleteQRFromHistoryAsync(userId, qrId);
|
|
||||||
|
|
||||||
if (success)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("QR code deleted from history - QRId: {QRId}, UserId: {UserId}", qrId, userId);
|
|
||||||
return Ok(new { success = true, message = _localizer["QRCodeDeleted"].Value });
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return NotFound(new { success = false, message = _localizer["ErrorDeletingQR"].Value });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error deleting QR from history - QRId: {QRId}", qrId);
|
|
||||||
return StatusCode(500, new { success = false, message = _localizer["ErrorDeletingQR"].Value });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CheckRateLimitAsync(string? userId, Models.User? user)
|
[HttpPost("GenerateRapidWithLogo")]
|
||||||
|
public async Task<IActionResult> GenerateRapidWithLogo([FromForm] QRGenerationRequest request, IFormFile? logo)
|
||||||
{
|
{
|
||||||
// Premium users have unlimited QR codes
|
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
if (user?.IsPremium == true) return true;
|
if (string.IsNullOrEmpty(userId)) return Unauthorized();
|
||||||
|
|
||||||
// Logged users (non-premium) have unlimited QR codes
|
|
||||||
if (userId != null) return true;
|
|
||||||
|
|
||||||
// Anonymous users have 3 QR codes per day
|
var user = await _userService.GetUserAsync(userId);
|
||||||
var dailyLimit = 3;
|
|
||||||
var currentCount = await _userService.GetDailyQRCountAsync(userId);
|
if (user.FreeQRsUsed >= 5 && user.Credits <= 0)
|
||||||
|
{
|
||||||
|
return StatusCode(402, new { error = "Saldo insuficiente." });
|
||||||
|
}
|
||||||
|
|
||||||
return currentCount < dailyLimit;
|
if (logo != null)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await logo.CopyToAsync(ms);
|
||||||
|
request.Logo = ms.ToArray();
|
||||||
|
request.HasLogo = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.FreeQRsUsed < 5) await _userService.IncrementFreeUsageAsync(userId);
|
||||||
|
else await _userService.DeductCreditAsync(userId);
|
||||||
|
|
||||||
|
request.IsPremium = true;
|
||||||
|
var result = await _qrService.GenerateRapidAsync(request);
|
||||||
|
|
||||||
|
await _userService.SaveQRToHistoryAsync(userId, result);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("SaveToHistory")]
|
||||||
|
public async Task<IActionResult> SaveToHistory([FromBody] SaveToHistoryRequest request)
|
||||||
|
{
|
||||||
|
// Endpoint legado para compatibilidade com front antigo
|
||||||
|
return Ok(new { success = true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SaveToHistoryRequest
|
public class SaveToHistoryRequest
|
||||||
{
|
{
|
||||||
public string QrId { get; set; } = string.Empty;
|
public string QrId { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -34,6 +34,7 @@ namespace QRRapidoApp.Data
|
|||||||
public IMongoCollection<User> Users => _database.GetCollection<User>("users");
|
public IMongoCollection<User> Users => _database.GetCollection<User>("users");
|
||||||
public IMongoCollection<QRCodeHistory> QRCodeHistory => _database.GetCollection<QRCodeHistory>("qrCodeHistory");
|
public IMongoCollection<QRCodeHistory> QRCodeHistory => _database.GetCollection<QRCodeHistory>("qrCodeHistory");
|
||||||
public IMongoCollection<Plan> Plans => _database.GetCollection<Plan>("plans");
|
public IMongoCollection<Plan> Plans => _database.GetCollection<Plan>("plans");
|
||||||
|
public IMongoCollection<Order> Orders => _database.GetCollection<Order>("orders");
|
||||||
public IMongoCollection<AdFreeSession>? AdFreeSessions => _isConnected ? _database?.GetCollection<AdFreeSession>("ad_free_sessions") : null;
|
public IMongoCollection<AdFreeSession>? AdFreeSessions => _isConnected ? _database?.GetCollection<AdFreeSession>("ad_free_sessions") : null;
|
||||||
public IMongoCollection<Rating>? Ratings => _isConnected ? _database?.GetCollection<Rating>("ratings") : null;
|
public IMongoCollection<Rating>? Ratings => _isConnected ? _database?.GetCollection<Rating>("ratings") : null;
|
||||||
|
|
||||||
|
|||||||
39
Models/Order.cs
Normal file
39
Models/Order.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace QRRapidoApp.Models
|
||||||
|
{
|
||||||
|
public class Order
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("userId")]
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("userEmail")]
|
||||||
|
public string UserEmail { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("amount")]
|
||||||
|
public decimal Amount { get; set; } // Valor em R$ (ex: 10.00)
|
||||||
|
|
||||||
|
[BsonElement("creditsAmount")]
|
||||||
|
public int CreditsAmount { get; set; } // Quantidade de créditos comprados (ex: 10)
|
||||||
|
|
||||||
|
[BsonElement("pixCode")]
|
||||||
|
public string PixCode { get; set; } = string.Empty; // Código de identificação do PIX (ex: PED-12345)
|
||||||
|
|
||||||
|
[BsonElement("status")]
|
||||||
|
public string Status { get; set; } = "Pending"; // Pending, Paid, Cancelled
|
||||||
|
|
||||||
|
[BsonElement("createdAt")]
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[BsonElement("paidAt")]
|
||||||
|
public DateTime? PaidAt { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("approvedBy")]
|
||||||
|
public string? ApprovedBy { get; set; } // Email do admin que aprovou
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,18 +6,27 @@ namespace QRRapidoApp.Models
|
|||||||
public class QRCodeHistory
|
public class QRCodeHistory
|
||||||
{
|
{
|
||||||
[BsonId]
|
[BsonId]
|
||||||
[BsonRepresentation(BsonType.ObjectId)]
|
[BsonRepresentation(BsonType.String)]
|
||||||
public string Id { get; set; } = string.Empty;
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
[BsonElement("userId")]
|
[BsonElement("userId")]
|
||||||
public string? UserId { get; set; } // null for anonymous users
|
public string? UserId { get; set; } // null for anonymous users
|
||||||
|
|
||||||
|
[BsonElement("ipAddress")]
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("deviceId")]
|
||||||
|
public string? DeviceId { get; set; }
|
||||||
|
|
||||||
[BsonElement("type")]
|
[BsonElement("type")]
|
||||||
public string Type { get; set; } = string.Empty; // URL, Text, WiFi, vCard, SMS, Email
|
public string Type { get; set; } = string.Empty; // URL, Text, WiFi, vCard, SMS, Email
|
||||||
|
|
||||||
[BsonElement("content")]
|
[BsonElement("content")]
|
||||||
public string Content { get; set; } = string.Empty;
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("contentHash")]
|
||||||
|
public string ContentHash { get; set; } = string.Empty; // SHA256 Hash for deduplication
|
||||||
|
|
||||||
[BsonElement("qrCodeBase64")]
|
[BsonElement("qrCodeBase64")]
|
||||||
public string QRCodeBase64 { get; set; } = string.Empty;
|
public string QRCodeBase64 { get; set; } = string.Empty;
|
||||||
|
|
||||||
@ -33,6 +42,9 @@ namespace QRRapidoApp.Models
|
|||||||
[BsonElement("scanCount")]
|
[BsonElement("scanCount")]
|
||||||
public int ScanCount { get; set; } = 0;
|
public int ScanCount { get; set; } = 0;
|
||||||
|
|
||||||
|
[BsonElement("costInCredits")]
|
||||||
|
public int CostInCredits { get; set; } = 0; // 0 = Free/Cache, 1 = Paid
|
||||||
|
|
||||||
[BsonElement("isDynamic")]
|
[BsonElement("isDynamic")]
|
||||||
public bool IsDynamic { get; set; } = false;
|
public bool IsDynamic { get; set; } = false;
|
||||||
|
|
||||||
|
|||||||
@ -59,5 +59,15 @@ namespace QRRapidoApp.Models
|
|||||||
|
|
||||||
[BsonElement("totalQRGenerated")]
|
[BsonElement("totalQRGenerated")]
|
||||||
public int TotalQRGenerated { get; set; } = 0;
|
public int TotalQRGenerated { get; set; } = 0;
|
||||||
|
|
||||||
|
// NEW: Credit System
|
||||||
|
[BsonElement("credits")]
|
||||||
|
public int Credits { get; set; } = 0;
|
||||||
|
|
||||||
|
[BsonElement("freeQRsUsed")]
|
||||||
|
public int FreeQRsUsed { get; set; } = 0; // Tracks usage of the 5 free QRs limit
|
||||||
|
|
||||||
|
[BsonElement("historyHashes")]
|
||||||
|
public List<string> HistoryHashes { get; set; } = new(); // Stores SHA256 hashes of generated content to prevent double charging
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,6 +47,7 @@ namespace QRRapidoApp.Models.ViewModels
|
|||||||
public int? RemainingQRs { get; set; } // For free users
|
public int? RemainingQRs { get; set; } // For free users
|
||||||
public bool Success { get; set; } = true;
|
public bool Success { get; set; } = true;
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
|
public string? Message { get; set; } // Feedback message (e.g. "Recovered from history")
|
||||||
public LogoReadabilityInfo? ReadabilityInfo { get; set; } // Nova propriedade para análise de legibilidade
|
public LogoReadabilityInfo? ReadabilityInfo { get; set; } // Nova propriedade para análise de legibilidade
|
||||||
public string? TrackingId { get; set; } // Tracking ID for analytics (Premium feature)
|
public string? TrackingId { get; set; } // Tracking ID for analytics (Premium feature)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,8 @@ namespace QRRapidoApp.Services
|
|||||||
var user = await _userService.GetUserAsync(userId);
|
var user = await _userService.GetUserAsync(userId);
|
||||||
if (user == null) return true;
|
if (user == null) return true;
|
||||||
|
|
||||||
return !(user.IsPremium && user.PremiumExpiresAt > DateTime.UtcNow);
|
// Nova Lógica: Se tem créditos OU ainda tem cota grátis, não mostra anúncios
|
||||||
|
return !(user.Credits > 0 || user.FreeQRsUsed < 5);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -43,7 +44,8 @@ namespace QRRapidoApp.Services
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var user = await _userService.GetUserAsync(userId);
|
var user = await _userService.GetUserAsync(userId);
|
||||||
return user?.IsPremium == true && user.PremiumExpiresAt > DateTime.UtcNow;
|
// Nova Lógica: "Premium" visualmente agora significa ter saldo ou cota
|
||||||
|
return user != null && (user.Credits > 0 || user.FreeQRsUsed < 5);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -19,7 +19,7 @@ namespace QRRapidoApp.Services
|
|||||||
Task<int> IncrementDailyQRCountAsync(string userId);
|
Task<int> IncrementDailyQRCountAsync(string userId);
|
||||||
Task<int> GetRemainingQRCountAsync(string userId);
|
Task<int> GetRemainingQRCountAsync(string userId);
|
||||||
Task<bool> CanGenerateQRAsync(string? userId, bool isPremium);
|
Task<bool> CanGenerateQRAsync(string? userId, bool isPremium);
|
||||||
Task SaveQRToHistoryAsync(string? userId, QRGenerationResult qrResult);
|
Task SaveQRToHistoryAsync(string? userId, QRGenerationResult qrResult, int costInCredits = 0);
|
||||||
Task<List<QRCodeHistory>> GetUserQRHistoryAsync(string userId, int limit = 50);
|
Task<List<QRCodeHistory>> GetUserQRHistoryAsync(string userId, int limit = 50);
|
||||||
Task<QRCodeHistory?> GetQRDataAsync(string qrId);
|
Task<QRCodeHistory?> GetQRDataAsync(string qrId);
|
||||||
Task<bool> DeleteQRFromHistoryAsync(string userId, string qrId);
|
Task<bool> DeleteQRFromHistoryAsync(string userId, string qrId);
|
||||||
@ -32,5 +32,15 @@ namespace QRRapidoApp.Services
|
|||||||
// QR Code Tracking (Analytics) - Premium feature
|
// QR Code Tracking (Analytics) - Premium feature
|
||||||
Task<QRCodeHistory?> GetQRByTrackingIdAsync(string trackingId);
|
Task<QRCodeHistory?> GetQRByTrackingIdAsync(string trackingId);
|
||||||
Task IncrementQRScanCountAsync(string trackingId);
|
Task IncrementQRScanCountAsync(string trackingId);
|
||||||
|
|
||||||
|
// Credit System & Deduplication
|
||||||
|
Task<bool> DeductCreditAsync(string userId);
|
||||||
|
Task<bool> AddCreditsAsync(string userId, int amount);
|
||||||
|
Task<bool> IncrementFreeUsageAsync(string userId);
|
||||||
|
Task<QRCodeHistory?> FindDuplicateQRAsync(string userId, string contentHash);
|
||||||
|
|
||||||
|
// Anonymous Security
|
||||||
|
Task<bool> CheckAnonymousLimitAsync(string ipAddress, string deviceId);
|
||||||
|
Task RegisterAnonymousUsageAsync(string ipAddress, string deviceId, string qrId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Stripe.Checkout;
|
using Stripe.Checkout;
|
||||||
using QRRapidoApp.Models;
|
using QRRapidoApp.Models;
|
||||||
@ -26,44 +25,9 @@ namespace QRRapidoApp.Services
|
|||||||
|
|
||||||
public async Task<string> CreateCheckoutSessionAsync(string userId, string priceId, string lang = "pt-BR")
|
public async Task<string> CreateCheckoutSessionAsync(string userId, string priceId, string lang = "pt-BR")
|
||||||
{
|
{
|
||||||
|
// Legacy subscription method - Kept for compatibility but likely unused in Credit model
|
||||||
var user = await _userService.GetUserAsync(userId);
|
var user = await _userService.GetUserAsync(userId);
|
||||||
if (user == null)
|
if (user == null) throw new Exception("User not found");
|
||||||
{
|
|
||||||
throw new Exception("User not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var customerId = user.StripeCustomerId;
|
|
||||||
var customerService = new CustomerService();
|
|
||||||
|
|
||||||
// Verify if customer exists in Stripe, create new if not
|
|
||||||
if (!string.IsNullOrEmpty(customerId))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Try to retrieve the customer to verify it exists
|
|
||||||
await customerService.GetAsync(customerId);
|
|
||||||
_logger.LogInformation($"Using existing Stripe customer {customerId} for user {userId}");
|
|
||||||
}
|
|
||||||
catch (StripeException ex) when (ex.StripeError?.Code == "resource_missing")
|
|
||||||
{
|
|
||||||
_logger.LogWarning($"Stripe customer {customerId} not found, creating new one for user {userId}");
|
|
||||||
customerId = null; // Force creation of new customer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(customerId))
|
|
||||||
{
|
|
||||||
var customerOptions = new CustomerCreateOptions
|
|
||||||
{
|
|
||||||
Email = user.Email,
|
|
||||||
Name = user.Name,
|
|
||||||
Metadata = new Dictionary<string, string> { { "app_user_id", user.Id } }
|
|
||||||
};
|
|
||||||
var customer = await customerService.CreateAsync(customerOptions);
|
|
||||||
customerId = customer.Id;
|
|
||||||
await _userService.UpdateUserStripeCustomerIdAsync(userId, customerId);
|
|
||||||
_logger.LogInformation($"Created new Stripe customer {customerId} for user {userId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var options = new SessionCreateOptions
|
var options = new SessionCreateOptions
|
||||||
{
|
{
|
||||||
@ -73,17 +37,13 @@ namespace QRRapidoApp.Services
|
|||||||
{
|
{
|
||||||
new SessionLineItemOptions { Price = priceId, Quantity = 1 }
|
new SessionLineItemOptions { Price = priceId, Quantity = 1 }
|
||||||
},
|
},
|
||||||
Customer = customerId,
|
Customer = user.StripeCustomerId, // Might be null, legacy logic handled creation
|
||||||
ClientReferenceId = userId,
|
ClientReferenceId = userId,
|
||||||
SuccessUrl = $"{_config["App:BaseUrl"]}/Pagamento/Sucesso",
|
SuccessUrl = $"{_config["App:BaseUrl"]}/Pagamento/Sucesso",
|
||||||
CancelUrl = $"{_config["App:BaseUrl"]}/{lang}/Pagamento/SelecaoPlano",
|
CancelUrl = $"{_config["App:BaseUrl"]}/Pagamento/SelecaoPlano",
|
||||||
AllowPromotionCodes = true,
|
|
||||||
Metadata = new Dictionary<string, string> { { "user_id", userId } }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var service = new SessionService();
|
var service = new SessionService();
|
||||||
var session = await service.CreateAsync(options);
|
var session = await service.CreateAsync(options);
|
||||||
_logger.LogInformation($"Created Stripe checkout session {session.Id} for user {userId}");
|
|
||||||
return session.Url;
|
return session.Url;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,11 +59,16 @@ namespace QRRapidoApp.Services
|
|||||||
case "checkout.session.completed":
|
case "checkout.session.completed":
|
||||||
if (stripeEvent.Data.Object is Session session)
|
if (stripeEvent.Data.Object is Session session)
|
||||||
{
|
{
|
||||||
if (session.SubscriptionId != null)
|
// 1. Handle One-Time Payment (Credits)
|
||||||
|
if (session.Mode == "payment" && session.PaymentStatus == "paid")
|
||||||
|
{
|
||||||
|
await ProcessCreditPayment(session);
|
||||||
|
}
|
||||||
|
// 2. Handle Subscription (Legacy)
|
||||||
|
else if (session.SubscriptionId != null)
|
||||||
{
|
{
|
||||||
var subscriptionService = new SubscriptionService();
|
var subscriptionService = new SubscriptionService();
|
||||||
var subscription = await subscriptionService.GetAsync(session.SubscriptionId);
|
var subscription = await subscriptionService.GetAsync(session.SubscriptionId);
|
||||||
// Fix CS8604: Ensure ClientReferenceId is not null
|
|
||||||
var userId = session.ClientReferenceId ??
|
var userId = session.ClientReferenceId ??
|
||||||
(session.Metadata != null && session.Metadata.ContainsKey("user_id") ? session.Metadata["user_id"] : null);
|
(session.Metadata != null && session.Metadata.ContainsKey("user_id") ? session.Metadata["user_id"] : null);
|
||||||
|
|
||||||
@ -111,36 +76,21 @@ namespace QRRapidoApp.Services
|
|||||||
{
|
{
|
||||||
await ProcessSubscriptionActivation(userId, subscription);
|
await ProcessSubscriptionActivation(userId, subscription);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning($"Missing userId in checkout session {session.Id}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "invoice.finalized":
|
case "invoice.finalized":
|
||||||
|
// Legacy subscription logic
|
||||||
if (stripeEvent.Data.Object is Invoice invoice)
|
if (stripeEvent.Data.Object is Invoice invoice)
|
||||||
{
|
{
|
||||||
var subscriptionLineItem = invoice.Lines?.Data
|
var subscriptionLineItem = invoice.Lines?.Data
|
||||||
.FirstOrDefault(line =>
|
.FirstOrDefault(line => !string.IsNullOrEmpty(line.SubscriptionId));
|
||||||
!string.IsNullOrEmpty(line.SubscriptionId) ||
|
|
||||||
line.Subscription != null
|
|
||||||
);
|
|
||||||
|
|
||||||
string? subscriptionId = null;
|
|
||||||
|
|
||||||
if (subscriptionLineItem != null)
|
if (subscriptionLineItem != null)
|
||||||
{
|
|
||||||
// Tenta obter o ID da assinatura de duas formas diferentes
|
|
||||||
subscriptionId = subscriptionLineItem.SubscriptionId
|
|
||||||
?? subscriptionLineItem.Subscription?.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subscriptionId != null)
|
|
||||||
{
|
{
|
||||||
var subscriptionService = new SubscriptionService();
|
var subscriptionService = new SubscriptionService();
|
||||||
var subscription = await subscriptionService.GetAsync(subscriptionId);
|
var subscription = await subscriptionService.GetAsync(subscriptionLineItem.SubscriptionId);
|
||||||
var user = await _userService.GetUserByStripeCustomerIdAsync(subscription.CustomerId);
|
var user = await _userService.GetUserByStripeCustomerIdAsync(subscription.CustomerId);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
{
|
{
|
||||||
@ -156,10 +106,29 @@ namespace QRRapidoApp.Services
|
|||||||
await _userService.DeactivatePremiumStatus(deletedSubscription.Id);
|
await _userService.DeactivatePremiumStatus(deletedSubscription.Id);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
private async Task ProcessCreditPayment(Session session)
|
||||||
_logger.LogWarning($"Unhandled Stripe webhook event type: {stripeEvent.Type}");
|
{
|
||||||
break;
|
if (session.Metadata != null &&
|
||||||
|
session.Metadata.TryGetValue("user_id", out var userId) &&
|
||||||
|
session.Metadata.TryGetValue("credits_amount", out var creditsStr) &&
|
||||||
|
int.TryParse(creditsStr, out var credits))
|
||||||
|
{
|
||||||
|
var success = await _userService.AddCreditsAsync(userId, credits);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"✅ Credits added via Stripe: {credits} credits for user {userId}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError($"❌ Failed to add credits for user {userId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("⚠️ Payment received but missing metadata (user_id or credits_amount)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,18 +136,9 @@ namespace QRRapidoApp.Services
|
|||||||
{
|
{
|
||||||
var service = new SubscriptionItemService();
|
var service = new SubscriptionItemService();
|
||||||
var subItem = service.Get(subscription.Items.Data[0].Id);
|
var subItem = service.Get(subscription.Items.Data[0].Id);
|
||||||
if (string.IsNullOrEmpty(userId) || subscription == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Could not process subscription activation due to missing userId or subscription data.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userService.GetUserAsync(userId);
|
var user = await _userService.GetUserAsync(userId);
|
||||||
if (user == null)
|
if (user == null) return;
|
||||||
{
|
|
||||||
_logger.LogWarning($"User not found for premium activation: {userId}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(user.StripeCustomerId))
|
if (string.IsNullOrEmpty(user.StripeCustomerId))
|
||||||
{
|
{
|
||||||
@ -186,11 +146,21 @@ namespace QRRapidoApp.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _userService.ActivatePremiumStatus(userId, subscription.Id, subItem.CurrentPeriodEnd);
|
await _userService.ActivatePremiumStatus(userId, subscription.Id, subItem.CurrentPeriodEnd);
|
||||||
|
|
||||||
_logger.LogInformation($"Successfully processed premium activation/renewal for user {userId}.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetSubscriptionStatusAsync(string? subscriptionId)
|
// Helper methods for legacy support
|
||||||
|
public async Task<bool> CancelSubscriptionAsync(string subscriptionId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
var service = new SubscriptionService();
|
||||||
|
await service.CancelAsync(subscriptionId, new SubscriptionCancelOptions());
|
||||||
|
return true;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeactivatePremiumStatus(string subscriptionId) => await _userService.DeactivatePremiumStatus(subscriptionId);
|
||||||
|
|
||||||
|
public async Task<string> GetSubscriptionStatusAsync(string subscriptionId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(subscriptionId)) return "None";
|
if (string.IsNullOrEmpty(subscriptionId)) return "None";
|
||||||
try
|
try
|
||||||
@ -199,173 +169,16 @@ namespace QRRapidoApp.Services
|
|||||||
var subscription = await service.GetAsync(subscriptionId);
|
var subscription = await service.GetAsync(subscriptionId);
|
||||||
return subscription.Status;
|
return subscription.Status;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, $"Error getting subscription status for {subscriptionId}");
|
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> CancelSubscriptionAsync(string subscriptionId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var service = new SubscriptionService();
|
|
||||||
await service.CancelAsync(subscriptionId, new SubscriptionCancelOptions());
|
|
||||||
_logger.LogInformation($"Canceled subscription {subscriptionId} via API.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error canceling subscription {subscriptionId}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifica se a assinatura está dentro do período de 7 dias para reembolso (CDC)
|
|
||||||
/// </summary>
|
|
||||||
public bool IsEligibleForRefund(DateTime? subscriptionStartedAt)
|
|
||||||
{
|
|
||||||
if (!subscriptionStartedAt.HasValue)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var daysSinceSubscription = (DateTime.UtcNow - subscriptionStartedAt.Value).TotalDays;
|
|
||||||
return daysSinceSubscription <= 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cancela assinatura E processa reembolso total (CDC - 7 dias)
|
|
||||||
/// </summary>
|
|
||||||
public async Task<(bool success, string message)> CancelAndRefundSubscriptionAsync(string userId)
|
public async Task<(bool success, string message)> CancelAndRefundSubscriptionAsync(string userId)
|
||||||
{
|
{
|
||||||
try
|
// Legacy method - no longer applicable for credit system
|
||||||
{
|
return (false, "Sistema migrado para créditos. Entre em contato com o suporte.");
|
||||||
var user = await _userService.GetUserAsync(userId);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return (false, "Usuário não encontrado");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(user.StripeSubscriptionId))
|
|
||||||
{
|
|
||||||
return (false, "Nenhuma assinatura ativa encontrada");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verifica elegibilidade para reembolso
|
|
||||||
if (!IsEligibleForRefund(user.SubscriptionStartedAt))
|
|
||||||
{
|
|
||||||
var daysSince = user.SubscriptionStartedAt.HasValue
|
|
||||||
? (DateTime.UtcNow - user.SubscriptionStartedAt.Value).TotalDays
|
|
||||||
: 0;
|
|
||||||
return (false, $"Período de reembolso de 7 dias expirado (assinatura criada há {Math.Round(daysSince, 1)} dias). Você ainda pode cancelar a renovação.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Busca a assinatura no Stripe
|
|
||||||
var subscriptionService = new SubscriptionService();
|
|
||||||
var subscription = await subscriptionService.GetAsync(user.StripeSubscriptionId);
|
|
||||||
|
|
||||||
if (subscription == null)
|
|
||||||
{
|
|
||||||
return (false, "Assinatura não encontrada no Stripe");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancela a assinatura primeiro
|
|
||||||
await subscriptionService.CancelAsync(subscription.Id, new SubscriptionCancelOptions());
|
|
||||||
|
|
||||||
// Busca o último pagamento (invoice) desta assinatura para reembolsar
|
|
||||||
var invoiceService = new InvoiceService();
|
|
||||||
var invoiceListOptions = new InvoiceListOptions
|
|
||||||
{
|
|
||||||
Subscription = subscription.Id,
|
|
||||||
Limit = 1,
|
|
||||||
Status = "paid"
|
|
||||||
};
|
|
||||||
var invoices = await invoiceService.ListAsync(invoiceListOptions);
|
|
||||||
var latestInvoice = invoices.Data.FirstOrDefault();
|
|
||||||
|
|
||||||
if (latestInvoice == null || latestInvoice.AmountPaid <= 0)
|
|
||||||
{
|
|
||||||
// Mesmo sem invoice, cancela e desativa
|
|
||||||
await _userService.DeactivatePremiumStatus(subscription.Id);
|
|
||||||
return (true, "Assinatura cancelada com sucesso. Nenhum pagamento para reembolsar foi encontrado.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Processa o reembolso - Stripe reembolsa automaticamente o último pagamento
|
|
||||||
var refundService = new RefundService();
|
|
||||||
var refundOptions = new RefundCreateOptions
|
|
||||||
{
|
|
||||||
Amount = latestInvoice.AmountPaid, // Reembolso total
|
|
||||||
Reason = RefundReasons.RequestedByCustomer,
|
|
||||||
Metadata = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
{ "user_id", userId },
|
|
||||||
{ "subscription_id", subscription.Id },
|
|
||||||
{ "invoice_id", latestInvoice.Id },
|
|
||||||
{ "refund_reason", "CDC 7 dias - Direito de arrependimento" }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stripe automaticamente encontra o charge/payment_intent correto através do subscription_id no metadata
|
|
||||||
// Alternativamente, podemos buscar o último charge da subscription
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Tenta reembolsar usando a subscription (Stripe encontra o charge automaticamente)
|
|
||||||
var chargeService = new ChargeService();
|
|
||||||
var chargeOptions = new ChargeListOptions
|
|
||||||
{
|
|
||||||
Limit = 1,
|
|
||||||
Customer = subscription.CustomerId
|
|
||||||
};
|
|
||||||
var charges = await chargeService.ListAsync(chargeOptions);
|
|
||||||
var lastCharge = charges.Data.FirstOrDefault();
|
|
||||||
|
|
||||||
if (lastCharge != null)
|
|
||||||
{
|
|
||||||
refundOptions.Charge = lastCharge.Id;
|
|
||||||
var refund = await refundService.CreateAsync(refundOptions);
|
|
||||||
|
|
||||||
if (refund.Status == "succeeded" || refund.Status == "pending")
|
|
||||||
{
|
|
||||||
// Desativa o premium imediatamente no caso de reembolso
|
|
||||||
await _userService.DeactivatePremiumStatus(subscription.Id);
|
|
||||||
|
|
||||||
_logger.LogInformation($"Successfully refunded and canceled subscription {subscription.Id} for user {userId}. Refund ID: {refund.Id}");
|
|
||||||
|
|
||||||
return (true, $"Reembolso processado com sucesso! Você receberá R$ {(latestInvoice.AmountPaid / 100.0):F2} de volta em 5-10 dias úteis.");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning($"Refund failed with status {refund.Status} for subscription {subscription.Id}");
|
|
||||||
await _userService.DeactivatePremiumStatus(subscription.Id);
|
|
||||||
return (false, "Falha ao processar reembolso, mas assinatura foi cancelada. Entre em contato com o suporte.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await _userService.DeactivatePremiumStatus(subscription.Id);
|
|
||||||
return (false, "Assinatura cancelada, mas nenhuma cobrança encontrada para reembolsar. Entre em contato com o suporte.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (StripeException refundEx)
|
|
||||||
{
|
|
||||||
_logger.LogError(refundEx, $"Error creating refund for subscription {subscription.Id}");
|
|
||||||
await _userService.DeactivatePremiumStatus(subscription.Id);
|
|
||||||
return (false, $"Assinatura cancelada, mas erro ao processar reembolso: {refundEx.Message}. Entre em contato com o suporte.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (StripeException ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Stripe error during refund for user {userId}: {ex.Message}");
|
|
||||||
return (false, $"Erro ao processar reembolso: {ex.StripeError?.Message ?? ex.Message}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Error processing refund for user {userId}");
|
|
||||||
return (false, "Erro inesperado ao processar reembolso. Tente novamente mais tarde.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -214,12 +214,13 @@ namespace QRRapidoApp.Services
|
|||||||
return dailyCount < limit;
|
return dailyCount < limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SaveQRToHistoryAsync(string? userId, QRGenerationResult qrResult)
|
public async Task SaveQRToHistoryAsync(string? userId, QRGenerationResult qrResult, int costInCredits = 0)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var qrHistory = new QRCodeHistory
|
var qrHistory = new QRCodeHistory
|
||||||
{
|
{
|
||||||
|
Id = string.IsNullOrEmpty(qrResult.QRId) ? Guid.NewGuid().ToString() : qrResult.QRId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Type = qrResult.RequestSettings?.Type ?? "unknown",
|
Type = qrResult.RequestSettings?.Type ?? "unknown",
|
||||||
Content = qrResult.RequestSettings?.Content ?? "",
|
Content = qrResult.RequestSettings?.Content ?? "",
|
||||||
@ -232,19 +233,12 @@ namespace QRRapidoApp.Services
|
|||||||
FromCache = qrResult.FromCache,
|
FromCache = qrResult.FromCache,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
LastAccessedAt = DateTime.UtcNow,
|
LastAccessedAt = DateTime.UtcNow,
|
||||||
TrackingId = qrResult.TrackingId, // Save tracking ID for analytics
|
TrackingId = qrResult.TrackingId,
|
||||||
IsDynamic = !string.IsNullOrEmpty(qrResult.TrackingId) // Mark as dynamic if tracking is enabled
|
IsDynamic = !string.IsNullOrEmpty(qrResult.TrackingId),
|
||||||
|
CostInCredits = costInCredits
|
||||||
};
|
};
|
||||||
|
|
||||||
await _context.QRCodeHistory.InsertOneAsync(qrHistory);
|
await _context.QRCodeHistory.InsertOneAsync(qrHistory);
|
||||||
|
|
||||||
// Update user's QR history IDs if logged in
|
|
||||||
if (!string.IsNullOrEmpty(userId))
|
|
||||||
{
|
|
||||||
var update = Builders<User>.Update
|
|
||||||
.Push(u => u.QRHistoryIds, qrHistory.Id);
|
|
||||||
await _context.Users.UpdateOneAsync(u => u.Id == userId, update);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -497,5 +491,124 @@ namespace QRRapidoApp.Services
|
|||||||
_logger.LogError(ex, "Error incrementing scan count for {TrackingId}: {ErrorMessage}", trackingId, ex.Message);
|
_logger.LogError(ex, "Error incrementing scan count for {TrackingId}: {ErrorMessage}", trackingId, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeductCreditAsync(string userId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var update = Builders<User>.Update.Inc(u => u.Credits, -1);
|
||||||
|
var result = await _context.Users.UpdateOneAsync(u => u.Id == userId && u.Credits > 0, update);
|
||||||
|
return result.ModifiedCount > 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error deducting credit for user {userId}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> AddCreditsAsync(string userId, int amount)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var update = Builders<User>.Update.Inc(u => u.Credits, amount);
|
||||||
|
var result = await _context.Users.UpdateOneAsync(u => u.Id == userId, update);
|
||||||
|
return result.ModifiedCount > 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error adding credits for user {userId}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IncrementFreeUsageAsync(string userId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Limite de 5 QRs gratuitos vitalícios/iniciais
|
||||||
|
var user = await GetUserAsync(userId);
|
||||||
|
if (user == null || user.FreeQRsUsed >= 5) return false;
|
||||||
|
|
||||||
|
var update = Builders<User>.Update.Inc(u => u.FreeQRsUsed, 1);
|
||||||
|
await _context.Users.UpdateOneAsync(u => u.Id == userId, update);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error incrementing free usage for user {userId}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QRCodeHistory?> FindDuplicateQRAsync(string userId, string contentHash)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verifica se o hash existe na lista do usuário (rápido)
|
||||||
|
var user = await GetUserAsync(userId);
|
||||||
|
if (user == null || user.HistoryHashes == null || !user.HistoryHashes.Contains(contentHash))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se existe, busca o objeto completo no histórico
|
||||||
|
return await _context.QRCodeHistory
|
||||||
|
.Find(q => q.UserId == userId && q.ContentHash == contentHash && q.IsActive)
|
||||||
|
.SortByDescending(q => q.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error finding duplicate QR for user {userId}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CheckAnonymousLimitAsync(string ipAddress, string deviceId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Definição do limite: 1 por dia
|
||||||
|
var limit = 1;
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
var tomorrow = today.AddDays(1);
|
||||||
|
|
||||||
|
// Busca QRs gerados hoje por este IP OU DeviceId
|
||||||
|
var count = await _context.QRCodeHistory
|
||||||
|
.CountDocumentsAsync(q =>
|
||||||
|
q.UserId == null && // Apenas anônimos
|
||||||
|
q.CreatedAt >= today &&
|
||||||
|
q.CreatedAt < tomorrow &&
|
||||||
|
(q.IpAddress == ipAddress || q.DeviceId == deviceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return count < limit;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error checking anonymous limit for IP {ipAddress}");
|
||||||
|
// Em caso de erro no banco (timeout, etc), bloqueia por segurança ou libera?
|
||||||
|
// Vamos liberar para não prejudicar UX em falha técnica momentânea,
|
||||||
|
// mas logamos o erro.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RegisterAnonymousUsageAsync(string ipAddress, string deviceId, string qrId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var update = Builders<QRCodeHistory>.Update
|
||||||
|
.Set(q => q.IpAddress, ipAddress)
|
||||||
|
.Set(q => q.DeviceId, deviceId);
|
||||||
|
|
||||||
|
await _context.QRCodeHistory.UpdateOneAsync(q => q.Id == qrId, update);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error registering anonymous usage");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -30,6 +30,20 @@
|
|||||||
{
|
{
|
||||||
<div class="col-12 col-md-6 col-lg-4 mb-4">
|
<div class="col-12 col-md-6 col-lg-4 mb-4">
|
||||||
<div class="card h-100 shadow-sm position-relative">
|
<div class="card h-100 shadow-sm position-relative">
|
||||||
|
<!-- Cost Badge -->
|
||||||
|
@if (qr.CostInCredits > 0)
|
||||||
|
{
|
||||||
|
<span class="badge bg-primary position-absolute shadow-sm" style="top: 8px; left: 8px; z-index: 5;">
|
||||||
|
-@qr.CostInCredits Crédito
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-success position-absolute shadow-sm" style="top: 8px; left: 8px; z-index: 5;">
|
||||||
|
Grátis
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Delete button in top-right corner -->
|
<!-- Delete button in top-right corner -->
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-sm btn-outline-danger position-absolute"
|
class="btn btn-sm btn-outline-danger position-absolute"
|
||||||
|
|||||||
@ -2,470 +2,160 @@
|
|||||||
@inject Microsoft.Extensions.Localization.IStringLocalizer<QRRapidoApp.Resources.SharedResource> Localizer
|
@inject Microsoft.Extensions.Localization.IStringLocalizer<QRRapidoApp.Resources.SharedResource> Localizer
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = Localizer["UserProfileTitle"];
|
ViewData["Title"] = Localizer["UserProfileTitle"];
|
||||||
var isPremium = ViewBag.IsPremium as bool? ?? false;
|
|
||||||
var monthlyQRCount = ViewBag.MonthlyQRCount as int? ?? 0;
|
var monthlyQRCount = ViewBag.MonthlyQRCount as int? ?? 0;
|
||||||
var qrHistory = ViewBag.QRHistory as List<QRRapidoApp.Models.QRCodeHistory> ?? new List<QRRapidoApp.Models.QRCodeHistory>();
|
var qrHistory = ViewBag.QRHistory as List<QRRapidoApp.Models.QRCodeHistory> ?? new List<QRRapidoApp.Models.QRCodeHistory>();
|
||||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
|
||||||
|
// Cálculos de Cota Gratuita
|
||||||
|
var freeUsed = Model.FreeQRsUsed;
|
||||||
|
var freeLimit = 5;
|
||||||
|
var freePercent = (double)freeUsed / freeLimit * 100;
|
||||||
|
var freeRemaining = Math.Max(0, freeLimit - freeUsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-8 mx-auto">
|
<div class="col-lg-4">
|
||||||
<!-- Header do Perfil -->
|
<!-- Header do Perfil -->
|
||||||
<div class="card mb-4 border-0 shadow-sm">
|
<div class="card mb-4 border-0 shadow-sm text-center">
|
||||||
<div class="card-header bg-primary text-white d-flex align-items-center">
|
<div class="card-body py-5">
|
||||||
<i class="fas fa-user-circle fa-2x me-3"></i>
|
<div class="mb-3">
|
||||||
<div>
|
<i class="fas fa-user-circle fa-5x text-secondary"></i>
|
||||||
<h4 class="mb-0">@Model.Name</h4>
|
|
||||||
<small class="opacity-75">@Model.Email</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row g-3">
|
|
||||||
<!-- Status do Plano -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="d-flex align-items-center mb-2">
|
|
||||||
<i class="fas fa-crown me-2 @(isPremium ? "text-warning" : "text-muted")"></i>
|
|
||||||
<strong>Status do Plano:</strong>
|
|
||||||
</div>
|
|
||||||
@if (isPremium)
|
|
||||||
{
|
|
||||||
<span class="badge bg-warning text-dark fs-6">
|
|
||||||
<i class="fas fa-star me-1"></i>Premium
|
|
||||||
</span>
|
|
||||||
@if (Model.PremiumExpiresAt.HasValue)
|
|
||||||
{
|
|
||||||
<p class="text-muted small mt-1 mb-0">
|
|
||||||
Expira em: @Model.PremiumExpiresAt.Value.ToString("dd/MM/yyyy")
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
@if (!string.IsNullOrEmpty(Model.StripeSubscriptionId))
|
|
||||||
{
|
|
||||||
var canRefund = Model.SubscriptionStartedAt.HasValue &&
|
|
||||||
(DateTime.UtcNow - Model.SubscriptionStartedAt.Value).TotalDays <= 7;
|
|
||||||
var daysSinceSubscription = Model.SubscriptionStartedAt.HasValue
|
|
||||||
? Math.Round((DateTime.UtcNow - Model.SubscriptionStartedAt.Value).TotalDays, 1)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
<p class="mt-2 mb-0">
|
|
||||||
@if (canRefund)
|
|
||||||
{
|
|
||||||
<button type="button" class="btn btn-sm btn-danger" data-bs-toggle="modal" data-bs-target="#refundSubscriptionModal">
|
|
||||||
<i class="fas fa-undo me-1"></i>Solicitar Reembolso (CDC)
|
|
||||||
</button>
|
|
||||||
<small class="text-muted d-block mt-1">
|
|
||||||
<i class="fas fa-info-circle"></i> Você tem direito a reembolso total (7 dias)
|
|
||||||
</small>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#cancelSubscriptionModal">
|
|
||||||
<i class="fas fa-times-circle me-1"></i>Cancelar Renovação
|
|
||||||
</button>
|
|
||||||
@if (daysSinceSubscription > 0)
|
|
||||||
{
|
|
||||||
<small class="text-muted d-block mt-1">
|
|
||||||
<i class="fas fa-info-circle"></i> Assinatura há @daysSinceSubscription dias (período de reembolso expirado)
|
|
||||||
</small>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="badge bg-secondary fs-6">
|
|
||||||
<i class="fas fa-user me-1"></i>Gratuito
|
|
||||||
</span>
|
|
||||||
<p class="text-muted small mt-1 mb-0">
|
|
||||||
<a href="/Pagamento/SelecaoPlano" class="text-decoration-none">
|
|
||||||
<i class="fas fa-arrow-up me-1"></i>Fazer upgrade
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Data de Cadastro -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="d-flex align-items-center mb-2">
|
|
||||||
<i class="fas fa-calendar-alt me-2 text-info"></i>
|
|
||||||
<strong>Membro desde:</strong>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted mb-0">@Model.CreatedAt.ToString("dd/MM/yyyy")</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Provedor de Login -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="d-flex align-items-center mb-2">
|
|
||||||
<i class="fab fa-@(Model.Provider.ToLower()) me-2 text-primary"></i>
|
|
||||||
<strong>Conectado via:</strong>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted mb-0">@Model.Provider</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Último Login -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="d-flex align-items-center mb-2">
|
|
||||||
<i class="fas fa-sign-in-alt me-2 text-success"></i>
|
|
||||||
<strong>Último acesso:</strong>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted mb-0">@Model.LastLoginAt.ToString("dd/MM/yyyy HH:mm")</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h4 class="mb-1">@Model.Name</h4>
|
||||||
|
<p class="text-muted mb-3">@Model.Email</p>
|
||||||
|
<p class="small text-muted">Membro desde @Model.CreatedAt.ToString("MMM yyyy")</p>
|
||||||
|
|
||||||
|
<form method="post" action="/Account/Logout" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="fas fa-sign-out-alt me-1"></i>Sair
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Estatísticas de Uso -->
|
<!-- Estatísticas Rápidas -->
|
||||||
<div class="card mb-4 border-0 shadow-sm">
|
<div class="card mb-4 border-0 shadow-sm">
|
||||||
<div class="card-header bg-info text-white">
|
<div class="card-header bg-white fw-bold">
|
||||||
<h5 class="mb-0">
|
<i class="fas fa-chart-pie me-2 text-primary"></i> Resumo
|
||||||
<i class="fas fa-chart-bar me-2"></i>Estatísticas de Uso
|
|
||||||
</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="list-group list-group-flush">
|
||||||
<div class="row text-center g-3">
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<div class="col-md-4">
|
<span>Total Gerado</span>
|
||||||
<div class="p-3 border rounded bg-light profile-stat">
|
<span class="badge bg-primary rounded-pill">@Model.TotalQRGenerated</span>
|
||||||
<h3 class="text-primary mb-1">@Model.TotalQRGenerated</h3>
|
</div>
|
||||||
<small class="text-muted">QR Codes Criados</small>
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span>Este Mês</span>
|
||||||
|
<span class="badge bg-info rounded-pill">@monthlyQRCount</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- CARTEIRA E SALDO -->
|
||||||
|
<div class="card mb-4 border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-success text-white d-flex align-items-center justify-content-between">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-wallet me-2"></i>Minha Carteira</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
|
||||||
|
<small class="text-muted text-uppercase fw-bold">Saldo Disponível</small>
|
||||||
|
<div class="display-4 fw-bold text-success">
|
||||||
|
@Model.Credits <span class="fs-4 text-muted">Créditos</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mb-0 text-muted small">
|
||||||
|
<i class="fas fa-check-circle text-success"></i> Válidos por 5 anos
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-6 text-center text-md-end">
|
||||||
<div class="p-3 border rounded bg-light profile-stat">
|
<a href="/Pagamento/SelecaoPlano" class="btn btn-warning btn-lg shadow-sm fw-bold">
|
||||||
<h3 class="text-success mb-1">@monthlyQRCount</h3>
|
<i class="fas fa-plus-circle me-2"></i> Recarregar Agora
|
||||||
<small class="text-muted">Este Mês</small>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
</div>
|
||||||
<div class="p-3 border rounded bg-light profile-stat">
|
|
||||||
<h3 class="text-info mb-1">@Model.DailyQRCount</h3>
|
<hr class="my-4">
|
||||||
<small class="text-muted">Hoje</small>
|
|
||||||
|
<!-- Cota Gratuita -->
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-end mb-1">
|
||||||
|
<span class="fw-bold text-secondary">
|
||||||
|
<i class="fas fa-gift me-1 text-warning"></i> Cota Gratuita Vitalícia
|
||||||
|
</span>
|
||||||
|
<span class="small text-muted">
|
||||||
|
@freeUsed de @freeLimit usados (@freeRemaining restantes)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="progress" style="height: 10px;">
|
||||||
|
<div class="progress-bar @(freeRemaining == 0 ? "bg-secondary" : "bg-warning")"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: @freePercent%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (freeRemaining == 0)
|
||||||
|
{
|
||||||
|
<small class="text-danger mt-1 d-block">
|
||||||
|
<i class="fas fa-exclamation-circle"></i> Cota esgotada. Use seus créditos para gerar mais.
|
||||||
|
</small>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Histórico Recente -->
|
<!-- Histórico Recente -->
|
||||||
@if (qrHistory.Any())
|
<div class="card border-0 shadow-sm">
|
||||||
{
|
<div class="card-header bg-white d-flex justify-content-between align-items-center py-3">
|
||||||
<div class="card mb-4 border-0 shadow-sm">
|
<h5 class="mb-0 text-primary">
|
||||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
|
<i class="fas fa-history me-2"></i>Últimos QR Codes
|
||||||
<h5 class="mb-0">
|
</h5>
|
||||||
<i class="fas fa-history me-2"></i>Histórico Recente
|
<a href="/Account/History" class="btn btn-outline-primary btn-sm">
|
||||||
</h5>
|
Ver Todos
|
||||||
<a href="/Account/History" class="btn btn-light btn-sm">
|
</a>
|
||||||
<i class="fas fa-list me-1"></i>Ver Todos
|
</div>
|
||||||
</a>
|
<div class="card-body p-0">
|
||||||
</div>
|
@if (qrHistory.Any())
|
||||||
<div class="card-body">
|
{
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
@foreach (var qr in qrHistory.Take(5))
|
@foreach (var qr in qrHistory.Take(5))
|
||||||
{
|
{
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center px-0">
|
<div class="list-group-item px-4 py-3">
|
||||||
<div>
|
<div class="d-flex align-items-center">
|
||||||
<div class="d-flex align-items-center">
|
<div class="flex-shrink-0 bg-light rounded p-2 me-3">
|
||||||
<i class="fas fa-qrcode text-primary me-2"></i>
|
<i class="fas fa-qrcode fa-2x text-secondary"></i>
|
||||||
<div>
|
</div>
|
||||||
<h6 class="mb-1">@qr.Type.ToUpper()</h6>
|
<div class="flex-grow-1">
|
||||||
<p class="mb-0 text-muted small">
|
<h6 class="mb-1 fw-bold text-uppercase text-primary">@qr.Type</h6>
|
||||||
@(qr.Content.Length > 50 ? qr.Content.Substring(0, 50) + "..." : qr.Content)
|
<p class="mb-0 text-muted small text-truncate" style="max-width: 300px;">
|
||||||
</p>
|
@qr.Content
|
||||||
</div>
|
</p>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="far fa-clock me-1"></i> @qr.CreatedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/api/QR/Download/@qr.Id" class="btn btn-sm btn-outline-secondary" title="Baixar PNG">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">@qr.CreatedAt.ToString("dd/MM HH:mm")</small>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Ações do Perfil -->
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-dark text-white">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="fas fa-cogs me-2"></i>Ações
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row g-3">
|
|
||||||
@if (!isPremium)
|
|
||||||
{
|
|
||||||
<div class="col-md-6">
|
|
||||||
<a href="/Pagamento/SelecaoPlano" class="btn btn-warning w-100">
|
|
||||||
<i class="fas fa-crown me-2"></i>Upgrade para Premium
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<a href="/Account/History" class="btn btn-info w-100">
|
|
||||||
<i class="fas fa-history me-2"></i>Ver Histórico Completo
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<a href="/" class="btn btn-primary w-100">
|
|
||||||
<i class="fas fa-qrcode me-2"></i>Criar Novo QR Code
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<form method="post" action="/Account/Logout" class="d-inline w-100">
|
|
||||||
<button type="submit" class="btn btn-outline-danger w-100">
|
|
||||||
<i class="fas fa-sign-out-alt me-2"></i>Sair
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal de Solicitação de Reembolso (CDC - 7 dias) -->
|
|
||||||
<div class="modal fade" id="refundSubscriptionModal" tabindex="-1" aria-labelledby="refundSubscriptionModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header bg-danger text-white">
|
|
||||||
<h5 class="modal-title" id="refundSubscriptionModalLabel">
|
|
||||||
<i class="fas fa-undo me-2"></i>Solicitar Reembolso Total (CDC)
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Fechar"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="fas fa-shield-alt me-2"></i>
|
|
||||||
<strong>Direito de Arrependimento (CDC):</strong> Você está dentro do período de 7 dias e tem direito a reembolso total conforme o Código de Defesa do Consumidor.
|
|
||||||
</div>
|
|
||||||
<h6><strong>O que acontecerá:</strong></h6>
|
|
||||||
<ul>
|
|
||||||
<li><i class="fas fa-check text-success me-2"></i>Reembolso total do valor pago</li>
|
|
||||||
<li><i class="fas fa-check text-success me-2"></i>Assinatura cancelada imediatamente</li>
|
|
||||||
<li><i class="fas fa-check text-success me-2"></i>Acesso Premium removido</li>
|
|
||||||
<li><i class="fas fa-check text-success me-2"></i>Dinheiro devolvido em 5-10 dias úteis</li>
|
|
||||||
</ul>
|
|
||||||
<p class="mb-0"><strong>Deseja continuar com o reembolso?</strong></p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
|
||||||
<i class="fas fa-arrow-left me-1"></i>Cancelar
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger" id="confirmRefundBtn">
|
|
||||||
<i class="fas fa-undo me-1"></i>Sim, Solicitar Reembolso
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal de Confirmação de Cancelamento -->
|
|
||||||
<div class="modal fade" id="cancelSubscriptionModal" tabindex="-1" aria-labelledby="cancelSubscriptionModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header bg-danger text-white">
|
|
||||||
<h5 class="modal-title" id="cancelSubscriptionModalLabel">
|
|
||||||
<i class="fas fa-exclamation-triangle me-2"></i>Cancelar Assinatura Premium
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Fechar"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<i class="fas fa-info-circle me-2"></i>
|
|
||||||
<strong>Atenção:</strong> Você manterá acesso aos recursos Premium até o final do período já pago.
|
|
||||||
</div>
|
|
||||||
<p><strong>Ao cancelar, você perderá:</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li><i class="fas fa-times text-danger me-2"></i>QR Codes ilimitados</li>
|
|
||||||
<li><i class="fas fa-times text-danger me-2"></i>Experiência sem anúncios</li>
|
|
||||||
<li><i class="fas fa-times text-danger me-2"></i>QR Codes dinâmicos (editáveis)</li>
|
|
||||||
<li><i class="fas fa-times text-danger me-2"></i>Estatísticas avançadas</li>
|
|
||||||
<li><i class="fas fa-times text-danger me-2"></i>Suporte prioritário</li>
|
|
||||||
</ul>
|
|
||||||
<p class="mb-0"><strong>Tem certeza que deseja cancelar?</strong></p>
|
|
||||||
<p class="text-muted small">Você pode reativar sua assinatura a qualquer momento.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
|
||||||
<i class="fas fa-arrow-left me-1"></i>Manter Premium
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger" id="confirmCancelBtn">
|
|
||||||
<i class="fas fa-times-circle me-1"></i>Sim, Cancelar Assinatura
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.card {
|
|
||||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 0.85em;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-light {
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-group-item {
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
padding: 15px 0;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-group-item:hover {
|
|
||||||
background-color: rgba(0,123,255,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-group-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-stat {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-stat:hover {
|
|
||||||
background-color: #e3f2fd !important;
|
|
||||||
border-color: #2196f3 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
font-weight: 600;
|
|
||||||
border-bottom: 2px solid rgba(255,255,255,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
padding: 0 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-md-6 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body .row .col-md-4 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Handler para reembolso (CDC - 7 dias)
|
|
||||||
const confirmRefundBtn = document.getElementById('confirmRefundBtn');
|
|
||||||
const refundModal = document.getElementById('refundSubscriptionModal');
|
|
||||||
|
|
||||||
if (confirmRefundBtn) {
|
|
||||||
confirmRefundBtn.addEventListener('click', async function() {
|
|
||||||
// Desabilita o botão durante o processamento
|
|
||||||
confirmRefundBtn.disabled = true;
|
|
||||||
confirmRefundBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Processando...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/Premium/RequestRefund', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Fecha o modal
|
|
||||||
const modalInstance = bootstrap.Modal.getInstance(refundModal);
|
|
||||||
if (modalInstance) {
|
|
||||||
modalInstance.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mostra mensagem de sucesso
|
|
||||||
alert('✅ ' + result.message);
|
|
||||||
|
|
||||||
// Recarrega a página após 2 segundos
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
alert('❌ ' + result.error);
|
|
||||||
confirmRefundBtn.disabled = false;
|
|
||||||
confirmRefundBtn.innerHTML = '<i class="fas fa-undo me-1"></i>Sim, Solicitar Reembolso';
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
else
|
||||||
console.error('Erro ao solicitar reembolso:', error);
|
{
|
||||||
alert('❌ Erro de conexão. Tente novamente mais tarde.');
|
<div class="text-center py-5">
|
||||||
confirmRefundBtn.disabled = false;
|
<i class="fas fa-ghost fa-3x text-muted mb-3 opacity-25"></i>
|
||||||
confirmRefundBtn.innerHTML = '<i class="fas fa-undo me-1"></i>Sim, Solicitar Reembolso';
|
<p class="text-muted">Nenhum QR Code gerado ainda.</p>
|
||||||
}
|
<a href="/" class="btn btn-primary btn-sm">Criar o Primeiro</a>
|
||||||
});
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
// Handler para cancelamento (sem reembolso)
|
|
||||||
const confirmCancelBtn = document.getElementById('confirmCancelBtn');
|
|
||||||
const cancelModal = document.getElementById('cancelSubscriptionModal');
|
|
||||||
|
|
||||||
if (confirmCancelBtn) {
|
|
||||||
confirmCancelBtn.addEventListener('click', async function() {
|
|
||||||
// Desabilita o botão durante o processamento
|
|
||||||
confirmCancelBtn.disabled = true;
|
|
||||||
confirmCancelBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Cancelando...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/Premium/CancelSubscription', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Fecha o modal
|
|
||||||
const modalInstance = bootstrap.Modal.getInstance(cancelModal);
|
|
||||||
if (modalInstance) {
|
|
||||||
modalInstance.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mostra mensagem de sucesso
|
|
||||||
alert('✅ Assinatura cancelada com sucesso!\n\nVocê manterá acesso Premium até o final do período pago.\n\nA página será recarregada.');
|
|
||||||
|
|
||||||
// Recarrega a página após 2 segundos
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
alert('❌ Erro ao cancelar assinatura: ' + (result.error || 'Erro desconhecido'));
|
|
||||||
confirmCancelBtn.disabled = false;
|
|
||||||
confirmCancelBtn.innerHTML = '<i class="fas fa-times-circle me-1"></i>Sim, Cancelar Assinatura';
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
</div>
|
||||||
console.error('Erro ao cancelar assinatura:', error);
|
</div>
|
||||||
alert('❌ Erro de conexão. Tente novamente mais tarde.');
|
</div>
|
||||||
confirmCancelBtn.disabled = false;
|
</div>
|
||||||
confirmCancelBtn.innerHTML = '<i class="fas fa-times-circle me-1"></i>Sim, Cancelar Assinatura';
|
</div>
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|||||||
161
Views/Admin/Index.cshtml
Normal file
161
Views/Admin/Index.cshtml
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
@model List<QRRapidoApp.Models.Order>
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Admin - Pedidos Pendentes";
|
||||||
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3"><i class="fas fa-tasks me-2"></i>Pedidos PIX Pendentes</h1>
|
||||||
|
<button onclick="window.location.reload()" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="fas fa-sync-alt"></i> Atualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model == null || !Model.Any())
|
||||||
|
{
|
||||||
|
<div class="alert alert-success text-center py-5">
|
||||||
|
<i class="fas fa-check-circle fa-3x mb-3"></i>
|
||||||
|
<h4>Tudo limpo!</h4>
|
||||||
|
<p>Não há pedidos pendentes no momento.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4">Data</th>
|
||||||
|
<th>Usuário</th>
|
||||||
|
<th>Valor</th>
|
||||||
|
<th>Créditos</th>
|
||||||
|
<th>Identificador (PIX)</th>
|
||||||
|
<th class="text-end pe-4">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var order in Model)
|
||||||
|
{
|
||||||
|
<tr id="row-@order.Id">
|
||||||
|
<td class="ps-4 small text-muted">@order.CreatedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm")</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-bold">@order.UserEmail</div>
|
||||||
|
<div class="small text-muted text-truncate" style="max-width: 150px;" title="@order.UserId">ID: @order.UserId</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-primary fw-bold">R$ @order.Amount.ToString("F2")</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info text-dark">@order.CreditsAmount Créditos</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code class="bg-light px-2 py-1 rounded border">@order.PixCode</code>
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-4">
|
||||||
|
<button class="btn btn-success btn-sm me-2" onclick="approveOrder('@order.Id')">
|
||||||
|
<i class="fas fa-check"></i> Aprovar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" onclick="rejectOrder('@order.Id')">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
async function approveOrder(orderId) {
|
||||||
|
if (!confirm('Confirmar recebimento do PIX e liberar créditos?')) return;
|
||||||
|
|
||||||
|
const row = document.getElementById(`row-${orderId}`);
|
||||||
|
const btn = row.querySelector('.btn-success');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/Admin/Orders/${orderId}/Approve`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Success animation
|
||||||
|
row.classList.add('table-success');
|
||||||
|
setTimeout(() => {
|
||||||
|
row.style.transition = 'opacity 0.5s ease';
|
||||||
|
row.style.opacity = '0';
|
||||||
|
setTimeout(() => row.remove(), 500);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
showToast('Créditos liberados com sucesso!', 'success');
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert('Erro: ' + (data.error || 'Falha ao aprovar'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Erro de conexão');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectOrder(orderId) {
|
||||||
|
if (!confirm('Tem certeza que deseja REJEITAR este pedido?')) return;
|
||||||
|
|
||||||
|
const row = document.getElementById(`row-${orderId}`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/Admin/Orders/${orderId}/Reject`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
row.classList.add('table-danger');
|
||||||
|
setTimeout(() => {
|
||||||
|
row.style.opacity = '0';
|
||||||
|
setTimeout(() => row.remove(), 500);
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
alert('Erro ao rejeitar');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type) {
|
||||||
|
// Reusing toast logic if available or simple alert
|
||||||
|
// Creating temporary toast container if needed
|
||||||
|
let toastContainer = document.getElementById('toast-container');
|
||||||
|
if (!toastContainer) {
|
||||||
|
toastContainer = document.createElement('div');
|
||||||
|
toastContainer.id = 'toast-container';
|
||||||
|
toastContainer.className = 'toast-container position-fixed top-0 start-0 p-3';
|
||||||
|
toastContainer.style.zIndex = '1060';
|
||||||
|
toastContainer.style.marginTop = '80px';
|
||||||
|
document.body.appendChild(toastContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastEl = document.createElement('div');
|
||||||
|
toastEl.className = `toast align-items-center text-white bg-${type} border-0`;
|
||||||
|
toastEl.innerHTML = `
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">${message}</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
toastContainer.appendChild(toastEl);
|
||||||
|
const toast = new bootstrap.Toast(toastEl);
|
||||||
|
toast.show();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@ -68,19 +68,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
@if (User.Identity.IsAuthenticated)
|
|
||||||
{
|
|
||||||
var isPremium = await AdService.HasValidPremiumSubscription(userId);
|
|
||||||
@if (isPremium)
|
|
||||||
{
|
|
||||||
<div class="alert alert-success border-0">
|
|
||||||
<i class="fas fa-crown text-warning"></i>
|
|
||||||
<strong>@Localizer["PremiumUserActive"]</strong>
|
|
||||||
<span class="badge bg-success">@Localizer["NoAdsHistoryUnlimitedQR"]</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<form id="qr-speed-form" class="needs-validation" novalidate>
|
<form id="qr-speed-form" class="needs-validation" novalidate>
|
||||||
<!-- Generation timer -->
|
<!-- Generation timer -->
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
@ -1328,39 +1315,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Premium Card for non-premium users -->
|
|
||||||
@if (User.Identity.IsAuthenticated && await AdService.ShouldShowAds(userId))
|
|
||||||
{
|
|
||||||
<div class="card border-warning mb-4">
|
|
||||||
<div class="card-header bg-warning text-dark">
|
|
||||||
<h6 class="mb-0">
|
|
||||||
<i class="fas fa-rocket"></i> QR Rapido Premium
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-center mb-3">
|
|
||||||
<div class="badge bg-success mb-2">@Localizer["ThreeTimesFaster"]</div>
|
|
||||||
</div>
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li><i class="fas fa-check text-success"></i> @Localizer["NoAdsForever"]</li>
|
|
||||||
<li><i class="fas fa-check text-success"></i> @Localizer["UnlimitedQRCodes"]</li>
|
|
||||||
<li><i class="fas fa-check text-success"></i> @Localizer["AdvancedCustomization"]</li>
|
|
||||||
<li><i class="fas fa-shapes text-success"></i> @Localizer["ThreeQRStyles"]</li>
|
|
||||||
<li><i class="fas fa-check text-success"></i> @Localizer["LogoSupport"]</li>
|
|
||||||
<li><i class="fas fa-check text-success"></i> @Localizer["HistoryAndDownloads"]</li>
|
|
||||||
<li><i class="fas fa-chart-line text-success"></i> @Localizer["QRReadCounter"]</li>
|
|
||||||
<li><i class="fas fa-check text-success"></i> @Localizer["PrioritySupport"]</li>
|
|
||||||
</ul>
|
|
||||||
<div class="text-center">
|
|
||||||
<a href="/Pagamento/SelecaoPlano" class="btn btn-warning w-100">
|
|
||||||
<i class="fas fa-bolt"></i> @Localizer["AcceleratePrice"]
|
|
||||||
</a>
|
|
||||||
<small class="text-muted d-block mt-1">@Localizer["CancelAnytime"]</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Tutorials Card -->
|
<!-- Tutorials Card -->
|
||||||
@{
|
@{
|
||||||
var tutorialCulture = System.Globalization.CultureInfo.CurrentUICulture.Name;
|
var tutorialCulture = System.Globalization.CultureInfo.CurrentUICulture.Name;
|
||||||
|
|||||||
@ -1,261 +1,247 @@
|
|||||||
|
@model IEnumerable<QRRapidoApp.Controllers.PagamentoController.CreditPackageViewModel>
|
||||||
@using Microsoft.Extensions.Localization
|
@using Microsoft.Extensions.Localization
|
||||||
@model QRRapidoApp.Models.ViewModels.SelecaoPlanoViewModel
|
|
||||||
@inject IStringLocalizer<QRRapidoApp.Resources.SharedResource> Localizer
|
@inject IStringLocalizer<QRRapidoApp.Resources.SharedResource> Localizer
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Escolha seu Plano Premium";
|
ViewData["Title"] = "Comprar Créditos";
|
||||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
var monthlyPlan = Model.Plans.FirstOrDefault(p => p.Interval == "month");
|
|
||||||
var yearlyPlan = Model.Plans.FirstOrDefault(p => p.Interval == "year");
|
|
||||||
var monthlyPrice = monthlyPlan?.PricesByCountry.GetValueOrDefault(Model.CountryCode)?.Amount ?? 0;
|
|
||||||
var yearlyPrice = yearlyPlan?.PricesByCountry.GetValueOrDefault(Model.CountryCode)?.Amount ?? 0;
|
|
||||||
var yearlySavings = (monthlyPrice * 12) - yearlyPrice;
|
|
||||||
var currency = monthlyPlan?.PricesByCountry.GetValueOrDefault(Model.CountryCode)?.Currency ?? "BRL";
|
|
||||||
var currencySymbol = currency == "PYG" ? "₲" : "R$";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="container mt-5">
|
<div class="container py-5">
|
||||||
<div class="text-center mb-5">
|
<div class="text-center mb-5">
|
||||||
<h1 class="display-4">@Localizer["UnlockFullPowerQRRapido"]</h1>
|
<h1 class="display-4 fw-bold">Créditos Pré-Pagos</h1>
|
||||||
<p class="lead text-muted">@Localizer["UnlimitedAccessNoAdsExclusive"]</p>
|
<p class="lead text-muted">Sem assinaturas. Sem renovação automática. Pague apenas pelo que usar.</p>
|
||||||
|
<p class="text-success"><i class="fas fa-check-circle"></i> Seus créditos valem por 5 anos!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row justify-content-center g-4">
|
<div class="row justify-content-center g-4">
|
||||||
<!-- Plano Mensal -->
|
@foreach (var package in Model)
|
||||||
@if (monthlyPlan != null)
|
|
||||||
{
|
{
|
||||||
<div class="col-lg-4 col-md-6">
|
<div class="col-lg-4 col-md-6">
|
||||||
<div class="card h-100 shadow-sm">
|
<div class="card h-100 shadow-sm border-0 hover-lift @(package.IsPopular ? "border-primary ring-2" : "")">
|
||||||
<div class="card-body d-flex flex-column">
|
@if (package.IsPopular)
|
||||||
<h3 class="card-title text-center">@Localizer["MonthlyPlan"]</h3>
|
{
|
||||||
<div class="text-center my-4">
|
<div class="position-absolute top-0 start-50 translate-middle">
|
||||||
<span class="display-4 fw-bold" id="monthly-price">@currencySymbol @(currency == "PYG" ? monthlyPrice.ToString("N0") : monthlyPrice.ToString("0.00"))</span>
|
<span class="badge rounded-pill bg-primary px-3 py-2 shadow-sm">MAIS POPULAR</span>
|
||||||
<span class="text-muted">@Localizer["PerMonth"]</span>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="card-body text-center p-4 d-flex flex-column">
|
||||||
|
<h3 class="fw-bold mb-3">@package.Name</h3>
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="display-4 fw-bold">@package.Credits</span>
|
||||||
|
<span class="text-muted d-block">QR Codes</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preços Duplos -->
|
||||||
|
<div class="bg-light rounded p-3 mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="text-muted"><i class="fas fa-bolt text-warning"></i> PIX</span>
|
||||||
|
<span class="h4 text-success mb-0 fw-bold">R$ @package.PricePix.ToString("F2")</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted"><i class="fas fa-credit-card"></i> Cartão</span>
|
||||||
|
<span class="h5 text-secondary mb-0">R$ @package.PriceCard.ToString("F2")</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-muted">@Localizer["IdealToStartExploring"]</p>
|
|
||||||
<button class="btn btn-outline-primary mt-auto checkout-btn" data-plan-id="@monthlyPlan.Id">@Localizer["SubscribeNow"]</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Plano Anual -->
|
<ul class="list-unstyled text-start mb-4 mx-auto" style="max-width: 250px;">
|
||||||
@if (yearlyPlan != null)
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>QR Codes Estáticos e Dinâmicos</li>
|
||||||
{
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Sem validade mensal</li>
|
||||||
<div class="col-lg-4 col-md-6">
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Suporte Prioritário</li>
|
||||||
<div class="card h-100 shadow border-primary">
|
</ul>
|
||||||
<div class="card-header bg-primary text-white text-center">
|
|
||||||
<h3 class="card-title mb-0">@Localizer["AnnualPlan"]</h3>
|
@if (User.Identity.IsAuthenticated)
|
||||||
<p class="mb-0">@Localizer["Recommended"]</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="text-center my-4">
|
|
||||||
<span class="display-4 fw-bold" id="yearly-price">@currencySymbol @(currency == "PYG" ? yearlyPrice.ToString("N0") : yearlyPrice.ToString("0.00"))</span>
|
|
||||||
<span class="text-muted">@Localizer["PerYear"]</span>
|
|
||||||
</div>
|
|
||||||
@if (yearlySavings > 0)
|
|
||||||
{
|
{
|
||||||
<div class="text-center mb-3">
|
<div class="d-grid gap-2 mt-auto">
|
||||||
<span class="badge bg-success" id="yearly-savings">@Localizer["SaveMoney"] @(currency == "PYG" ? yearlySavings.ToString("N0") : yearlySavings.ToString("0.00"))!</span>
|
<button class="btn btn-outline-success btn-pix-checkout"
|
||||||
|
data-package-id="@package.Id">
|
||||||
|
<i class="fas fa-qrcode me-2"></i> Pagar com PIX
|
||||||
|
</button>
|
||||||
|
<button class="btn @(package.IsPopular ? "btn-primary" : "btn-outline-primary") btn-card-checkout"
|
||||||
|
data-package-id="@package.Id">
|
||||||
|
<i class="fas fa-credit-card me-2"></i> Pagar com Cartão
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<p class="text-center text-muted">@Localizer["BestValueFrequentUsers"]</p>
|
else
|
||||||
<button class="btn btn-primary mt-auto checkout-btn" data-plan-id="@yearlyPlan.Id">@Localizer["SubscribeAnnualPlan"]</button>
|
{
|
||||||
|
<a href="/Account/Login?returnUrl=/Pagamento/SelecaoPlano" class="btn btn-lg btn-success w-100 mt-auto">
|
||||||
|
<i class="fas fa-user-plus me-2"></i> Cadastrar para Comprar
|
||||||
|
</a>
|
||||||
|
<div class="text-muted small mt-2">Faça login para adicionar créditos</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lista de Recursos -->
|
|
||||||
<div class="row justify-content-center mt-5">
|
<div class="row justify-content-center mt-5">
|
||||||
<div class="col-lg-8">
|
<div class="col-md-8 text-center">
|
||||||
<h3 class="text-center mb-4">@Localizer["AllPlansInclude"]</h3>
|
<div class="alert alert-info border-0 bg-light">
|
||||||
<ul class="list-group list-group-flush">
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
<li class="list-group-item border-0"><i class="fas fa-check-circle text-success me-2"></i>@Localizer["UnlimitedQRCodes"]</li>
|
Pagamentos via PIX têm liberação em até 1 hora (dias úteis).
|
||||||
<li class="list-group-item border-0"><i class="fas fa-check-circle text-success me-2"></i>@Localizer["NoAds"]</li>
|
Pagamentos via Cartão são liberados instantaneamente.
|
||||||
<li class="list-group-item border-0"><i class="fas fa-check-circle text-success me-2"></i>@Localizer["AdvancedCustomization"]</li>
|
</div>
|
||||||
<li class="list-group-item border-0"><i class="fas fa-shapes text-success me-2"></i>@Localizer["ThreeQRStyles"]</li>
|
</div>
|
||||||
<li class="list-group-item border-0"><i class="fas fa-check-circle text-success me-2"></i>@Localizer["LogoSupport"]</li>
|
</div>
|
||||||
<li class="list-group-item border-0"><i class="fas fa-check-circle text-success me-2"></i>@Localizer["HistoryAndDownloads"]</li>
|
</div>
|
||||||
<li class="list-group-item border-0"><i class="fas fa-chart-line text-success me-2"></i>@Localizer["QRReadCounter"]</li>
|
|
||||||
<li class="list-group-item border-0"><i class="fas fa-check-circle text-success me-2"></i>@Localizer["PrioritySupport"]</li>
|
<!-- Modal PIX -->
|
||||||
</ul>
|
<div class="modal fade" id="pixModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center pb-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-check-circle text-success display-1"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="fw-bold mb-2">Pedido Criado!</h3>
|
||||||
|
<p class="text-muted mb-4">Escaneie o QR Code abaixo com seu app de banco</p>
|
||||||
|
|
||||||
|
<div class="bg-white p-3 d-inline-block border rounded mb-4 shadow-sm position-relative">
|
||||||
|
<div id="pix-qr-loading" class="position-absolute top-50 start-50 translate-middle">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
<img id="pix-qr-image" src="" alt="QR Code PIX" width="200" height="200" style="opacity: 0; transition: opacity 0.3s;" onload="this.style.opacity=1; document.getElementById('pix-qr-loading').style.display='none';">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group mb-3 px-4">
|
||||||
|
<input type="text" class="form-control font-monospace" id="pix-copypaste" readonly value="">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="btn-copy-pix">
|
||||||
|
<i class="fas fa-copy"></i> Copiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning d-inline-block text-start small">
|
||||||
|
<strong>Importante:</strong><br>
|
||||||
|
Identificador do Pedido: <span id="order-id-display" class="fw-bold font-monospace">...</span><br>
|
||||||
|
Valor Exato: <span id="amount-display" class="fw-bold">...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="button" class="btn btn-primary px-5" data-bs-dismiss="modal">
|
||||||
|
Já paguei!
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script>
|
<script>
|
||||||
// Plans data for dynamic pricing
|
|
||||||
const plansData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.Plans.Select(p => new {
|
|
||||||
Id = p.Id,
|
|
||||||
Interval = p.Interval,
|
|
||||||
PricesByCountry = p.PricesByCountry.ToDictionary(kvp => kvp.Key, kvp => new {
|
|
||||||
Amount = kvp.Value.Amount,
|
|
||||||
Currency = kvp.Value.Currency,
|
|
||||||
StripePriceId = kvp.Value.StripePriceId
|
|
||||||
})
|
|
||||||
})));
|
|
||||||
|
|
||||||
// Current country code
|
|
||||||
let currentCountryCode = '@Model.CountryCode';
|
|
||||||
|
|
||||||
// Update prices based on country
|
|
||||||
function updatePrices(countryCode) {
|
|
||||||
const monthlyPlan = plansData.find(p => p.Interval === 'month');
|
|
||||||
const yearlyPlan = plansData.find(p => p.Interval === 'year');
|
|
||||||
|
|
||||||
if (!monthlyPlan || !yearlyPlan) return;
|
|
||||||
|
|
||||||
const monthlyPrice = monthlyPlan.PricesByCountry[countryCode];
|
|
||||||
const yearlyPrice = yearlyPlan.PricesByCountry[countryCode];
|
|
||||||
|
|
||||||
if (!monthlyPrice || !yearlyPrice) return;
|
|
||||||
|
|
||||||
const currencySymbol = monthlyPrice.Currency === 'PYG' ? '₲' : 'R$';
|
|
||||||
const isGuarani = monthlyPrice.Currency === 'PYG';
|
|
||||||
|
|
||||||
// Update monthly price
|
|
||||||
const monthlyPriceEl = document.getElementById('monthly-price');
|
|
||||||
if (monthlyPriceEl) {
|
|
||||||
monthlyPriceEl.textContent = currencySymbol + ' ' + (isGuarani ?
|
|
||||||
monthlyPrice.Amount.toLocaleString('es-PY') :
|
|
||||||
monthlyPrice.Amount.toFixed(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update yearly price
|
|
||||||
const yearlyPriceEl = document.getElementById('yearly-price');
|
|
||||||
if (yearlyPriceEl) {
|
|
||||||
yearlyPriceEl.textContent = currencySymbol + ' ' + (isGuarani ?
|
|
||||||
yearlyPrice.Amount.toLocaleString('es-PY') :
|
|
||||||
yearlyPrice.Amount.toFixed(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update yearly savings
|
|
||||||
const yearlySavings = (monthlyPrice.Amount * 12) - yearlyPrice.Amount;
|
|
||||||
const yearlySavingsEl = document.getElementById('yearly-savings');
|
|
||||||
if (yearlySavingsEl && yearlySavings > 0) {
|
|
||||||
yearlySavingsEl.innerHTML = '@Html.Raw(Localizer["SaveMoney"])' + ' ' + (isGuarani ?
|
|
||||||
yearlySavings.toLocaleString('es-PY') :
|
|
||||||
yearlySavings.toFixed(2)) + '!';
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCountryCode = countryCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect country based on current culture
|
|
||||||
function getCountryFromCulture() {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const segments = path.split('/').filter(s => s);
|
|
||||||
|
|
||||||
if (segments.length > 0) {
|
|
||||||
const culture = segments[0];
|
|
||||||
const countryMap = {
|
|
||||||
'pt-BR': 'BR',
|
|
||||||
'es-PY': 'PY',
|
|
||||||
'es': 'PY'
|
|
||||||
};
|
|
||||||
return countryMap[culture] || 'BR';
|
|
||||||
}
|
|
||||||
return 'BR';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for page visibility changes (when user switches language and page reloads)
|
|
||||||
document.addEventListener('visibilitychange', function() {
|
|
||||||
if (!document.hidden) {
|
|
||||||
const newCountryCode = getCountryFromCulture();
|
|
||||||
if (newCountryCode !== currentCountryCode) {
|
|
||||||
updatePrices(newCountryCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial setup
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const detectedCountry = getCountryFromCulture();
|
const pixModal = new bootstrap.Modal(document.getElementById('pixModal'));
|
||||||
if (detectedCountry !== currentCountryCode) {
|
|
||||||
updatePrices(detectedCountry);
|
// PIX Checkout
|
||||||
}
|
document.querySelectorAll('.btn-pix-checkout').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async function() {
|
||||||
|
const packageId = this.dataset.packageId;
|
||||||
|
const originalText = this.innerHTML;
|
||||||
|
|
||||||
|
this.disabled = true;
|
||||||
|
this.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> Gerando...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/Pagamento/CreatePixOrder', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ packageId: packageId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
document.getElementById('pix-copypaste').value = result.pixCode;
|
||||||
|
document.getElementById('order-id-display').textContent = result.orderId;
|
||||||
|
|
||||||
|
const currencyFormatter = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
document.getElementById('amount-display').textContent = currencyFormatter.format(result.amount);
|
||||||
|
|
||||||
|
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(result.pixCode)}`;
|
||||||
|
document.getElementById('pix-qr-image').src = qrUrl;
|
||||||
|
|
||||||
|
pixModal.show();
|
||||||
|
} else {
|
||||||
|
alert('Erro: ' + (result.error || 'Tente novamente'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Erro de conexão.');
|
||||||
|
} finally {
|
||||||
|
this.disabled = false;
|
||||||
|
this.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Card (Stripe) Checkout
|
||||||
|
document.querySelectorAll('.btn-card-checkout').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async function() {
|
||||||
|
const packageId = this.dataset.packageId;
|
||||||
|
const originalText = this.innerHTML;
|
||||||
|
|
||||||
|
this.disabled = true;
|
||||||
|
this.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> Redirecionando...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/Pagamento/CreateStripeSession', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ packageId: packageId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
window.location.href = result.url;
|
||||||
|
} else {
|
||||||
|
alert('Erro: ' + (result.error || 'Tente novamente'));
|
||||||
|
this.disabled = false;
|
||||||
|
this.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Erro de conexão.');
|
||||||
|
this.disabled = false;
|
||||||
|
this.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy Button Logic
|
||||||
|
document.getElementById('btn-copy-pix').addEventListener('click', function() {
|
||||||
|
const copyText = document.getElementById('pix-copypaste');
|
||||||
|
copyText.select();
|
||||||
|
copyText.setSelectionRange(0, 99999);
|
||||||
|
navigator.clipboard.writeText(copyText.value).then(() => {
|
||||||
|
const originalBtnText = this.innerHTML;
|
||||||
|
this.innerHTML = '<i class="fas fa-check"></i> Copiado!';
|
||||||
|
this.classList.remove('btn-outline-secondary');
|
||||||
|
this.classList.add('btn-success');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.innerHTML = originalBtnText;
|
||||||
|
this.classList.remove('btn-success');
|
||||||
|
this.classList.add('btn-outline-secondary');
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<style>
|
||||||
function obterIdiomaDaUrl() {
|
.hover-lift {
|
||||||
const path = window.location.pathname;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
const segments = path.split('/').filter(segment => segment !== '');
|
|
||||||
|
|
||||||
// O primeiro segmento após o domínio é o idioma
|
|
||||||
return segments[0] || 'pt-BR'; // retorna 'pt-BR' como padrão se não encontrar
|
|
||||||
}
|
}
|
||||||
|
.hover-lift:hover {
|
||||||
document.querySelectorAll('.checkout-btn').forEach(button => {
|
transform: translateY(-5px);
|
||||||
button.addEventListener('click', async function() {
|
box-shadow: 0 10px 20px rgba(0,0,0,0.1) !important;
|
||||||
const planId = this.dataset.planId;
|
|
||||||
const idioma = obterIdiomaDaUrl();
|
|
||||||
this.disabled = true;
|
|
||||||
this.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> @Localizer["Redirecting"]';
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/Pagamento/CreateCheckout', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: `planId=${planId}&lang=${idioma}`
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
window.location.href = result.url;
|
|
||||||
} else {
|
|
||||||
showToast('@Localizer["Error"] ' + result.error, 'danger');
|
|
||||||
this.disabled = false;
|
|
||||||
this.innerHTML = '@Localizer["SubscribeNow"]'; // Reset button text
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Checkout error:', error);
|
|
||||||
showToast('@Localizer["PaymentInitializationError"]', 'danger');
|
|
||||||
this.disabled = false;
|
|
||||||
this.innerHTML = 'Assinar Agora'; // Reset button text
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toast notification function
|
|
||||||
function showToast(message, type) {
|
|
||||||
// Create toast container if doesn't exist
|
|
||||||
let toastContainer = document.getElementById('toast-container');
|
|
||||||
if (!toastContainer) {
|
|
||||||
toastContainer = document.createElement('div');
|
|
||||||
toastContainer.id = 'toast-container';
|
|
||||||
toastContainer.className = 'toast-container position-fixed top-0 start-0 p-3';
|
|
||||||
toastContainer.style.zIndex = '1060';
|
|
||||||
toastContainer.style.marginTop = '80px';
|
|
||||||
document.body.appendChild(toastContainer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create toast element
|
|
||||||
const toastId = 'toast-' + Date.now();
|
|
||||||
const toastElement = document.createElement('div');
|
|
||||||
toastElement.innerHTML = `
|
|
||||||
<div class="toast align-items-center text-white bg-${type} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="toast-body">${message}</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
toastContainer.appendChild(toastElement);
|
|
||||||
const toast = new bootstrap.Toast(toastElement.querySelector('.toast'), { delay: 5000 });
|
|
||||||
toast.show();
|
|
||||||
|
|
||||||
// Remove toast element after it's hidden
|
|
||||||
toastElement.querySelector('.toast').addEventListener('hidden.bs.toast', function() {
|
|
||||||
toastElement.remove();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
.ring-2 {
|
||||||
|
border-width: 2px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -332,56 +332,44 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global speed timer -->
|
<!-- Global speed timer -->
|
||||||
<div class="d-none d-md-block">
|
<div class="d-none d-md-block">
|
||||||
<small class="text-success fw-bold">
|
<small class="text-success fw-bold">
|
||||||
<i class="fas fa-stopwatch"></i>
|
<i class="fas fa-stopwatch"></i>
|
||||||
<span id="avg-generation-time">1.2s</span> @Localizer["Average"]
|
<span id="avg-generation-time">1.2s</span> @Localizer["Average"]
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (User.Identity.IsAuthenticated)
|
||||||
|
{
|
||||||
|
<a href="/Pagamento/SelecaoPlano" class="btn btn-warning text-dark fw-bold shadow-sm animate-pulse">
|
||||||
|
<i class="fas fa-coins"></i> <span class="d-none d-md-inline">Comprar Créditos</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
@if (User.Identity.IsAuthenticated)
|
<div class="dropdown">
|
||||||
{
|
<button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
<div class="dropdown">
|
<i class="fas fa-user"></i> @User.Identity.Name
|
||||||
<button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
</button>
|
||||||
<i class="fas fa-user"></i> @User.Identity.Name
|
<ul class="dropdown-menu">
|
||||||
</button>
|
<li><a class="dropdown-item" href="/">
|
||||||
<ul class="dropdown-menu">
|
<i class="fas fa-qrcode me-2"></i> @Localizer["GenerateQRCode"]
|
||||||
<li><a class="dropdown-item" href="/">
|
</a></li>
|
||||||
<i class="fas fa-qrcode"></i> @Localizer["GenerateQRCode"]
|
<li><a class="dropdown-item" href="/Account/Profile">
|
||||||
</a></li>
|
<i class="fas fa-user-cog me-2"></i> @Localizer["Profile"]
|
||||||
<li><a class="dropdown-item" href="/Account/Profile">
|
</a></li>
|
||||||
<i class="fas fa-user-cog"></i> @Localizer["Profile"]
|
<li><a class="dropdown-item" href="/Account/History">
|
||||||
</a></li>
|
<i class="fas fa-history me-2"></i> @Localizer["History"]
|
||||||
<li><a class="dropdown-item" href="/Account/History">
|
</a></li>
|
||||||
<i class="fas fa-history"></i> @Localizer["History"]
|
<li><hr class="dropdown-divider"></li>
|
||||||
</a></li>
|
<li>
|
||||||
@{
|
<form method="post" action="/Account/Logout" class="d-inline">
|
||||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
<button type="submit" class="dropdown-item text-danger">
|
||||||
var shouldShowAds = await AdService.ShouldShowAds(userId);
|
<i class="fas fa-sign-out-alt me-2"></i>@Localizer["Logout"]
|
||||||
}
|
</button>
|
||||||
@if (!shouldShowAds)
|
</form>
|
||||||
{
|
</li>
|
||||||
<li><span class="dropdown-item text-success">
|
</ul>
|
||||||
<i class="fas fa-crown"></i> @Localizer["PremiumActive"]
|
</div>
|
||||||
</span></li>
|
}
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<li><a class="dropdown-item text-warning" href="/Pagamento/SelecaoPlano">
|
|
||||||
<i class="fas fa-rocket"></i> QR Rapido Premium
|
|
||||||
</a></li>
|
|
||||||
}
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/Account/Logout" class="d-inline">
|
|
||||||
<button type="submit" class="dropdown-item">
|
|
||||||
<i class="fas fa-sign-out-alt"></i> @Localizer["Logout"]
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<a href="/Account/Login" class="btn btn-primary btn-sm">
|
<a href="/Account/Login" class="btn btn-primary btn-sm">
|
||||||
@ -389,7 +377,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="d-none d-md-block">
|
<div class="d-none d-md-block">
|
||||||
<small class="text-success">
|
<small class="text-success">
|
||||||
<i class="fas fa-gift"></i> @Localizer["LoginThirtyDaysNoAds"]
|
<i class="fas fa-gift"></i> Cadastre-se = 5 Grátis!
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
"Environment": "Development",
|
"Environment": "Development",
|
||||||
"SecretsLoaded": false
|
"SecretsLoaded": false
|
||||||
},
|
},
|
||||||
|
"Admin": {
|
||||||
|
"AllowedEmails": [ "rrcgoncalves@gmail.com" ]
|
||||||
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"MongoDB": "mongodb://localhost:27017/QrRapido"
|
"MongoDB": "mongodb://localhost:27017/QrRapido"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -294,5 +294,16 @@ body {
|
|||||||
/* ESTADO OPACO (quando nada selecionado) */
|
/* ESTADO OPACO (quando nada selecionado) */
|
||||||
.opacity-controlled.disabled-state {
|
.opacity-controlled.disabled-state {
|
||||||
opacity: 0.2 !important; /* Bem opaco */
|
opacity: 0.2 !important; /* Bem opaco */
|
||||||
pointer-events: none; /* Desabilita interação */
|
pointer-events: none; /* Desabilita interao */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animação de pulso para botão de créditos */
|
||||||
|
@keyframes pulse-yellow {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4); }
|
||||||
|
70% { box-shadow: 0 0 0 10px rgba(255, 193, 7, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(255, 193, 7, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse-yellow 2s infinite;
|
||||||
|
}
|
||||||
BIN
wwwroot/images/tutoriais/pix-qr-hero.jpg
Normal file
BIN
wwwroot/images/tutoriais/pix-qr-hero.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
@ -62,7 +62,35 @@ class QRRapidoGenerator {
|
|||||||
this.updateLanguage();
|
this.updateLanguage();
|
||||||
this.updateStatsCounters();
|
this.updateStatsCounters();
|
||||||
this.initializeUserCounter();
|
this.initializeUserCounter();
|
||||||
|
// Initialize progressive flow
|
||||||
this.initializeProgressiveFlow();
|
this.initializeProgressiveFlow();
|
||||||
|
|
||||||
|
// Check for type in URL (SEO landing pages)
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const typeFromUrl = urlParams.get('type') || window.location.pathname.split('/').pop();
|
||||||
|
|
||||||
|
// Map SEO paths to internal types
|
||||||
|
const typeMap = {
|
||||||
|
'pix': 'pix',
|
||||||
|
'wifi': 'wifi',
|
||||||
|
'vcard': 'vcard',
|
||||||
|
'whatsapp': 'whatsapp', // Maps to url usually, or custom
|
||||||
|
'email': 'email',
|
||||||
|
'sms': 'sms',
|
||||||
|
'texto': 'text',
|
||||||
|
'text': 'text',
|
||||||
|
'url': 'url'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeFromUrl && typeMap[typeFromUrl]) {
|
||||||
|
const select = document.getElementById('qr-type');
|
||||||
|
if (select) {
|
||||||
|
select.value = typeMap[typeFromUrl];
|
||||||
|
// CRITICAL: Dispatch change event to trigger UI updates
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.initializeRateLimiting();
|
this.initializeRateLimiting();
|
||||||
|
|
||||||
// Validar segurança dos dados após carregamento
|
// Validar segurança dos dados após carregamento
|
||||||
@ -591,12 +619,18 @@ class QRRapidoGenerator {
|
|||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
this.showUpgradeModal(window.QRRapidoTranslations?.rateLimitReached || 'QR codes limit reached!');
|
this.showUpgradeModal(window.QRRapidoTranslations?.rateLimitReached || 'Limite diário atingido! Faça login.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Handle Payment Required (No Credits)
|
||||||
|
if (response.status === 402) {
|
||||||
|
this.showCreditsModal(errorData.error || 'Saldo insuficiente.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 400 && errorData.requiresPremium) {
|
if (response.status === 400 && errorData.requiresPremium) {
|
||||||
this.showUpgradeModal(errorData.error || window.QRRapidoTranslations?.premiumLogoRequired || 'Premium logo required.');
|
this.showUpgradeModal(errorData.error || window.QRRapidoTranslations?.premiumLogoRequired || 'Recurso Premium.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -953,6 +987,25 @@ class QRRapidoGenerator {
|
|||||||
const downloadSection = document.getElementById('download-section');
|
const downloadSection = document.getElementById('download-section');
|
||||||
if (downloadSection) {
|
if (downloadSection) {
|
||||||
downloadSection.style.display = 'block';
|
downloadSection.style.display = 'block';
|
||||||
|
|
||||||
|
// Remove existing upsell if any
|
||||||
|
const existingUpsell = document.getElementById('post-gen-upsell');
|
||||||
|
if (existingUpsell) existingUpsell.remove();
|
||||||
|
|
||||||
|
// Inject Buy Credits Upsell Button
|
||||||
|
const userStatus = document.getElementById('user-premium-status')?.value;
|
||||||
|
if (userStatus === 'logged-in' || userStatus === 'premium') {
|
||||||
|
const upsellDiv = document.createElement('div');
|
||||||
|
upsellDiv.id = 'post-gen-upsell';
|
||||||
|
upsellDiv.className = 'mt-3 pt-3 border-top';
|
||||||
|
upsellDiv.innerHTML = `
|
||||||
|
<a href="/Pagamento/SelecaoPlano" class="btn btn-warning w-100 fw-bold shadow-sm">
|
||||||
|
<i class="fas fa-coins"></i> Adicionar Mais Créditos
|
||||||
|
</a>
|
||||||
|
<small class="text-muted d-block mt-1">Garanta o próximo QR Code!</small>
|
||||||
|
`;
|
||||||
|
downloadSection.appendChild(upsellDiv);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current data
|
// Save current data
|
||||||
@ -1265,28 +1318,50 @@ class QRRapidoGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initializeUserCounter() {
|
async initializeUserCounter() {
|
||||||
// ✅ Check if user is logged in before making API request
|
|
||||||
const userStatus = document.getElementById('user-premium-status')?.value;
|
const userStatus = document.getElementById('user-premium-status')?.value;
|
||||||
if (userStatus === 'anonymous') {
|
if (!userStatus || userStatus === 'anonymous') return;
|
||||||
return; // Don't make request for anonymous users
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/QR/GetUserStats');
|
const response = await fetch('/api/QR/GetUserStats');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const stats = await response.json();
|
const stats = await response.json();
|
||||||
this.showUnlimitedCounter();
|
this.updateCreditDisplay(stats);
|
||||||
} else {
|
|
||||||
if (response.status !== 401) {
|
|
||||||
console.log('GetUserStats response not ok:', response.status);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If not authenticated or error, keep the default "Carregando..." text
|
console.debug('Error loading user stats:', error);
|
||||||
console.debug('User not authenticated or error loading stats:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateCreditDisplay(stats) {
|
||||||
|
const counterElements = document.querySelectorAll('.qr-counter');
|
||||||
|
if (counterElements.length === 0) return;
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
let className = 'badge qr-counter ';
|
||||||
|
|
||||||
|
if (stats.freeUsed < stats.freeLimit) {
|
||||||
|
const remaining = stats.freeLimit - stats.freeUsed;
|
||||||
|
text = `${remaining} Grátis Restantes`;
|
||||||
|
className += 'bg-success';
|
||||||
|
} else if (stats.credits > 0) {
|
||||||
|
text = `${stats.credits} Créditos`;
|
||||||
|
className += 'bg-primary';
|
||||||
|
} else {
|
||||||
|
text = '0 Créditos';
|
||||||
|
className += 'bg-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
counterElements.forEach(el => {
|
||||||
|
el.textContent = text;
|
||||||
|
// Preserve other classes if needed, but for now enforcing badge style
|
||||||
|
// Ensure we don't wipe out structural classes if they exist, but here we replace for badge style
|
||||||
|
el.className = className;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualizar também o input hidden para lógica interna se necessário
|
||||||
|
this.isPremium = stats.credits > 0 || stats.freeUsed < stats.freeLimit;
|
||||||
|
}
|
||||||
|
|
||||||
trackGenerationEvent(type, time) {
|
trackGenerationEvent(type, time) {
|
||||||
// Google Analytics
|
// Google Analytics
|
||||||
if (typeof gtag !== 'undefined') {
|
if (typeof gtag !== 'undefined') {
|
||||||
@ -1644,6 +1719,43 @@ class QRRapidoGenerator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showCreditsModal(message) {
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal fade';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-coins"></i> Seus créditos acabaram
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<p class="lead">${message}</p>
|
||||||
|
<i class="fas fa-wallet fa-3x text-muted mb-3"></i>
|
||||||
|
<p>Adquira um novo pacote de créditos para continuar gerando QR Codes de alta qualidade.</p>
|
||||||
|
<p class="text-success small"><i class="fas fa-check"></i> Créditos não expiram!</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer justify-content-center">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<a href="/Pagamento/SelecaoPlano" class="btn btn-primary px-4">
|
||||||
|
<i class="fas fa-shopping-cart"></i> Comprar Créditos
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
const bsModal = new bootstrap.Modal(modal);
|
||||||
|
bsModal.show();
|
||||||
|
|
||||||
|
modal.addEventListener('hidden.bs.modal', () => {
|
||||||
|
document.body.removeChild(modal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
updateRemainingCounter(remaining) {
|
updateRemainingCounter(remaining) {
|
||||||
const counterElement = document.querySelector('.qr-counter');
|
const counterElement = document.querySelector('.qr-counter');
|
||||||
if (counterElement && remaining !== null && remaining !== undefined) {
|
if (counterElement && remaining !== null && remaining !== undefined) {
|
||||||
@ -2005,44 +2117,44 @@ class QRRapidoGenerator {
|
|||||||
safeHide(pixInterface);
|
safeHide(pixInterface);
|
||||||
safeHide(dynamicQRSection);
|
safeHide(dynamicQRSection);
|
||||||
safeHide(urlPreview);
|
safeHide(urlPreview);
|
||||||
|
safeHide(contentGroup); // Hide default group initially
|
||||||
// 2. Default: Show content group (hidden later if specific)
|
|
||||||
if (contentGroup) contentGroup.style.display = 'block';
|
|
||||||
|
|
||||||
// 3. Specific logic
|
// 2. Enable specific interface based on type
|
||||||
if (type === 'vcard') {
|
if (type === 'vcard') {
|
||||||
if (contentGroup) contentGroup.style.display = 'none';
|
|
||||||
safeShow(vcardInterface);
|
safeShow(vcardInterface);
|
||||||
this.enableVCardFields();
|
this.enableVCardFields();
|
||||||
}
|
}
|
||||||
else if (type === 'wifi') {
|
else if (type === 'wifi') {
|
||||||
if (contentGroup) contentGroup.style.display = 'none';
|
|
||||||
safeShow(wifiInterface);
|
safeShow(wifiInterface);
|
||||||
}
|
}
|
||||||
else if (type === 'sms') {
|
else if (type === 'sms') {
|
||||||
if (contentGroup) contentGroup.style.display = 'none';
|
|
||||||
safeShow(smsInterface);
|
safeShow(smsInterface);
|
||||||
}
|
}
|
||||||
else if (type === 'email') {
|
else if (type === 'email') {
|
||||||
if (contentGroup) contentGroup.style.display = 'none';
|
|
||||||
safeShow(emailInterface);
|
safeShow(emailInterface);
|
||||||
}
|
}
|
||||||
else if (type === 'pix') {
|
else if (type === 'pix') {
|
||||||
console.log('Showing PIX interface');
|
|
||||||
if (contentGroup) contentGroup.style.display = 'none';
|
|
||||||
safeShow(pixInterface);
|
safeShow(pixInterface);
|
||||||
}
|
}
|
||||||
else if (type === 'url') {
|
else if (type === 'url') {
|
||||||
|
safeShow(contentGroup);
|
||||||
safeShow(dynamicQRSection);
|
safeShow(dynamicQRSection);
|
||||||
safeShow(urlPreview);
|
safeShow(urlPreview);
|
||||||
// URL needs content field
|
|
||||||
const qrContent = document.getElementById('qr-content');
|
const qrContent = document.getElementById('qr-content');
|
||||||
if(qrContent) qrContent.disabled = false;
|
if(qrContent) {
|
||||||
|
qrContent.disabled = false;
|
||||||
|
qrContent.placeholder = "https://www.exemplo.com.br";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Text or others - Keep content group
|
// Text (default fallback)
|
||||||
|
safeShow(contentGroup);
|
||||||
const qrContent = document.getElementById('qr-content');
|
const qrContent = document.getElementById('qr-content');
|
||||||
if(qrContent) qrContent.disabled = false;
|
if(qrContent) {
|
||||||
|
qrContent.disabled = false;
|
||||||
|
qrContent.placeholder = "Digite seu texto aqui...";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2652,40 +2764,117 @@ class QRRapidoGenerator {
|
|||||||
if (!counterElement) return;
|
if (!counterElement) return;
|
||||||
|
|
||||||
// Check user status
|
// Check user status
|
||||||
const userStatus = document.getElementById('user-premium-status');
|
const userStatus = document.getElementById('user-premium-status')?.value;
|
||||||
|
|
||||||
if (userStatus && userStatus.value === 'premium') {
|
if (userStatus === 'logged-in' || userStatus === 'premium') {
|
||||||
// Premium users have unlimited QRs
|
// Logged users use the Credit Display logic
|
||||||
const unlimitedText = this.getLocalizedString('UnlimitedToday');
|
this.initializeUserCounter();
|
||||||
counterElement.textContent = unlimitedText;
|
|
||||||
counterElement.className = 'badge bg-success qr-counter';
|
|
||||||
return;
|
|
||||||
} else if (userStatus && userStatus.value === 'logged-in') {
|
|
||||||
// Free logged users - we need to get their actual remaining count
|
|
||||||
this.updateLoggedUserCounter();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For anonymous users, show remaining count
|
// --- ANONYMOUS USERS ---
|
||||||
const today = new Date().toDateString();
|
const today = new Date().toDateString();
|
||||||
const cookieName = 'qr_daily_count';
|
const cookieName = 'qr_daily_count';
|
||||||
const rateLimitData = this.getCookie(cookieName);
|
const rateLimitData = this.getCookie(cookieName);
|
||||||
|
|
||||||
let remaining = 3;
|
let count = 0;
|
||||||
|
|
||||||
if (rateLimitData) {
|
if (rateLimitData) {
|
||||||
try {
|
try {
|
||||||
const currentData = JSON.parse(rateLimitData);
|
const currentData = JSON.parse(rateLimitData);
|
||||||
if (currentData.date === today) {
|
if (currentData.date === today) {
|
||||||
remaining = Math.max(0, 3 - currentData.count);
|
count = currentData.count;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
remaining = 3;
|
count = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingText = this.getLocalizedString('QRCodesRemainingToday');
|
// New limit is 1
|
||||||
counterElement.textContent = `${remaining} ${remainingText}`;
|
const remaining = Math.max(0, 1 - count);
|
||||||
|
|
||||||
|
const counterElements = document.querySelectorAll('.qr-counter');
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
counterElements.forEach(el => {
|
||||||
|
el.textContent = 'Um QRCode grátis';
|
||||||
|
el.className = 'badge bg-success qr-counter';
|
||||||
|
});
|
||||||
|
this.unlockInterface();
|
||||||
|
} else {
|
||||||
|
counterElements.forEach(el => {
|
||||||
|
el.textContent = '0 QRCodes grátis';
|
||||||
|
el.className = 'badge bg-danger qr-counter';
|
||||||
|
});
|
||||||
|
this.lockInterfaceForAnonymous();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lockInterfaceForAnonymous() {
|
||||||
|
const form = document.getElementById('qr-speed-form');
|
||||||
|
const generateBtn = document.getElementById('generate-btn');
|
||||||
|
const qrType = document.getElementById('qr-type');
|
||||||
|
const qrContent = document.getElementById('qr-content');
|
||||||
|
|
||||||
|
// Disable main controls
|
||||||
|
if (generateBtn) generateBtn.disabled = true;
|
||||||
|
if (qrType) qrType.disabled = true;
|
||||||
|
if (qrContent) qrContent.disabled = true;
|
||||||
|
|
||||||
|
// Disable all inputs in form
|
||||||
|
if (form) {
|
||||||
|
const inputs = form.querySelectorAll('input, select, textarea');
|
||||||
|
inputs.forEach(input => input.disabled = true);
|
||||||
|
form.style.opacity = '0.5';
|
||||||
|
form.style.pointerEvents = 'none'; // Prevent clicks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show large CTA overlay if not already present
|
||||||
|
const container = document.querySelector('.card-body'); // Assuming form is in a card-body
|
||||||
|
if (container && !document.getElementById('anonymous-lock-overlay')) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'anonymous-lock-overlay';
|
||||||
|
overlay.className = 'text-center p-4 position-absolute top-50 start-50 translate-middle w-100 h-100 d-flex flex-column justify-content-center align-items-center bg-white bg-opacity-75';
|
||||||
|
overlay.style.zIndex = '1000';
|
||||||
|
overlay.style.backdropFilter = 'blur(2px)';
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="bg-white p-4 rounded shadow border border-warning">
|
||||||
|
<i class="fas fa-lock fa-3x text-warning mb-3"></i>
|
||||||
|
<h4 class="fw-bold">Cota Grátis Esgotada!</h4>
|
||||||
|
<p class="text-muted mb-4">Você já gerou seu QR Code gratuito de hoje.</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="/Account/Login" class="btn btn-primary btn-lg">
|
||||||
|
<i class="fas fa-sign-in-alt me-2"></i> Fazer Login e Ganhar +5
|
||||||
|
</a>
|
||||||
|
<a href="/Pagamento/SelecaoPlano" class="btn btn-outline-success">
|
||||||
|
<i class="fas fa-coins me-2"></i> Comprar Créditos
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Make parent relative so absolute positioning works
|
||||||
|
if (getComputedStyle(container).position === 'static') {
|
||||||
|
container.style.position = 'relative';
|
||||||
|
}
|
||||||
|
container.appendChild(overlay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockInterface() {
|
||||||
|
const form = document.getElementById('qr-speed-form');
|
||||||
|
const overlay = document.getElementById('anonymous-lock-overlay');
|
||||||
|
|
||||||
|
// Remove overlay
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
|
||||||
|
// Enable form
|
||||||
|
if (form) {
|
||||||
|
form.style.opacity = '1';
|
||||||
|
form.style.pointerEvents = 'auto';
|
||||||
|
const inputs = form.querySelectorAll('input, select, textarea');
|
||||||
|
inputs.forEach(input => input.disabled = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLoggedUserCounter() {
|
async updateLoggedUserCounter() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user