feat/live-preview #1
@ -15,7 +15,8 @@
|
|||||||
"Bash(rg:*)",
|
"Bash(rg:*)",
|
||||||
"Bash(pkill:*)",
|
"Bash(pkill:*)",
|
||||||
"Bash(sudo rm:*)",
|
"Bash(sudo rm:*)",
|
||||||
"Bash(rm:*)"
|
"Bash(rm:*)",
|
||||||
|
"Bash(curl:*)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enableAllProjectMcpServers": false
|
"enableAllProjectMcpServers": false
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
||||||
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -493,7 +493,13 @@ public class AdminController : Controller
|
|||||||
Description = l.Description,
|
Description = l.Description,
|
||||||
Icon = l.Icon,
|
Icon = l.Icon,
|
||||||
Order = l.Order,
|
Order = l.Order,
|
||||||
IsActive = l.IsActive
|
IsActive = l.IsActive,
|
||||||
|
Type = l.Type,
|
||||||
|
ProductTitle = l.ProductTitle,
|
||||||
|
ProductImage = l.ProductImage,
|
||||||
|
ProductPrice = l.ProductPrice,
|
||||||
|
ProductDescription = l.ProductDescription,
|
||||||
|
ProductDataCachedAt = l.ProductDataCachedAt
|
||||||
}).ToList() ?? new List<ManageLinkViewModel>(),
|
}).ToList() ?? new List<ManageLinkViewModel>(),
|
||||||
AvailableCategories = categories,
|
AvailableCategories = categories,
|
||||||
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
|
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
|
||||||
@ -529,7 +535,13 @@ public class AdminController : Controller
|
|||||||
Description = l.Description,
|
Description = l.Description,
|
||||||
Icon = l.Icon,
|
Icon = l.Icon,
|
||||||
IsActive = l.IsActive,
|
IsActive = l.IsActive,
|
||||||
Order = index
|
Order = index,
|
||||||
|
Type = l.Type,
|
||||||
|
ProductTitle = l.ProductTitle,
|
||||||
|
ProductImage = l.ProductImage,
|
||||||
|
ProductPrice = l.ProductPrice,
|
||||||
|
ProductDescription = l.ProductDescription,
|
||||||
|
ProductDataCachedAt = l.ProductDataCachedAt
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -612,7 +624,13 @@ public class AdminController : Controller
|
|||||||
Description = l.Description,
|
Description = l.Description,
|
||||||
Icon = l.Icon,
|
Icon = l.Icon,
|
||||||
IsActive = l.IsActive,
|
IsActive = l.IsActive,
|
||||||
Order = index
|
Order = index,
|
||||||
|
Type = l.Type,
|
||||||
|
ProductTitle = l.ProductTitle,
|
||||||
|
ProductImage = l.ProductImage,
|
||||||
|
ProductPrice = l.ProductPrice,
|
||||||
|
ProductDescription = l.ProductDescription,
|
||||||
|
ProductDataCachedAt = l.ProductDataCachedAt
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -110,8 +110,26 @@ public class AuthController : Controller
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> Logout()
|
public async Task<IActionResult> Logout()
|
||||||
{
|
{
|
||||||
|
// Identifica qual provedor foi usado
|
||||||
|
var authResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
var loginProvider = authResult.Principal?.FindFirst("LoginProvider")?.Value;
|
||||||
|
|
||||||
|
// Faz logout local primeiro
|
||||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
TempData["Success"] = "Logout realizado com sucesso";
|
TempData["Success"] = "Logout realizado com sucesso";
|
||||||
|
|
||||||
|
// Se foi Microsoft, faz logout completo no provedor
|
||||||
|
if (loginProvider == "Microsoft")
|
||||||
|
{
|
||||||
|
return SignOut(MicrosoftAccountDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
// Se foi Google, faz logout completo no provedor
|
||||||
|
else if (loginProvider == "Google")
|
||||||
|
{
|
||||||
|
return SignOut(GoogleDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
|
||||||
return RedirectToAction("Index", "Home");
|
return RedirectToAction("Index", "Home");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
131
src/BCards.Web/Controllers/ProductController.cs
Normal file
131
src/BCards.Web/Controllers/ProductController.cs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ProductController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IOpenGraphService _openGraphService;
|
||||||
|
private readonly ILogger<ProductController> _logger;
|
||||||
|
|
||||||
|
public ProductController(
|
||||||
|
IOpenGraphService openGraphService,
|
||||||
|
ILogger<ProductController> logger)
|
||||||
|
{
|
||||||
|
_openGraphService = openGraphService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("extract")]
|
||||||
|
public async Task<IActionResult> ExtractProduct([FromBody] ExtractProductRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Url))
|
||||||
|
{
|
||||||
|
return BadRequest(new { success = false, message = "URL é obrigatória." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return BadRequest(new { success = false, message = "URL inválida." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
return Unauthorized(new { success = false, message = "Usuário não autenticado." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar rate limiting antes de tentar extrair
|
||||||
|
var isRateLimited = await _openGraphService.IsRateLimitedAsync(userId);
|
||||||
|
if (isRateLimited)
|
||||||
|
{
|
||||||
|
return this.TooManyRequests(new {
|
||||||
|
success = false,
|
||||||
|
message = "Aguarde 1 minuto antes de extrair dados de outro produto."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var ogData = await _openGraphService.ExtractDataAsync(request.Url, userId);
|
||||||
|
|
||||||
|
if (!ogData.IsValid)
|
||||||
|
{
|
||||||
|
return BadRequest(new {
|
||||||
|
success = false,
|
||||||
|
message = string.IsNullOrEmpty(ogData.ErrorMessage)
|
||||||
|
? "Não foi possível extrair dados desta página."
|
||||||
|
: ogData.ErrorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
success = true,
|
||||||
|
title = ogData.Title,
|
||||||
|
description = ogData.Description,
|
||||||
|
image = ogData.Image,
|
||||||
|
price = ogData.Price,
|
||||||
|
currency = ogData.Currency
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Operação inválida na extração de produto para usuário {UserId}",
|
||||||
|
User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||||
|
return BadRequest(new { success = false, message = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro interno na extração de produto para usuário {UserId}",
|
||||||
|
User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||||
|
return StatusCode(500, new {
|
||||||
|
success = false,
|
||||||
|
message = "Erro interno do servidor. Tente novamente em alguns instantes."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("cache/{urlHash}")]
|
||||||
|
public Task<IActionResult> GetCachedData(string urlHash)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Por segurança, vamos reconstruir a URL a partir do hash (se necessário)
|
||||||
|
// Por agora, apenas retornamos erro se não encontrado
|
||||||
|
return Task.FromResult<IActionResult>(NotFound(new { success = false, message = "Cache não encontrado." }));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao buscar cache para hash {UrlHash}", urlHash);
|
||||||
|
return Task.FromResult<IActionResult>(StatusCode(500, new { success = false, message = "Erro interno do servidor." }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExtractProductRequest
|
||||||
|
{
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom result for 429 Too Many Requests
|
||||||
|
public class TooManyRequestsResult : ObjectResult
|
||||||
|
{
|
||||||
|
public TooManyRequestsResult(object value) : base(value)
|
||||||
|
{
|
||||||
|
StatusCode = 429;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ControllerBaseExtensions
|
||||||
|
{
|
||||||
|
public static TooManyRequestsResult TooManyRequests(this ControllerBase controller, object value)
|
||||||
|
{
|
||||||
|
return new TooManyRequestsResult(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,15 +9,18 @@ public class UserPageController : Controller
|
|||||||
private readonly IUserPageService _userPageService;
|
private readonly IUserPageService _userPageService;
|
||||||
private readonly ICategoryService _categoryService;
|
private readonly ICategoryService _categoryService;
|
||||||
private readonly ISeoService _seoService;
|
private readonly ISeoService _seoService;
|
||||||
|
private readonly IThemeService _themeService;
|
||||||
|
|
||||||
public UserPageController(
|
public UserPageController(
|
||||||
IUserPageService userPageService,
|
IUserPageService userPageService,
|
||||||
ICategoryService categoryService,
|
ICategoryService categoryService,
|
||||||
ISeoService seoService)
|
ISeoService seoService,
|
||||||
|
IThemeService themeService)
|
||||||
{
|
{
|
||||||
_userPageService = userPageService;
|
_userPageService = userPageService;
|
||||||
_categoryService = categoryService;
|
_categoryService = categoryService;
|
||||||
_seoService = seoService;
|
_seoService = seoService;
|
||||||
|
_themeService = themeService;
|
||||||
}
|
}
|
||||||
|
|
||||||
//[Route("{category}/{slug}")]
|
//[Route("{category}/{slug}")]
|
||||||
@ -32,6 +35,12 @@ public class UserPageController : Controller
|
|||||||
if (categoryObj == null)
|
if (categoryObj == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
|
// Ensure theme is loaded - critical fix for theme display issue
|
||||||
|
if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor))
|
||||||
|
{
|
||||||
|
userPage.Theme = _themeService.GetDefaultTheme();
|
||||||
|
}
|
||||||
|
|
||||||
// Generate SEO settings
|
// Generate SEO settings
|
||||||
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
|
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
|
||||||
|
|
||||||
@ -65,6 +74,12 @@ public class UserPageController : Controller
|
|||||||
if (categoryObj == null)
|
if (categoryObj == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
|
// Ensure theme is loaded for preview too
|
||||||
|
if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor))
|
||||||
|
{
|
||||||
|
userPage.Theme = _themeService.GetDefaultTheme();
|
||||||
|
}
|
||||||
|
|
||||||
ViewBag.Category = categoryObj;
|
ViewBag.Category = categoryObj;
|
||||||
ViewBag.IsPreview = true;
|
ViewBag.IsPreview = true;
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,12 @@ using MongoDB.Bson.Serialization.Attributes;
|
|||||||
|
|
||||||
namespace BCards.Web.Models;
|
namespace BCards.Web.Models;
|
||||||
|
|
||||||
|
public enum LinkType
|
||||||
|
{
|
||||||
|
Normal = 0, // Link comum
|
||||||
|
Product = 1 // Link de produto com preview
|
||||||
|
}
|
||||||
|
|
||||||
public class LinkItem
|
public class LinkItem
|
||||||
{
|
{
|
||||||
[BsonElement("title")]
|
[BsonElement("title")]
|
||||||
@ -27,4 +33,23 @@ public class LinkItem
|
|||||||
|
|
||||||
[BsonElement("createdAt")]
|
[BsonElement("createdAt")]
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Campos para Link de Produto
|
||||||
|
[BsonElement("type")]
|
||||||
|
public LinkType Type { get; set; } = LinkType.Normal;
|
||||||
|
|
||||||
|
[BsonElement("productTitle")]
|
||||||
|
public string ProductTitle { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("productImage")]
|
||||||
|
public string ProductImage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("productPrice")]
|
||||||
|
public string ProductPrice { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("productDescription")]
|
||||||
|
public string ProductDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("productDataCachedAt")]
|
||||||
|
public DateTime? ProductDataCachedAt { get; set; }
|
||||||
}
|
}
|
||||||
55
src/BCards.Web/Models/OpenGraphCache.cs
Normal file
55
src/BCards.Web/Models/OpenGraphCache.cs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace BCards.Web.Models;
|
||||||
|
|
||||||
|
public class OpenGraphCache
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("url")]
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("urlHash")]
|
||||||
|
public string UrlHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("description")]
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("image")]
|
||||||
|
public string Image { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("price")]
|
||||||
|
public string Price { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("currency")]
|
||||||
|
public string Currency { get; set; } = "BRL";
|
||||||
|
|
||||||
|
[BsonElement("isValid")]
|
||||||
|
public bool IsValid { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("errorMessage")]
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("cachedAt")]
|
||||||
|
public DateTime CachedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[BsonElement("expiresAt")]
|
||||||
|
public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(24);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OpenGraphData
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Image { get; set; } = string.Empty;
|
||||||
|
public string Price { get; set; } = string.Empty;
|
||||||
|
public string Currency { get; set; } = "BRL";
|
||||||
|
public bool IsValid { get; set; }
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -24,4 +24,20 @@ public class PlanLimitations
|
|||||||
|
|
||||||
[BsonElement("planType")]
|
[BsonElement("planType")]
|
||||||
public string PlanType { get; set; } = "free";
|
public string PlanType { get; set; } = "free";
|
||||||
|
|
||||||
|
// Novos campos para Links de Produto
|
||||||
|
[BsonElement("maxProductLinks")]
|
||||||
|
public int MaxProductLinks { get; set; } = 0;
|
||||||
|
|
||||||
|
[BsonElement("maxOGExtractionsPerDay")]
|
||||||
|
public int MaxOGExtractionsPerDay { get; set; } = 0;
|
||||||
|
|
||||||
|
[BsonElement("allowProductLinks")]
|
||||||
|
public bool AllowProductLinks { get; set; } = false;
|
||||||
|
|
||||||
|
[BsonElement("ogExtractionsUsedToday")]
|
||||||
|
public int OGExtractionsUsedToday { get; set; } = 0;
|
||||||
|
|
||||||
|
[BsonElement("lastExtractionDate")]
|
||||||
|
public DateTime? LastExtractionDate { get; set; }
|
||||||
}
|
}
|
||||||
@ -91,4 +91,33 @@ public static class PlanTypeExtensions
|
|||||||
{
|
{
|
||||||
return planType == PlanType.Trial ? 7 : 0;
|
return planType == PlanType.Trial ? 7 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int GetMaxProductLinks(this PlanType planType)
|
||||||
|
{
|
||||||
|
return planType switch
|
||||||
|
{
|
||||||
|
PlanType.Trial => 1, // 1 link de produto para trial
|
||||||
|
PlanType.Basic => 3, // 3 links de produto
|
||||||
|
PlanType.Professional => 8, // DECOY - mais caro para poucos benefícios
|
||||||
|
PlanType.Premium => int.MaxValue, // Ilimitado
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetMaxOGExtractionsPerDay(this PlanType planType)
|
||||||
|
{
|
||||||
|
return planType switch
|
||||||
|
{
|
||||||
|
PlanType.Trial => 2, // 2 extrações por dia no trial
|
||||||
|
PlanType.Basic => 5, // 5 extrações por dia
|
||||||
|
PlanType.Professional => 15, // 15 extrações por dia
|
||||||
|
PlanType.Premium => int.MaxValue, // Ilimitado
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool AllowsProductLinks(this PlanType planType)
|
||||||
|
{
|
||||||
|
return GetMaxProductLinks(planType) > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -8,6 +8,8 @@ using Microsoft.AspNetCore.Localization;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Stripe;
|
||||||
|
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@ -69,6 +71,19 @@ builder.Services.AddAuthentication(options =>
|
|||||||
var msAuth = builder.Configuration.GetSection("Authentication:Microsoft");
|
var msAuth = builder.Configuration.GetSection("Authentication:Microsoft");
|
||||||
options.ClientId = msAuth["ClientId"] ?? "";
|
options.ClientId = msAuth["ClientId"] ?? "";
|
||||||
options.ClientSecret = msAuth["ClientSecret"] ?? "";
|
options.ClientSecret = msAuth["ClientSecret"] ?? "";
|
||||||
|
|
||||||
|
// Força seleção de conta a cada login
|
||||||
|
options.AuthorizationEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
||||||
|
options.TokenEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
||||||
|
|
||||||
|
options.Events = new OAuthEvents
|
||||||
|
{
|
||||||
|
OnRedirectToAuthorizationEndpoint = context =>
|
||||||
|
{
|
||||||
|
context.Response.Redirect(context.RedirectUri + "&prompt=select_account");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Localization
|
// Localization
|
||||||
@ -99,6 +114,10 @@ builder.Services.AddScoped<ISeoService, SeoService>();
|
|||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
||||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
|
builder.Services.AddScoped<IOpenGraphService, OpenGraphService>();
|
||||||
|
|
||||||
|
// Add HttpClient for OpenGraphService
|
||||||
|
builder.Services.AddHttpClient<OpenGraphService>();
|
||||||
|
|
||||||
// Background Services
|
// Background Services
|
||||||
builder.Services.AddHostedService<TrialExpirationService>();
|
builder.Services.AddHostedService<TrialExpirationService>();
|
||||||
|
|||||||
10
src/BCards.Web/Services/IOpenGraphService.cs
Normal file
10
src/BCards.Web/Services/IOpenGraphService.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public interface IOpenGraphService
|
||||||
|
{
|
||||||
|
Task<OpenGraphData> ExtractDataAsync(string url, string userId);
|
||||||
|
Task<bool> IsRateLimitedAsync(string userId);
|
||||||
|
Task<OpenGraphCache?> GetCachedDataAsync(string url);
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ public interface IThemeService
|
|||||||
Task<PageTheme?> GetThemeByIdAsync(string themeId);
|
Task<PageTheme?> GetThemeByIdAsync(string themeId);
|
||||||
Task<PageTheme?> GetThemeByNameAsync(string themeName);
|
Task<PageTheme?> GetThemeByNameAsync(string themeName);
|
||||||
Task<string> GenerateCustomCssAsync(PageTheme theme);
|
Task<string> GenerateCustomCssAsync(PageTheme theme);
|
||||||
|
Task<string> GenerateThemeCSSAsync(PageTheme theme, UserPage page);
|
||||||
Task InitializeDefaultThemesAsync();
|
Task InitializeDefaultThemesAsync();
|
||||||
PageTheme GetDefaultTheme();
|
PageTheme GetDefaultTheme();
|
||||||
}
|
}
|
||||||
299
src/BCards.Web/Services/OpenGraphService.cs
Normal file
299
src/BCards.Web/Services/OpenGraphService.cs
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Utils;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public class OpenGraphService : IOpenGraphService
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly ILogger<OpenGraphService> _logger;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IMongoCollection<OpenGraphCache> _ogCache;
|
||||||
|
|
||||||
|
public OpenGraphService(
|
||||||
|
IMemoryCache cache,
|
||||||
|
ILogger<OpenGraphService> logger,
|
||||||
|
HttpClient httpClient,
|
||||||
|
IMongoDatabase database)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_ogCache = database.GetCollection<OpenGraphCache>("openGraphCache");
|
||||||
|
|
||||||
|
// Configure HttpClient
|
||||||
|
_httpClient.DefaultRequestHeaders.Clear();
|
||||||
|
//_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||||
|
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36");
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||||
|
"facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)");
|
||||||
|
_httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenGraphData> ExtractDataAsync(string url, string userId)
|
||||||
|
{
|
||||||
|
// 1. Validar domínio
|
||||||
|
if (!AllowedDomains.IsAllowed(url))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Tentativa de extração de domínio não permitido: {Url} pelo usuário {UserId}", url, userId);
|
||||||
|
throw new InvalidOperationException("Domínio não permitido. Use apenas e-commerces conhecidos e seguros.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verificar rate limit (1 request por minuto por usuário)
|
||||||
|
var rateLimitKey = $"og_rate_{userId}";
|
||||||
|
if (_cache.TryGetValue(rateLimitKey, out _))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Rate limit excedido para usuário {UserId}", userId);
|
||||||
|
throw new InvalidOperationException("Aguarde 1 minuto antes de extrair dados de outro produto.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verificar cache no MongoDB
|
||||||
|
var urlHash = GenerateUrlHash(url);
|
||||||
|
var cachedData = await GetCachedDataAsync(url);
|
||||||
|
|
||||||
|
if (cachedData != null && cachedData.ExpiresAt > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Retornando dados do cache MongoDB para URL: {Url}", url);
|
||||||
|
return new OpenGraphData
|
||||||
|
{
|
||||||
|
Title = cachedData.Title,
|
||||||
|
Description = cachedData.Description,
|
||||||
|
Image = cachedData.Image,
|
||||||
|
Price = cachedData.Price,
|
||||||
|
Currency = cachedData.Currency,
|
||||||
|
IsValid = cachedData.IsValid,
|
||||||
|
ErrorMessage = cachedData.ErrorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Extrair dados da URL
|
||||||
|
var extractedData = await ExtractFromUrlAsync(url);
|
||||||
|
|
||||||
|
// 5. Salvar no cache MongoDB
|
||||||
|
await SaveToCacheAsync(url, urlHash, extractedData);
|
||||||
|
|
||||||
|
// 6. Aplicar rate limit (1 minuto)
|
||||||
|
_cache.Set(rateLimitKey, true, TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
_logger.LogInformation("Dados extraídos com sucesso para URL: {Url}", url);
|
||||||
|
return extractedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> IsRateLimitedAsync(string userId)
|
||||||
|
{
|
||||||
|
var rateLimitKey = $"og_rate_{userId}";
|
||||||
|
return Task.FromResult(_cache.TryGetValue(rateLimitKey, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenGraphCache?> GetCachedDataAsync(string url)
|
||||||
|
{
|
||||||
|
var urlHash = GenerateUrlHash(url);
|
||||||
|
return await _ogCache
|
||||||
|
.Find(x => x.UrlHash == urlHash && x.ExpiresAt > DateTime.UtcNow)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OpenGraphData> ExtractFromUrlAsync(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Iniciando extração de dados para URL: {Url}", url);
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var html = await response.Content.ReadAsStringAsync();
|
||||||
|
var doc = new HtmlDocument();
|
||||||
|
doc.LoadHtml(html);
|
||||||
|
|
||||||
|
var title = GetMetaContent(doc, "og:title", "title") ?? GetTitleFromHTML(doc);
|
||||||
|
var description = GetMetaContent(doc, "og:description", "description");
|
||||||
|
var image = GetMetaContent(doc, "og:image");
|
||||||
|
var price = GetMetaContent(doc, "og:price:amount") ?? ExtractPriceFromHTML(html, doc);
|
||||||
|
var currency = GetMetaContent(doc, "og:price:currency") ?? "BRL";
|
||||||
|
|
||||||
|
// Limpar e validar dados
|
||||||
|
title = CleanText(title);
|
||||||
|
description = CleanText(description);
|
||||||
|
price = CleanPrice(price);
|
||||||
|
image = ValidateImageUrl(image, url);
|
||||||
|
|
||||||
|
var isValid = !string.IsNullOrEmpty(title);
|
||||||
|
|
||||||
|
return new OpenGraphData
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Description = description,
|
||||||
|
Image = image,
|
||||||
|
Price = price,
|
||||||
|
Currency = currency,
|
||||||
|
IsValid = isValid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Falha ao extrair dados de {Url}", url);
|
||||||
|
return new OpenGraphData
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
ErrorMessage = $"Erro ao processar a página: {ex.Message}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GetMetaContent(HtmlDocument doc, params string[] properties)
|
||||||
|
{
|
||||||
|
foreach (var property in properties)
|
||||||
|
{
|
||||||
|
var meta = doc.DocumentNode
|
||||||
|
.SelectSingleNode($"//meta[@property='{property}' or @name='{property}' or @itemprop='{property}']");
|
||||||
|
|
||||||
|
var content = meta?.GetAttributeValue("content", null);
|
||||||
|
if (!string.IsNullOrWhiteSpace(content))
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GetTitleFromHTML(HtmlDocument doc)
|
||||||
|
{
|
||||||
|
var titleNode = doc.DocumentNode.SelectSingleNode("//title");
|
||||||
|
return titleNode?.InnerText?.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ExtractPriceFromHTML(string html, HtmlDocument doc)
|
||||||
|
{
|
||||||
|
// Regex patterns para encontrar preços em diferentes formatos
|
||||||
|
var pricePatterns = new[]
|
||||||
|
{
|
||||||
|
@"R\$\s*[\d\.,]+",
|
||||||
|
@"BRL\s*[\d\.,]+",
|
||||||
|
@"[\$]\s*[\d\.,]+",
|
||||||
|
@"price[^>]*>([^<]*[\d\.,]+[^<]*)<",
|
||||||
|
@"valor[^>]*>([^<]*[\d\.,]+[^<]*)<",
|
||||||
|
@"preço[^>]*>([^<]*[\d\.,]+[^<]*)<"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var pattern in pricePatterns)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
return match.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar encontrar por seletores específicos
|
||||||
|
var priceSelectors = new[]
|
||||||
|
{
|
||||||
|
".price", ".valor", ".preco", "[data-price]", ".price-current",
|
||||||
|
".price-value", ".product-price", ".sale-price"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var selector in priceSelectors)
|
||||||
|
{
|
||||||
|
var priceNode = doc.DocumentNode.SelectSingleNode($"//*[contains(@class, '{selector.Replace(".", "")}')]");
|
||||||
|
if (priceNode != null)
|
||||||
|
{
|
||||||
|
var priceText = priceNode.InnerText?.Trim();
|
||||||
|
if (Regex.IsMatch(priceText ?? "", @"[\d\.,]+"))
|
||||||
|
{
|
||||||
|
return priceText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CleanText(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return Regex.Replace(text.Trim(), @"\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CleanPrice(string? price)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(price))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// Limpar e formatar preço
|
||||||
|
var cleanPrice = Regex.Replace(price, @"[^\d\.,R\$]", " ").Trim();
|
||||||
|
return Regex.Replace(cleanPrice, @"\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ValidateImageUrl(string? imageUrl, string baseUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(imageUrl))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Se for URL relativa, converter para absoluta
|
||||||
|
if (imageUrl.StartsWith("/"))
|
||||||
|
{
|
||||||
|
var baseUri = new Uri(baseUrl);
|
||||||
|
return $"{baseUri.Scheme}://{baseUri.Host}{imageUrl}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar se é uma URL válida
|
||||||
|
if (Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return uri.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Erro ao validar URL da imagem: {ImageUrl}", imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateUrlHash(string url)
|
||||||
|
{
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(url.ToLowerInvariant()));
|
||||||
|
return Convert.ToBase64String(hashBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveToCacheAsync(string url, string urlHash, OpenGraphData data)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cacheItem = new OpenGraphCache
|
||||||
|
{
|
||||||
|
Url = url,
|
||||||
|
UrlHash = urlHash,
|
||||||
|
Title = data.Title,
|
||||||
|
Description = data.Description,
|
||||||
|
Image = data.Image,
|
||||||
|
Price = data.Price,
|
||||||
|
Currency = data.Currency,
|
||||||
|
IsValid = data.IsValid,
|
||||||
|
ErrorMessage = data.ErrorMessage,
|
||||||
|
CachedAt = DateTime.UtcNow,
|
||||||
|
ExpiresAt = data.IsValid ? DateTime.UtcNow.AddHours(24) : DateTime.UtcNow.AddHours(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upsert no MongoDB
|
||||||
|
await _ogCache.ReplaceOneAsync(
|
||||||
|
x => x.UrlHash == urlHash,
|
||||||
|
cacheItem,
|
||||||
|
new ReplaceOptions { IsUpsert = true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao salvar cache para URL: {Url}", url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace BCards.Web.Services;
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
@ -133,6 +134,128 @@ public class ThemeService : IThemeService
|
|||||||
return Task.FromResult(css);
|
return Task.FromResult(css);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> GenerateThemeCSSAsync(PageTheme theme, UserPage page)
|
||||||
|
{
|
||||||
|
var css = new StringBuilder();
|
||||||
|
|
||||||
|
// CSS base com variáveis do tema
|
||||||
|
css.AppendLine($":root {{");
|
||||||
|
css.AppendLine($" --primary-color: {theme.PrimaryColor};");
|
||||||
|
css.AppendLine($" --secondary-color: {theme.SecondaryColor};");
|
||||||
|
css.AppendLine($" --background-color: {theme.BackgroundColor};");
|
||||||
|
css.AppendLine($" --text-color: {theme.TextColor};");
|
||||||
|
css.AppendLine($"}}");
|
||||||
|
|
||||||
|
// CSS específico por tema
|
||||||
|
switch (theme.Name?.ToLower())
|
||||||
|
{
|
||||||
|
case "minimalista":
|
||||||
|
css.AppendLine(GetMinimalistCSS());
|
||||||
|
break;
|
||||||
|
case "corporativo":
|
||||||
|
css.AppendLine(GetCorporateCSS());
|
||||||
|
break;
|
||||||
|
case "dark mode":
|
||||||
|
css.AppendLine(GetDarkCSS());
|
||||||
|
break;
|
||||||
|
case "natureza":
|
||||||
|
css.AppendLine(GetNatureCSS());
|
||||||
|
break;
|
||||||
|
case "vibrante":
|
||||||
|
css.AppendLine(GetVibrantCSS());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
css.AppendLine(await GenerateCustomCssAsync(theme));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return css.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetMinimalistCSS() => @"
|
||||||
|
.profile-card {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private string GetCorporateCSS() => @"
|
||||||
|
.user-page {
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
}
|
||||||
|
.profile-card {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private string GetDarkCSS() => @"
|
||||||
|
.user-page {
|
||||||
|
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||||
|
}
|
||||||
|
.profile-card {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: var(--primary-color);
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.profile-name, .profile-bio {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private string GetNatureCSS() => @"
|
||||||
|
.user-page {
|
||||||
|
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||||
|
background-image: url('data:image/svg+xml,<svg xmlns=""http://www.w3.org/2000/svg"" viewBox=""0 0 100 100""><defs><pattern id=""grain"" width=""100"" height=""100"" patternUnits=""userSpaceOnUse""><circle cx=""25"" cy=""25"" r=""1"" fill=""%23059669"" opacity=""0.1""/><circle cx=""75"" cy=""75"" r=""1"" fill=""%23059669"" opacity=""0.1""/></pattern></defs><rect width=""100"" height=""100"" fill=""url(%23grain)""/></svg>');
|
||||||
|
}
|
||||||
|
.profile-card {
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||||
|
border-radius: 25px;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private string GetVibrantCSS() => @"
|
||||||
|
.user-page {
|
||||||
|
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 50%, #fecaca 100%);
|
||||||
|
}
|
||||||
|
.profile-card {
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.2);
|
||||||
|
border: 2px solid rgba(220, 38, 38, 0.1);
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||||
|
border-radius: 30px;
|
||||||
|
transform: perspective(1000px) rotateX(0deg);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.link-button:hover {
|
||||||
|
transform: perspective(1000px) rotateX(-5deg) translateY(-5px);
|
||||||
|
box-shadow: 0 15px 30px rgba(220, 38, 38, 0.3);
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
public async Task InitializeDefaultThemesAsync()
|
public async Task InitializeDefaultThemesAsync()
|
||||||
{
|
{
|
||||||
var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync();
|
var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync();
|
||||||
|
|||||||
69
src/BCards.Web/Utils/AllowedDomains.cs
Normal file
69
src/BCards.Web/Utils/AllowedDomains.cs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
namespace BCards.Web.Utils;
|
||||||
|
|
||||||
|
public static class AllowedDomains
|
||||||
|
{
|
||||||
|
public static readonly HashSet<string> EcommerceWhitelist = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// Principais E-commerces Brasileiros
|
||||||
|
"mercadolivre.com.br", "mercadolibre.com",
|
||||||
|
"amazon.com.br", "amazon.com",
|
||||||
|
"magazineluiza.com.br", "magalu.com.br",
|
||||||
|
"americanas.com", "submarino.com.br",
|
||||||
|
"extra.com.br", "pontofrio.com.br",
|
||||||
|
"casasbahia.com.br", "casas.com.br",
|
||||||
|
"shopee.com.br", "shopee.com", "s.shopee.com.br",
|
||||||
|
"aliexpress.com", "aliexpress.us",
|
||||||
|
"netshoes.com.br", "centauro.com.br",
|
||||||
|
"dafiti.com.br", "kanui.com.br",
|
||||||
|
"fastshop.com.br", "kabum.com.br",
|
||||||
|
"pichau.com.br", "terabyteshop.com.br",
|
||||||
|
|
||||||
|
// Marketplaces Internacionais Seguros
|
||||||
|
"ebay.com", "etsy.com", "walmart.com",
|
||||||
|
"target.com", "bestbuy.com",
|
||||||
|
|
||||||
|
// E-commerces de Moda
|
||||||
|
"zara.com", "hm.com", "gap.com",
|
||||||
|
"uniqlo.com", "forever21.com",
|
||||||
|
|
||||||
|
// Livrarias e Educação
|
||||||
|
"saraiva.com.br", "livrariacultura.com.br",
|
||||||
|
"estantevirtual.com.br",
|
||||||
|
|
||||||
|
// Casa e Decoração
|
||||||
|
"mobly.com.br", "tok-stok.com.br",
|
||||||
|
"westwing.com.br", "madeiramadeira.com.br"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsAllowed(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
var domain = uri.Host.ToLowerInvariant();
|
||||||
|
|
||||||
|
// Remove "www." se existir
|
||||||
|
if (domain.StartsWith("www."))
|
||||||
|
domain = domain.Substring(4);
|
||||||
|
|
||||||
|
return EcommerceWhitelist.Contains(domain);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetDomainFromUrl(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
return uri.Host.ToLowerInvariant().Replace("www.", "");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -63,6 +63,22 @@ public class ManageLinkViewModel
|
|||||||
public string Icon { get; set; } = string.Empty;
|
public string Icon { get; set; } = string.Empty;
|
||||||
public int Order { get; set; } = 0;
|
public int Order { get; set; } = 0;
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
// Campos para Links de Produto
|
||||||
|
public LinkType Type { get; set; } = LinkType.Normal;
|
||||||
|
|
||||||
|
[StringLength(100, ErrorMessage = "Título do produto deve ter no máximo 100 caracteres")]
|
||||||
|
public string ProductTitle { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ProductImage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(50, ErrorMessage = "Preço deve ter no máximo 50 caracteres")]
|
||||||
|
public string ProductPrice { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(200, ErrorMessage = "Descrição do produto deve ter no máximo 200 caracteres")]
|
||||||
|
public string ProductDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime? ProductDataCachedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DashboardViewModel
|
public class DashboardViewModel
|
||||||
|
|||||||
@ -355,6 +355,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="addLinkForm">
|
<form id="addLinkForm">
|
||||||
|
<!-- Tipo de Link -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Tipo de Link</label>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<div class="form-check flex-fill">
|
||||||
|
<input class="form-check-input" type="radio" name="linkType" id="linkTypeNormal" value="Normal" checked>
|
||||||
|
<label class="form-check-label w-100 p-2 border rounded" for="linkTypeNormal">
|
||||||
|
<i class="fas fa-link me-2"></i>
|
||||||
|
<strong>Link Normal</strong>
|
||||||
|
<div class="small text-muted">Link simples para sites, redes sociais, etc.</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check flex-fill">
|
||||||
|
<input class="form-check-input" type="radio" name="linkType" id="linkTypeProduct" value="Product">
|
||||||
|
<label class="form-check-label w-100 p-2 border rounded" for="linkTypeProduct">
|
||||||
|
<i class="fas fa-shopping-bag me-2"></i>
|
||||||
|
<strong>Link de Produto</strong>
|
||||||
|
<div class="small text-muted">Para produtos de e-commerce com preview</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seção para Link Normal -->
|
||||||
|
<div id="normalLinkSection">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="linkTitle" class="form-label">Título do Link</label>
|
<label for="linkTitle" class="form-label">Título do Link</label>
|
||||||
<input type="text" class="form-control" id="linkTitle" placeholder="Ex: Meu Site, Portfólio, Instagram..." required>
|
<input type="text" class="form-control" id="linkTitle" placeholder="Ex: Meu Site, Portfólio, Instagram..." required>
|
||||||
@ -391,6 +416,66 @@
|
|||||||
<option value="fas fa-heart">❤️ Favorito</option>
|
<option value="fas fa-heart">❤️ Favorito</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seção para Link de Produto -->
|
||||||
|
<div id="productLinkSection" style="display: none;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="productUrl" class="form-label">URL do Produto</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="url" class="form-control" id="productUrl" placeholder="https://mercadolivre.com.br/produto...">
|
||||||
|
<button type="button" class="btn btn-outline-primary" id="extractProductBtn">
|
||||||
|
<i class="fas fa-magic"></i> Extrair Dados
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
<small>
|
||||||
|
<strong>Suportamos:</strong> Mercado Livre, Amazon, Magazine Luiza, Americanas, Shopee, e outros e-commerces conhecidos.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="extractLoading" style="display: none;" class="text-center my-3">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Carregando...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-muted">Extraindo informações do produto...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="productTitle" class="form-label">Título do Produto</label>
|
||||||
|
<input type="text" class="form-control" id="productTitle" maxlength="100" placeholder="Nome do produto">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="productDescription" class="form-label">Descrição (Opcional)</label>
|
||||||
|
<textarea class="form-control" id="productDescription" rows="2" maxlength="200" placeholder="Breve descrição do produto"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="productPrice" class="form-label">Preço (Opcional)</label>
|
||||||
|
<input type="text" class="form-control" id="productPrice" placeholder="R$ 99,90">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Imagem do Produto</label>
|
||||||
|
<div class="border rounded p-3 text-center">
|
||||||
|
<img id="productImagePreview" class="img-fluid rounded" style="display: none; max-height: 120px;">
|
||||||
|
<div id="productImagePlaceholder" class="text-muted">
|
||||||
|
<i class="fas fa-image fa-2x mb-2"></i>
|
||||||
|
<p class="small mb-0">A imagem será extraída automaticamente</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="productImage">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info small">
|
||||||
|
<i class="fas fa-info-circle me-1"></i>
|
||||||
|
<strong>Dica:</strong> Os dados serão extraídos automaticamente da página do produto.
|
||||||
|
Você pode editar manualmente se necessário.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@ -501,6 +586,32 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Product Link Preview Styles */
|
||||||
|
.product-link-preview {
|
||||||
|
background: rgba(25, 135, 84, 0.05);
|
||||||
|
border-color: rgba(25, 135, 84, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-preview .card {
|
||||||
|
box-shadow: none;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-preview .card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-preview .card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-preview img {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
@ -534,40 +645,44 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save link from modal
|
// Toggle between link types
|
||||||
$(document).on('click', '#saveLinkBtn', function() {
|
$('input[name="linkType"]').on('change', function() {
|
||||||
console.log('Save button clicked');
|
const linkType = $(this).val();
|
||||||
|
if (linkType === 'Product') {
|
||||||
|
$('#normalLinkSection').hide();
|
||||||
|
$('#productLinkSection').show();
|
||||||
|
} else {
|
||||||
|
$('#normalLinkSection').show();
|
||||||
|
$('#productLinkSection').hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const title = $('#linkTitle').val().trim();
|
// Extract product data
|
||||||
const url = $('#linkUrl').val().trim();
|
$('#extractProductBtn').on('click', function() {
|
||||||
const description = $('#linkDescription').val().trim();
|
const url = $('#productUrl').val().trim();
|
||||||
const icon = $('#linkIcon').val();
|
|
||||||
|
|
||||||
console.log('Values:', { title, url, description, icon });
|
if (!url) {
|
||||||
|
alert('Por favor, insira a URL do produto.');
|
||||||
if (!title || !url) {
|
|
||||||
alert('Por favor, preencha pelo menos o título e a URL do link.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic URL validation
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
|
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
addLinkInput(title, url, description, icon);
|
extractProductData(url);
|
||||||
|
});
|
||||||
|
|
||||||
// Clear modal form
|
// Save link from modal
|
||||||
$('#addLinkForm')[0].reset();
|
$(document).on('click', '#saveLinkBtn', function() {
|
||||||
|
const linkType = $('input[name="linkType"]:checked').val();
|
||||||
|
|
||||||
// Close modal using Bootstrap 5 syntax
|
if (linkType === 'Product') {
|
||||||
var modal = bootstrap.Modal.getInstance(document.getElementById('addLinkModal'));
|
saveProductLink();
|
||||||
if (modal) {
|
} else {
|
||||||
modal.hide();
|
saveNormalLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
markStepComplete(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove link functionality
|
// Remove link functionality
|
||||||
@ -663,7 +778,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLinkInput(title = '', url = '', description = '', icon = '') {
|
function addLinkInput(title = '', url = '', description = '', icon = '', linkType = 'Normal') {
|
||||||
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
|
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
|
||||||
|
|
||||||
const linkHtml = `
|
const linkHtml = `
|
||||||
@ -696,6 +811,7 @@
|
|||||||
<input type="text" name="Links[${linkCount}].Description" class="form-control link-description" value="${description}" placeholder="Breve descrição do link" readonly>
|
<input type="text" name="Links[${linkCount}].Description" class="form-control link-description" value="${description}" placeholder="Breve descrição do link" readonly>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="Links[${linkCount}].Id" value="">
|
<input type="hidden" name="Links[${linkCount}].Id" value="">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Type" value="${linkType}">
|
||||||
<input type="hidden" name="Links[${linkCount}].Icon" value="${icon}">
|
<input type="hidden" name="Links[${linkCount}].Icon" value="${icon}">
|
||||||
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
|
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
|
||||||
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
|
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
|
||||||
@ -712,5 +828,193 @@
|
|||||||
$(this).attr('data-link', index);
|
$(this).attr('data-link', index);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveNormalLink() {
|
||||||
|
const title = $('#linkTitle').val().trim();
|
||||||
|
const url = $('#linkUrl').val().trim();
|
||||||
|
const description = $('#linkDescription').val().trim();
|
||||||
|
const icon = $('#linkIcon').val();
|
||||||
|
|
||||||
|
if (!title || !url) {
|
||||||
|
alert('Por favor, preencha pelo menos o título e a URL do link.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLinkInput(title, url, description, icon, 'Normal');
|
||||||
|
closeModalAndReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProductLink() {
|
||||||
|
const url = $('#productUrl').val().trim();
|
||||||
|
const title = $('#productTitle').val().trim();
|
||||||
|
const description = $('#productDescription').val().trim();
|
||||||
|
const price = $('#productPrice').val().trim();
|
||||||
|
const image = $('#productImage').val();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
alert('Por favor, insira a URL do produto.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
alert('Por favor, preencha o título do produto.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addProductLinkInput(title, url, description, price, image);
|
||||||
|
closeModalAndReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProductData(url) {
|
||||||
|
$('#extractProductBtn').prop('disabled', true);
|
||||||
|
$('#extractLoading').show();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/api/Product/extract',
|
||||||
|
type: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify({ url: url }),
|
||||||
|
success: function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
$('#productTitle').val(response.title || '');
|
||||||
|
$('#productDescription').val(response.description || '');
|
||||||
|
$('#productPrice').val(response.price || '');
|
||||||
|
|
||||||
|
if (response.image) {
|
||||||
|
$('#productImage').val(response.image);
|
||||||
|
$('#productImagePreview').attr('src', response.image).show();
|
||||||
|
$('#productImagePlaceholder').hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Dados extraídos com sucesso!', 'success');
|
||||||
|
} else {
|
||||||
|
alert('Erro: ' + (response.message || 'Não foi possível extrair os dados do produto.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
let errorMessage = 'Erro ao extrair dados do produto.';
|
||||||
|
|
||||||
|
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||||
|
errorMessage = xhr.responseJSON.message;
|
||||||
|
} else if (xhr.status === 429) {
|
||||||
|
errorMessage = 'Aguarde 1 minuto antes de extrair dados de outro produto.';
|
||||||
|
} else if (xhr.status === 401) {
|
||||||
|
errorMessage = 'Você precisa estar logado para usar esta funcionalidade.';
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(errorMessage);
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
$('#extractProductBtn').prop('disabled', false);
|
||||||
|
$('#extractLoading').hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProductLinkInput(title, url, description, price, image) {
|
||||||
|
const linkHtml = `
|
||||||
|
<div class="link-input-group product-link-preview" data-link="${linkCount}">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-shopping-bag me-2 text-success"></i>Link de Produto ${linkCount + 1}
|
||||||
|
</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="row g-0">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="p-3 text-center">
|
||||||
|
${image ? `<img src="${image}" class="img-fluid rounded" style="max-height: 80px; max-width: 100%;" onerror="this.style.display='none'; this.parentNode.innerHTML='<i class=\\"fas fa-image text-muted\\"></i><br><small class=\\"text-muted\\">Sem imagem</small>';">` : '<i class="fas fa-image text-muted fa-2x"></i><br><small class="text-muted">Sem imagem</small>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-success">${title}</h6>
|
||||||
|
${price ? `<p class="card-text"><strong class="text-success">${price}</strong></p>` : ''}
|
||||||
|
${description ? `<p class="card-text small text-muted">${description}</p>` : ''}
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>
|
||||||
|
${url.length > 50 ? url.substring(0, 50) + '...' : url}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden fields for form submission -->
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Id" value="">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Title" value="${title}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Url" value="${url}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Description" value="${description}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Type" value="Product">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].ProductTitle" value="${title}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].ProductDescription" value="${description}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].ProductPrice" value="${price}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].ProductImage" value="${image}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Icon" value="fas fa-shopping-bag">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
$('#linksContainer').append(linkHtml);
|
||||||
|
linkCount++;
|
||||||
|
markStepComplete(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModalAndReset() {
|
||||||
|
// Clear modal form
|
||||||
|
$('#addLinkForm')[0].reset();
|
||||||
|
$('#productImagePreview').hide();
|
||||||
|
$('#productImagePlaceholder').show();
|
||||||
|
$('#productImage').val('');
|
||||||
|
$('#normalLinkSection').show();
|
||||||
|
$('#productLinkSection').hide();
|
||||||
|
$('#linkTypeNormal').prop('checked', true);
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
var modal = bootstrap.Modal.getInstance(document.getElementById('addLinkModal'));
|
||||||
|
if (modal) {
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
// Simple toast notification
|
||||||
|
const toastHtml = `
|
||||||
|
<div class="toast align-items-center text-white bg-${type === 'success' ? 'success' : 'primary'} 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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!$('#toastContainer').length) {
|
||||||
|
$('body').append('<div id="toastContainer" class="toast-container position-fixed top-0 end-0 p-3"></div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const $toast = $(toastHtml);
|
||||||
|
$('#toastContainer').append($toast);
|
||||||
|
|
||||||
|
const toast = new bootstrap.Toast($toast[0]);
|
||||||
|
toast.show();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
$toast.remove();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
@ -2,11 +2,15 @@
|
|||||||
@{
|
@{
|
||||||
var theme = Model ?? new BCards.Web.Models.PageTheme
|
var theme = Model ?? new BCards.Web.Models.PageTheme
|
||||||
{
|
{
|
||||||
|
Name = "Padrão",
|
||||||
PrimaryColor = "#2563eb",
|
PrimaryColor = "#2563eb",
|
||||||
SecondaryColor = "#1d4ed8",
|
SecondaryColor = "#1d4ed8",
|
||||||
BackgroundColor = "#ffffff",
|
BackgroundColor = "#ffffff",
|
||||||
TextColor = "#1f2937"
|
TextColor = "#1f2937"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Debug - vamos ver o que está chegando
|
||||||
|
// Console.WriteLine($"Theme recebido: {Model?.Name ?? "NULL"} - Primary: {Model?.PrimaryColor ?? "NULL"}");
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@ -78,23 +82,25 @@
|
|||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
color: white !important;
|
color: white !important;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 1rem 2rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border-radius: 50px;
|
border-radius: 12px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-button:hover {
|
.link-button:hover {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
color: white !important;
|
color: white !important;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
@ -156,16 +162,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link-button {
|
.link-button {
|
||||||
padding: 0.875rem 1.5rem;
|
padding: 0.65rem 1.25rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-title {
|
.link-title {
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-description {
|
.link-description {
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,7 +193,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link-button {
|
.link-button {
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,3 +222,107 @@
|
|||||||
.link-button:hover::before {
|
.link-button:hover::before {
|
||||||
left: 100%;
|
left: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Product Link Card Styles */
|
||||||
|
.product-link-card {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||||
|
border-radius: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image {
|
||||||
|
height: 120px;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 15px 0 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-title {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-description {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-price {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #28a745 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link .card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for product cards */
|
||||||
|
@@media (max-width: 768px) {
|
||||||
|
.product-image {
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 15px 15px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-card .row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-card .col-md-4,
|
||||||
|
.product-link-card .col-md-8 {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-description {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-price {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 480px) {
|
||||||
|
.product-image {
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link .card-body {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-description {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,21 +8,12 @@
|
|||||||
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!isPreview)
|
|
||||||
{
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<style>
|
<style>
|
||||||
@Html.Raw(await Html.PartialAsync("_ThemeStyles", Model.Theme))
|
@Html.Raw(await Html.PartialAsync("_ThemeStyles", Model.Theme))
|
||||||
</style>
|
</style>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@section Styles {
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="user-page min-vh-100 d-flex align-items-center py-4">
|
<div class="user-page min-vh-100 d-flex align-items-center py-4">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@ -58,6 +49,57 @@ else
|
|||||||
var link = Model.Links[i];
|
var link = Model.Links[i];
|
||||||
if (link.IsActive)
|
if (link.IsActive)
|
||||||
{
|
{
|
||||||
|
@if (link.Type == BCards.Web.Models.LinkType.Product)
|
||||||
|
{
|
||||||
|
<!-- Card de Produto -->
|
||||||
|
<a href="@link.Url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-decoration-none mb-3 d-block product-link"
|
||||||
|
data-link-index="@i"
|
||||||
|
onclick="recordClick('@Model.Id', @i)">
|
||||||
|
<div class="card product-link-card">
|
||||||
|
<div class="row g-0">
|
||||||
|
@if (!string.IsNullOrEmpty(link.ProductImage))
|
||||||
|
{
|
||||||
|
<div class="col-md-4">
|
||||||
|
<img src="@link.ProductImage"
|
||||||
|
class="img-fluid product-image"
|
||||||
|
alt="@(link.ProductTitle ?? link.Title)"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="@(string.IsNullOrEmpty(link.ProductImage) ? "col-12" : "col-md-8")">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title product-title">
|
||||||
|
@(!string.IsNullOrEmpty(link.ProductTitle) ? link.ProductTitle : link.Title)
|
||||||
|
</h6>
|
||||||
|
@if (!string.IsNullOrEmpty(link.ProductDescription))
|
||||||
|
{
|
||||||
|
<p class="card-text small text-muted product-description">
|
||||||
|
@link.ProductDescription
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(link.ProductPrice))
|
||||||
|
{
|
||||||
|
<p class="card-text">
|
||||||
|
<strong class="text-success product-price">@link.ProductPrice</strong>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>
|
||||||
|
Ver produto
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<!-- Link Normal -->
|
||||||
<a href="@link.Url"
|
<a href="@link.Url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@ -79,6 +121,7 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user