feat/live-preview #8

Merged
ricardo merged 43 commits from feat/live-preview into main 2025-08-18 00:50:03 +00:00
20 changed files with 1404 additions and 98 deletions
Showing only changes of commit 27ae8b606e - Show all commits

View File

@ -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

View File

@ -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>

View File

@ -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
})); }));
} }

View File

@ -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");
} }

View 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);
}
}

View File

@ -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;

View File

@ -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; }
} }

View 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;
}

View File

@ -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; }
} }

View File

@ -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;
}
} }

View File

@ -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>();

View 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);
}

View File

@ -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();
} }

View 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);
}
}
}

View File

@ -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();

View 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;
}
}
}

View File

@ -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

View File

@ -355,41 +355,126 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="addLinkForm"> <form id="addLinkForm">
<!-- Tipo de Link -->
<div class="mb-3"> <div class="mb-3">
<label for="linkTitle" class="form-label">Título do Link</label> <label class="form-label">Tipo de Link</label>
<input type="text" class="form-control" id="linkTitle" placeholder="Ex: Meu Site, Portfólio, Instagram..." required> <div class="d-flex gap-2">
<div class="form-text">Nome que aparecerá no botão</div> <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> </div>
<div class="mb-3"> <!-- Seção para Link Normal -->
<label for="linkUrl" class="form-label">URL</label> <div id="normalLinkSection">
<input type="url" class="form-control" id="linkUrl" placeholder="https://exemplo.com" required> <div class="mb-3">
<div class="form-text">Link completo incluindo https://</div> <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>
<div class="form-text">Nome que aparecerá no botão</div>
</div>
<div class="mb-3">
<label for="linkUrl" class="form-label">URL</label>
<input type="url" class="form-control" id="linkUrl" placeholder="https://exemplo.com" required>
<div class="form-text">Link completo incluindo https://</div>
</div>
<div class="mb-3">
<label for="linkDescription" class="form-label">Descrição (opcional)</label>
<input type="text" class="form-control" id="linkDescription" placeholder="Breve descrição do link">
<div class="form-text">Texto adicional que aparece abaixo do título</div>
</div>
<div class="mb-3">
<label for="linkIcon" class="form-label">Ícone (opcional)</label>
<select class="form-select" id="linkIcon">
<option value="">Sem ícone</option>
<option value="fas fa-globe">🌐 Site</option>
<option value="fas fa-shopping-cart">🛒 Loja</option>
<option value="fas fa-briefcase">💼 Portfólio</option>
<option value="fas fa-envelope">✉️ Email</option>
<option value="fas fa-phone">📞 Telefone</option>
<option value="fas fa-map-marker-alt">📍 Localização</option>
<option value="fab fa-youtube">📺 YouTube</option>
<option value="fab fa-linkedin">💼 LinkedIn</option>
<option value="fab fa-github">💻 GitHub</option>
<option value="fas fa-download">⬇️ Download</option>
<option value="fas fa-calendar">📅 Agenda</option>
<option value="fas fa-heart">❤️ Favorito</option>
</select>
</div>
</div> </div>
<div class="mb-3"> <!-- Seção para Link de Produto -->
<label for="linkDescription" class="form-label">Descrição (opcional)</label> <div id="productLinkSection" style="display: none;">
<input type="text" class="form-control" id="linkDescription" placeholder="Breve descrição do link"> <div class="mb-3">
<div class="form-text">Texto adicional que aparece abaixo do título</div> <label for="productUrl" class="form-label">URL do Produto</label>
</div> <div class="input-group">
<input type="url" class="form-control" id="productUrl" placeholder="https://mercadolivre.com.br/produto...">
<div class="mb-3"> <button type="button" class="btn btn-outline-primary" id="extractProductBtn">
<label for="linkIcon" class="form-label">Ícone (opcional)</label> <i class="fas fa-magic"></i> Extrair Dados
<select class="form-select" id="linkIcon"> </button>
<option value="">Sem ícone</option> </div>
<option value="fas fa-globe">🌐 Site</option> <div class="form-text">
<option value="fas fa-shopping-cart">🛒 Loja</option> <small>
<option value="fas fa-briefcase">💼 Portfólio</option> <strong>Suportamos:</strong> Mercado Livre, Amazon, Magazine Luiza, Americanas, Shopee, e outros e-commerces conhecidos.
<option value="fas fa-envelope">✉️ Email</option> </small>
<option value="fas fa-phone">📞 Telefone</option> </div>
<option value="fas fa-map-marker-alt">📍 Localização</option> </div>
<option value="fab fa-youtube">📺 YouTube</option>
<option value="fab fa-linkedin">💼 LinkedIn</option> <div id="extractLoading" style="display: none;" class="text-center my-3">
<option value="fab fa-github">💻 GitHub</option> <div class="spinner-border text-primary" role="status">
<option value="fas fa-download">⬇️ Download</option> <span class="visually-hidden">Carregando...</span>
<option value="fas fa-calendar">📅 Agenda</option> </div>
<option value="fas fa-heart">❤️ Favorito</option> <p class="mt-2 text-muted">Extraindo informações do produto...</p>
</select> </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> </div>
</form> </form>
</div> </div>
@ -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();
}
});
// Extract product data
$('#extractProductBtn').on('click', function() {
const url = $('#productUrl').val().trim();
const title = $('#linkTitle').val().trim(); if (!url) {
const url = $('#linkUrl').val().trim(); alert('Por favor, insira a URL do produto.');
const description = $('#linkDescription').val().trim();
const icon = $('#linkIcon').val();
console.log('Values:', { title, url, description, icon });
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);
});
// Save link from modal
$(document).on('click', '#saveLinkBtn', function() {
const linkType = $('input[name="linkType"]:checked').val();
// Clear modal form if (linkType === 'Product') {
$('#addLinkForm')[0].reset(); saveProductLink();
} else {
// Close modal using Bootstrap 5 syntax saveNormalLink();
var modal = bootstrap.Modal.getInstance(document.getElementById('addLinkModal'));
if (modal) {
modal.hide();
} }
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>
} }

View File

@ -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;
} }
} }
@ -212,4 +221,108 @@
.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;
}
} }

View File

@ -8,20 +8,11 @@
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">
@ -58,24 +49,76 @@ else
var link = Model.Links[i]; var link = Model.Links[i];
if (link.IsActive) if (link.IsActive)
{ {
<a href="@link.Url" @if (link.Type == BCards.Web.Models.LinkType.Product)
target="_blank" {
rel="noopener noreferrer" <!-- Card de Produto -->
class="link-button" <a href="@link.Url"
data-link-index="@i" target="_blank"
onclick="recordClick('@Model.Id', @i)"> rel="noopener noreferrer"
@if (!string.IsNullOrEmpty(link.Icon)) class="text-decoration-none mb-3 d-block product-link"
{ data-link-index="@i"
<i class="@link.Icon"></i> onclick="recordClick('@Model.Id', @i)">
} <div class="card product-link-card">
<div> <div class="row g-0">
<div class="link-title">@link.Title</div> @if (!string.IsNullOrEmpty(link.ProductImage))
@if (!string.IsNullOrEmpty(link.Description)) {
<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"
target="_blank"
rel="noopener noreferrer"
class="link-button"
data-link-index="@i"
onclick="recordClick('@Model.Id', @i)">
@if (!string.IsNullOrEmpty(link.Icon))
{ {
<div class="link-description">@link.Description</div> <i class="@link.Icon"></i>
} }
</div> <div>
</a> <div class="link-title">@link.Title</div>
@if (!string.IsNullOrEmpty(link.Description))
{
<div class="link-description">@link.Description</div>
}
</div>
</a>
}
} }
} }
} }