Compare commits
No commits in common. "5b0bc44e20bf1c87df842f3606338e142ad3a789" and "70ba07bb644889f2429eb5309aeb5ef2fdf3e0bd" have entirely different histories.
5b0bc44e20
...
70ba07bb64
@ -15,9 +15,7 @@
|
|||||||
"Bash(rg:*)",
|
"Bash(rg:*)",
|
||||||
"Bash(pkill:*)",
|
"Bash(pkill:*)",
|
||||||
"Bash(sudo rm:*)",
|
"Bash(sudo rm:*)",
|
||||||
"Bash(rm:*)",
|
"Bash(rm:*)"
|
||||||
"Bash(curl:*)",
|
|
||||||
"Bash(docker-compose up:*)"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enableAllProjectMcpServers": false
|
"enableAllProjectMcpServers": false
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
using BCards.Web.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace BCards.Web.Attributes
|
|
||||||
{
|
|
||||||
public class ModeratorAuthorizeAttribute : Attribute, IAuthorizationFilter
|
|
||||||
{
|
|
||||||
public void OnAuthorization(AuthorizationFilterContext context)
|
|
||||||
{
|
|
||||||
var user = context.HttpContext.User;
|
|
||||||
|
|
||||||
if (!user.Identity?.IsAuthenticated == true)
|
|
||||||
{
|
|
||||||
context.Result = new RedirectToActionResult("Login", "Auth",
|
|
||||||
new { returnUrl = context.HttpContext.Request.Path });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var moderationAuth = context.HttpContext.RequestServices
|
|
||||||
.GetRequiredService<IModerationAuthService>();
|
|
||||||
|
|
||||||
if (!moderationAuth.IsUserModerator(user))
|
|
||||||
{
|
|
||||||
context.Result = new ForbidResult();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adicionar flag para views
|
|
||||||
context.HttpContext.Items["IsModerator"] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
@ -17,12 +17,14 @@
|
|||||||
<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" />
|
|
||||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="Resources\**\*.resx" />
|
<EmbeddedResource Include="Resources\**\*.resx" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Views\Payment\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@ -1,10 +0,0 @@
|
|||||||
namespace BCards.Web.Configuration
|
|
||||||
{
|
|
||||||
public class ModerationSettings
|
|
||||||
{
|
|
||||||
public Dictionary<string, TimeSpan> PriorityTimeframes { get; set; } = new();
|
|
||||||
public int MaxAttempts { get; set; } = 3;
|
|
||||||
public string ModeratorEmail { get; set; } = "";
|
|
||||||
public List<string> ModeratorEmails { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using BCards.Web.Utils;
|
|
||||||
using BCards.Web.ViewModels;
|
using BCards.Web.ViewModels;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -16,8 +15,6 @@ public class AdminController : Controller
|
|||||||
private readonly IUserPageService _userPageService;
|
private readonly IUserPageService _userPageService;
|
||||||
private readonly ICategoryService _categoryService;
|
private readonly ICategoryService _categoryService;
|
||||||
private readonly IThemeService _themeService;
|
private readonly IThemeService _themeService;
|
||||||
private readonly IModerationService _moderationService;
|
|
||||||
private readonly IEmailService _emailService;
|
|
||||||
private readonly ILogger<AdminController> _logger;
|
private readonly ILogger<AdminController> _logger;
|
||||||
|
|
||||||
public AdminController(
|
public AdminController(
|
||||||
@ -25,16 +22,12 @@ public class AdminController : Controller
|
|||||||
IUserPageService userPageService,
|
IUserPageService userPageService,
|
||||||
ICategoryService categoryService,
|
ICategoryService categoryService,
|
||||||
IThemeService themeService,
|
IThemeService themeService,
|
||||||
IModerationService moderationService,
|
|
||||||
IEmailService emailService,
|
|
||||||
ILogger<AdminController> logger)
|
ILogger<AdminController> logger)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
_userPageService = userPageService;
|
_userPageService = userPageService;
|
||||||
_categoryService = categoryService;
|
_categoryService = categoryService;
|
||||||
_themeService = themeService;
|
_themeService = themeService;
|
||||||
_moderationService = moderationService;
|
|
||||||
_emailService = emailService;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,10 +55,7 @@ public class AdminController : Controller
|
|||||||
Status = p.Status,
|
Status = p.Status,
|
||||||
TotalClicks = p.Analytics?.TotalClicks ?? 0,
|
TotalClicks = p.Analytics?.TotalClicks ?? 0,
|
||||||
TotalViews = p.Analytics?.TotalViews ?? 0,
|
TotalViews = p.Analytics?.TotalViews ?? 0,
|
||||||
PreviewToken = p.PreviewToken,
|
CreatedAt = p.CreatedAt
|
||||||
CreatedAt = p.CreatedAt,
|
|
||||||
LastModerationStatus = p.ModerationHistory == null || p.ModerationHistory.Last().Status != "rejected" ? null : Enum.Parse<PageStatus>(p.ModerationHistory.Last().Status, true),
|
|
||||||
Motive = p.ModerationHistory == null && p.ModerationHistory.Last().Status == "rejected" ? "" : p.ModerationHistory.Last().Reason
|
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
CurrentPlan = new PlanInfo
|
CurrentPlan = new PlanInfo
|
||||||
{
|
{
|
||||||
@ -195,29 +185,10 @@ public class AdminController : Controller
|
|||||||
var userPage = await MapToUserPage(model, user.Id);
|
var userPage = await MapToUserPage(model, user.Id);
|
||||||
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
|
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
|
||||||
|
|
||||||
// Set status to PendingModeration for new pages
|
|
||||||
userPage.Status = ViewModels.PageStatus.PendingModeration;
|
|
||||||
|
|
||||||
await _userPageService.CreatePageAsync(userPage);
|
await _userPageService.CreatePageAsync(userPage);
|
||||||
_logger.LogInformation("Page created successfully!");
|
_logger.LogInformation("Page created successfully!");
|
||||||
|
|
||||||
// Generate preview token and send for moderation
|
TempData["Success"] = "Página criada com sucesso!";
|
||||||
var previewToken = await _moderationService.GeneratePreviewTokenAsync(userPage.Id);
|
|
||||||
var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{userPage.Category}/{userPage.Slug}?preview={previewToken}";
|
|
||||||
userPage.PreviewToken = previewToken;
|
|
||||||
userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4);
|
|
||||||
await _userPageService.UpdatePageAsync(userPage);
|
|
||||||
|
|
||||||
// Send email to user
|
|
||||||
await _emailService.SendModerationStatusAsync(
|
|
||||||
user.Email,
|
|
||||||
user.Name,
|
|
||||||
userPage.DisplayName,
|
|
||||||
"pending",
|
|
||||||
null,
|
|
||||||
previewUrl);
|
|
||||||
|
|
||||||
TempData["Success"] = "Página enviada para moderação! Você receberá um email quando for aprovada.";
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -225,7 +196,6 @@ public class AdminController : Controller
|
|||||||
ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
|
ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
|
||||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||||
TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}";
|
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -236,36 +206,9 @@ public class AdminController : Controller
|
|||||||
if (existingPage == null || existingPage.UserId != user.Id)
|
if (existingPage == null || existingPage.UserId != user.Id)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
// Check if user can create pages (for users with rejected pages)
|
|
||||||
var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id);
|
|
||||||
if (!canCreatePage)
|
|
||||||
{
|
|
||||||
TempData["Error"] = "Você não pode editar páginas devido a muitas rejeições. Entre em contato com o suporte.";
|
|
||||||
return RedirectToAction("Dashboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateUserPageFromModel(existingPage, model);
|
UpdateUserPageFromModel(existingPage, model);
|
||||||
|
|
||||||
// Set status to PendingModeration for updates
|
|
||||||
existingPage.Status = ViewModels.PageStatus.PendingModeration;
|
|
||||||
existingPage.ModerationAttempts = existingPage.ModerationAttempts;
|
|
||||||
|
|
||||||
await _userPageService.UpdatePageAsync(existingPage);
|
await _userPageService.UpdatePageAsync(existingPage);
|
||||||
|
TempData["Success"] = "Página atualizada com sucesso!";
|
||||||
// Generate new preview token
|
|
||||||
var previewToken = await _moderationService.GeneratePreviewTokenAsync(existingPage.Id);
|
|
||||||
var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{existingPage.Category}/{existingPage.Slug}?preview={previewToken}";
|
|
||||||
|
|
||||||
// Send email to user
|
|
||||||
await _emailService.SendModerationStatusAsync(
|
|
||||||
user.Email,
|
|
||||||
user.Name,
|
|
||||||
existingPage.DisplayName,
|
|
||||||
"pending",
|
|
||||||
null,
|
|
||||||
previewUrl);
|
|
||||||
|
|
||||||
TempData["Success"] = "Página atualizada e enviada para moderação!";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return RedirectToAction("Dashboard");
|
return RedirectToAction("Dashboard");
|
||||||
@ -514,23 +457,19 @@ public class AdminController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("DeletePage/{id}")]
|
public async Task<IActionResult> DeletePage()
|
||||||
public async Task<IActionResult> DeletePage(string id)
|
|
||||||
{
|
{
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return RedirectToAction("Login", "Auth");
|
return RedirectToAction("Login", "Auth");
|
||||||
|
|
||||||
var userPage = await _userPageService.GetPageByIdAsync(id);
|
var userPage = await _userPageService.GetUserPageAsync(user.Id);
|
||||||
if (userPage == null || userPage.UserId != user.Id)
|
if (userPage != null)
|
||||||
{
|
{
|
||||||
TempData["Error"] = "Página não encontrada!";
|
await _userPageService.DeletePageAsync(userPage.Id);
|
||||||
return RedirectToAction("Dashboard");
|
TempData["Success"] = "Página excluída com sucesso!";
|
||||||
}
|
}
|
||||||
|
|
||||||
await _userPageService.DeletePageAsync(userPage.Id);
|
|
||||||
TempData["Success"] = "Página excluída com sucesso!";
|
|
||||||
|
|
||||||
return RedirectToAction("Dashboard");
|
return RedirectToAction("Dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -554,13 +493,7 @@ 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(),
|
||||||
@ -576,10 +509,10 @@ public class AdminController : Controller
|
|||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
DisplayName = model.DisplayName,
|
DisplayName = model.DisplayName,
|
||||||
Category = SlugHelper.ConvertCategory(model.Category.ToLower()),
|
Category = model.Category.ToLower(),
|
||||||
BusinessType = model.BusinessType,
|
BusinessType = model.BusinessType,
|
||||||
Bio = model.Bio,
|
Bio = model.Bio,
|
||||||
Slug = SlugHelper.CreateSlug(model.Slug.ToLower()),
|
Slug = model.Slug.ToLower(),
|
||||||
Theme = theme,
|
Theme = theme,
|
||||||
Status = ViewModels.PageStatus.Active,
|
Status = ViewModels.PageStatus.Active,
|
||||||
Links = new List<LinkItem>()
|
Links = new List<LinkItem>()
|
||||||
@ -596,13 +529,7 @@ 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
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -685,13 +612,7 @@ 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,26 +110,8 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,230 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using BCards.Web.Services;
|
|
||||||
using BCards.Web.Models;
|
|
||||||
using BCards.Web.ViewModels;
|
|
||||||
using BCards.Web.Repositories;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using BCards.Web.Attributes;
|
|
||||||
|
|
||||||
namespace BCards.Web.Controllers;
|
|
||||||
|
|
||||||
[ModeratorAuthorize]
|
|
||||||
[Route("Moderation")]
|
|
||||||
public class ModerationController : Controller
|
|
||||||
{
|
|
||||||
private readonly IModerationService _moderationService;
|
|
||||||
private readonly IEmailService _emailService;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
private readonly ILogger<ModerationController> _logger;
|
|
||||||
|
|
||||||
public ModerationController(
|
|
||||||
IModerationService moderationService,
|
|
||||||
IEmailService emailService,
|
|
||||||
IUserRepository userRepository,
|
|
||||||
ILogger<ModerationController> logger)
|
|
||||||
{
|
|
||||||
_moderationService = moderationService;
|
|
||||||
_emailService = emailService;
|
|
||||||
_userRepository = userRepository;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("Dashboard")]
|
|
||||||
public async Task<IActionResult> Dashboard(int page = 1, int size = 20)
|
|
||||||
{
|
|
||||||
var skip = (page - 1) * size;
|
|
||||||
var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size);
|
|
||||||
var stats = await _moderationService.GetModerationStatsAsync();
|
|
||||||
|
|
||||||
var viewModel = new ModerationDashboardViewModel
|
|
||||||
{
|
|
||||||
PendingPages = pendingPages.Select(p => new PendingPageViewModel
|
|
||||||
{
|
|
||||||
Id = p.Id,
|
|
||||||
DisplayName = p.DisplayName,
|
|
||||||
Category = p.Category,
|
|
||||||
Slug = p.Slug,
|
|
||||||
CreatedAt = p.CreatedAt,
|
|
||||||
ModerationAttempts = p.ModerationAttempts,
|
|
||||||
PlanType = p.PlanLimitations.PlanType.ToString(),
|
|
||||||
PreviewUrl = !string.IsNullOrEmpty(p.PreviewToken)
|
|
||||||
? $"/page/{p.Category}/{p.Slug}?preview={p.PreviewToken}"
|
|
||||||
: null
|
|
||||||
}).ToList(),
|
|
||||||
Stats = stats,
|
|
||||||
CurrentPage = page,
|
|
||||||
PageSize = size,
|
|
||||||
HasNextPage = pendingPages.Count == size
|
|
||||||
};
|
|
||||||
|
|
||||||
return View(viewModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("Review/{id}")]
|
|
||||||
public async Task<IActionResult> Review(string id)
|
|
||||||
{
|
|
||||||
var page = await _moderationService.GetPageForModerationAsync(id);
|
|
||||||
if (page == null)
|
|
||||||
{
|
|
||||||
TempData["Error"] = "Página não encontrada ou não está pendente de moderação.";
|
|
||||||
return RedirectToAction("Dashboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userRepository.GetByIdAsync(page.UserId);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
TempData["Error"] = "Usuário não encontrado.";
|
|
||||||
return RedirectToAction("Dashboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
var viewModel = new ModerationReviewViewModel
|
|
||||||
{
|
|
||||||
Page = page,
|
|
||||||
User = user,
|
|
||||||
PreviewUrl = !string.IsNullOrEmpty(page.PreviewToken)
|
|
||||||
? $"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"
|
|
||||||
: null,
|
|
||||||
ModerationCriteria = GetModerationCriteria()
|
|
||||||
};
|
|
||||||
|
|
||||||
return View(viewModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("Approve/{id}")]
|
|
||||||
[ValidateAntiForgeryToken]
|
|
||||||
public async Task<IActionResult> Approve(string id, string notes)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var page = await _moderationService.GetPageForModerationAsync(id);
|
|
||||||
if (page == null)
|
|
||||||
{
|
|
||||||
TempData["Error"] = "Página não encontrada.";
|
|
||||||
return RedirectToAction("Dashboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userRepository.GetByIdAsync(page.UserId);
|
|
||||||
var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
|
|
||||||
|
|
||||||
await _moderationService.ApprovePageAsync(id, moderatorId, notes);
|
|
||||||
|
|
||||||
if (user != null)
|
|
||||||
{
|
|
||||||
await _emailService.SendModerationStatusAsync(
|
|
||||||
user.Email,
|
|
||||||
user.Name,
|
|
||||||
page.DisplayName,
|
|
||||||
"approved");
|
|
||||||
}
|
|
||||||
|
|
||||||
TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!";
|
|
||||||
return RedirectToAction("Dashboard");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error approving page {PageId}", id);
|
|
||||||
TempData["Error"] = "Erro ao aprovar página.";
|
|
||||||
return RedirectToAction("Review", new { id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("Reject/{id}")]
|
|
||||||
[ValidateAntiForgeryToken]
|
|
||||||
public async Task<IActionResult> Reject(string id, string reason, List<string> issues)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var page = await _moderationService.GetPageForModerationAsync(id);
|
|
||||||
if (page == null)
|
|
||||||
{
|
|
||||||
TempData["Error"] = "Página não encontrada.";
|
|
||||||
return RedirectToAction("Dashboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userRepository.GetByIdAsync(page.UserId);
|
|
||||||
var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
|
|
||||||
|
|
||||||
await _moderationService.RejectPageAsync(id, moderatorId, reason, issues);
|
|
||||||
|
|
||||||
if (user != null)
|
|
||||||
{
|
|
||||||
await _emailService.SendModerationStatusAsync(
|
|
||||||
user.Email,
|
|
||||||
user.Name,
|
|
||||||
page.DisplayName,
|
|
||||||
"rejected",
|
|
||||||
reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
TempData["Success"] = $"Página '{page.DisplayName}' rejeitada.";
|
|
||||||
return RedirectToAction("Dashboard");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error rejecting page {PageId}", id);
|
|
||||||
TempData["Error"] = "Erro ao rejeitar página.";
|
|
||||||
return RedirectToAction("Review", new { id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("History")]
|
|
||||||
public async Task<IActionResult> History(int page = 1, int size = 20)
|
|
||||||
{
|
|
||||||
var skip = (page - 1) * size;
|
|
||||||
var historyPages = await _moderationService.GetModerationHistoryAsync(skip, size);
|
|
||||||
|
|
||||||
var viewModel = new ModerationHistoryViewModel
|
|
||||||
{
|
|
||||||
Pages = historyPages.Select(p => new ModerationPageViewModel
|
|
||||||
{
|
|
||||||
Id = p.Id,
|
|
||||||
DisplayName = p.DisplayName,
|
|
||||||
Category = p.Category,
|
|
||||||
Slug = p.Slug,
|
|
||||||
CreatedAt = p.CreatedAt,
|
|
||||||
Status = p.Status.ToString(),
|
|
||||||
ModerationAttempts = p.ModerationAttempts,
|
|
||||||
PlanType = p.PlanLimitations.PlanType.ToString(),
|
|
||||||
ApprovedAt = p.ApprovedAt,
|
|
||||||
LastModerationEntry = p.ModerationHistory.LastOrDefault()
|
|
||||||
}).ToList(),
|
|
||||||
CurrentPage = page,
|
|
||||||
PageSize = size,
|
|
||||||
HasNextPage = historyPages.Count == size
|
|
||||||
};
|
|
||||||
|
|
||||||
return View(viewModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<ModerationCriterion> GetModerationCriteria()
|
|
||||||
{
|
|
||||||
return new List<ModerationCriterion>
|
|
||||||
{
|
|
||||||
new() { Category = "Conteúdo Proibido", Items = new List<string>
|
|
||||||
{
|
|
||||||
"Pornografia e conteúdo sexual explícito",
|
|
||||||
"Drogas ilegais e substâncias controladas",
|
|
||||||
"Armas e explosivos",
|
|
||||||
"Atividades ilegais (fraudes, pirataria)",
|
|
||||||
"Apostas e jogos de azar",
|
|
||||||
"Criptomoedas e esquemas de pirâmide",
|
|
||||||
"Conteúdo que promove violência ou ódio",
|
|
||||||
"Spam e links suspeitos/maliciosos"
|
|
||||||
}},
|
|
||||||
new() { Category = "Conteúdo Suspeito", Items = new List<string>
|
|
||||||
{
|
|
||||||
"Excesso de anúncios (>30% dos links)",
|
|
||||||
"Sites com pop-ups excessivos",
|
|
||||||
"Links encurtados suspeitos",
|
|
||||||
"Conteúdo que imita marcas conhecidas",
|
|
||||||
"Produtos \"milagrosos\""
|
|
||||||
}},
|
|
||||||
new() { Category = "Verificações Técnicas", Items = new List<string>
|
|
||||||
{
|
|
||||||
"Links funcionais (não quebrados)",
|
|
||||||
"Sites com SSL válido",
|
|
||||||
"Não redirecionamentos maliciosos"
|
|
||||||
}}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -16,6 +16,11 @@ public class PaymentController : Controller
|
|||||||
_authService = authService;
|
_authService = authService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IActionResult Plans()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> CreateCheckoutSession(string planType)
|
public async Task<IActionResult> CreateCheckoutSession(string planType)
|
||||||
{
|
{
|
||||||
@ -39,7 +44,7 @@ public class PaymentController : Controller
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
|
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
|
||||||
return RedirectToAction("Pricing", "Home");
|
return RedirectToAction("Plans");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +57,7 @@ public class PaymentController : Controller
|
|||||||
public IActionResult Cancel()
|
public IActionResult Cancel()
|
||||||
{
|
{
|
||||||
TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser.";
|
TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser.";
|
||||||
return RedirectToAction("Pricing", "Home");
|
return RedirectToAction("Plans");
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|||||||
@ -1,131 +0,0 @@
|
|||||||
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,88 +9,36 @@ 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;
|
|
||||||
private readonly IModerationService _moderationService;
|
|
||||||
|
|
||||||
public UserPageController(
|
public UserPageController(
|
||||||
IUserPageService userPageService,
|
IUserPageService userPageService,
|
||||||
ICategoryService categoryService,
|
ICategoryService categoryService,
|
||||||
ISeoService seoService,
|
ISeoService seoService)
|
||||||
IThemeService themeService,
|
|
||||||
IModerationService moderationService)
|
|
||||||
{
|
{
|
||||||
_userPageService = userPageService;
|
_userPageService = userPageService;
|
||||||
_categoryService = categoryService;
|
_categoryService = categoryService;
|
||||||
_seoService = seoService;
|
_seoService = seoService;
|
||||||
_themeService = themeService;
|
|
||||||
_moderationService = moderationService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//[Route("{category}/{slug}")]
|
//[Route("{category}/{slug}")]
|
||||||
//VOltar a linha abaixo em prod
|
[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })]
|
||||||
//[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })]
|
|
||||||
public async Task<IActionResult> Display(string category, string slug)
|
public async Task<IActionResult> Display(string category, string slug)
|
||||||
{
|
{
|
||||||
var userPage = await _userPageService.GetPageAsync(category, slug);
|
var userPage = await _userPageService.GetPageAsync(category, slug);
|
||||||
if (userPage == null)
|
if (userPage == null || !userPage.IsActive)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
||||||
if (categoryObj == null)
|
if (categoryObj == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
// Check if it's a preview request
|
|
||||||
var isPreview = HttpContext.Items.ContainsKey("IsPreview") && (bool)HttpContext.Items["IsPreview"];
|
|
||||||
var previewToken = Request.Query["preview"].FirstOrDefault();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(previewToken))
|
|
||||||
{
|
|
||||||
// Handle preview request
|
|
||||||
var isValidPreview = await _moderationService.ValidatePreviewTokenAsync(userPage.Id, previewToken);
|
|
||||||
if (!isValidPreview)
|
|
||||||
{
|
|
||||||
return View("PreviewExpired");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set preview flag
|
|
||||||
ViewBag.IsPreview = true;
|
|
||||||
ViewBag.PreviewToken = previewToken;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Regular request - check if page is active
|
|
||||||
if (userPage.Status == ViewModels.PageStatus.PendingModeration)
|
|
||||||
{
|
|
||||||
return View("PendingModeration");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userPage.Status == ViewModels.PageStatus.Rejected)
|
|
||||||
{
|
|
||||||
return View("PageRejected");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userPage.Status == ViewModels.PageStatus.Inactive || !userPage.IsActive)
|
|
||||||
{
|
|
||||||
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);
|
||||||
|
|
||||||
// Record page view (async, don't wait) - only for non-preview requests
|
// Record page view (async, don't wait)
|
||||||
if (!isPreview)
|
var referrer = Request.Headers["Referer"].FirstOrDefault();
|
||||||
{
|
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
|
||||||
var referrer = Request.Headers["Referer"].FirstOrDefault();
|
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
|
||||||
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
|
|
||||||
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
|
|
||||||
}
|
|
||||||
|
|
||||||
ViewBag.SeoSettings = seoSettings;
|
ViewBag.SeoSettings = seoSettings;
|
||||||
ViewBag.Category = categoryObj;
|
ViewBag.Category = categoryObj;
|
||||||
@ -117,12 +65,6 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
using BCards.Web.Services;
|
|
||||||
|
|
||||||
namespace BCards.Web.Middleware
|
|
||||||
{
|
|
||||||
public class ModerationAuthMiddleware
|
|
||||||
{
|
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
private readonly IModerationAuthService _moderationAuth;
|
|
||||||
|
|
||||||
public ModerationAuthMiddleware(RequestDelegate next, IModerationAuthService moderationAuth)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
_moderationAuth = moderationAuth;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
|
||||||
{
|
|
||||||
var path = context.Request.Path.Value?.ToLowerInvariant();
|
|
||||||
|
|
||||||
// Verificar se é uma rota de moderação
|
|
||||||
if (path != null && path.StartsWith("/moderation"))
|
|
||||||
{
|
|
||||||
// Verificar se usuário está autenticado
|
|
||||||
if (!context.User.Identity?.IsAuthenticated == true)
|
|
||||||
{
|
|
||||||
context.Response.Redirect("/Auth/Login?returnUrl=" + Uri.EscapeDataString(context.Request.Path));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se é moderador
|
|
||||||
if (!_moderationAuth.IsUserModerator(context.User))
|
|
||||||
{
|
|
||||||
context.Response.StatusCode = 403;
|
|
||||||
await context.Response.WriteAsync("Acesso negado. Você não tem permissão para acessar esta área.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adicionar flag para usar nas views
|
|
||||||
context.Items["IsModerator"] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _next(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
using BCards.Web.Services;
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
|
|
||||||
namespace BCards.Web.Middleware;
|
|
||||||
|
|
||||||
public class PreviewTokenMiddleware
|
|
||||||
{
|
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
private readonly IMemoryCache _cache;
|
|
||||||
private readonly ILogger<PreviewTokenMiddleware> _logger;
|
|
||||||
|
|
||||||
public PreviewTokenMiddleware(RequestDelegate next, IMemoryCache cache, ILogger<PreviewTokenMiddleware> logger)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
_cache = cache;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
|
||||||
{
|
|
||||||
var path = context.Request.Path.Value;
|
|
||||||
var query = context.Request.Query;
|
|
||||||
|
|
||||||
// Verificar se é uma requisição de preview
|
|
||||||
if (path != null && path.StartsWith("/page/") && query.ContainsKey("preview"))
|
|
||||||
{
|
|
||||||
var previewToken = query["preview"].FirstOrDefault();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(previewToken))
|
|
||||||
{
|
|
||||||
var result = await HandlePreviewRequest(context, previewToken);
|
|
||||||
if (!result)
|
|
||||||
{
|
|
||||||
context.Response.StatusCode = 404;
|
|
||||||
await context.Response.WriteAsync("Preview não encontrado ou expirado.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _next(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> HandlePreviewRequest(HttpContext context, string previewToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Verificar rate limiting por IP
|
|
||||||
var clientIp = GetClientIpAddress(context);
|
|
||||||
var rateLimitKey = $"preview_rate_limit_{clientIp}";
|
|
||||||
|
|
||||||
if (_cache.TryGetValue(rateLimitKey, out int requestCount))
|
|
||||||
{
|
|
||||||
if (requestCount >= 10) // Máximo 10 requisições por minuto por IP
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Rate limit exceeded for IP {IP} on preview token {Token}", clientIp, previewToken);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
_cache.Set(rateLimitKey, requestCount + 1, TimeSpan.FromMinutes(1));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_cache.Set(rateLimitKey, 1, TimeSpan.FromMinutes(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se o token é válido
|
|
||||||
var moderationService = context.RequestServices.GetService<IModerationService>();
|
|
||||||
if (moderationService == null)
|
|
||||||
{
|
|
||||||
_logger.LogError("ModerationService not found in DI container");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var page = await moderationService.GetPageByPreviewTokenAsync(previewToken);
|
|
||||||
if (page == null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Invalid or expired preview token: {Token}", previewToken);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incrementar contador de visualizações
|
|
||||||
var incrementResult = await moderationService.IncrementPreviewViewAsync(page.Id);
|
|
||||||
if (!incrementResult)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Preview view limit exceeded for page {PageId}", page.Id);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adicionar informações do preview ao contexto
|
|
||||||
context.Items["IsPreview"] = true;
|
|
||||||
context.Items["PreviewPageId"] = page.Id;
|
|
||||||
context.Items["PreviewToken"] = previewToken;
|
|
||||||
|
|
||||||
_logger.LogInformation("Valid preview request for page {PageId} with token {Token}", page.Id, previewToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error handling preview request with token {Token}", previewToken);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetClientIpAddress(HttpContext context)
|
|
||||||
{
|
|
||||||
// Verificar cabeçalhos de proxy
|
|
||||||
var xForwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
|
||||||
if (!string.IsNullOrEmpty(xForwardedFor))
|
|
||||||
{
|
|
||||||
return xForwardedFor.Split(',')[0].Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
var xRealIp = context.Request.Headers["X-Real-IP"].FirstOrDefault();
|
|
||||||
if (!string.IsNullOrEmpty(xRealIp))
|
|
||||||
{
|
|
||||||
return xRealIp;
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,12 +2,6 @@ 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")]
|
||||||
@ -33,23 +27,4 @@ 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; }
|
|
||||||
}
|
}
|
||||||
@ -1,25 +0,0 @@
|
|||||||
using MongoDB.Bson;
|
|
||||||
using MongoDB.Bson.Serialization.Attributes;
|
|
||||||
|
|
||||||
namespace BCards.Web.Models;
|
|
||||||
|
|
||||||
public class ModerationHistory
|
|
||||||
{
|
|
||||||
[BsonElement("attempt")]
|
|
||||||
public int Attempt { get; set; }
|
|
||||||
|
|
||||||
[BsonElement("status")]
|
|
||||||
public string Status { get; set; } = "pending"; // "pending", "approved", "rejected"
|
|
||||||
|
|
||||||
[BsonElement("reason")]
|
|
||||||
public string? Reason { get; set; }
|
|
||||||
|
|
||||||
[BsonElement("moderatorId")]
|
|
||||||
public string? ModeratorId { get; set; }
|
|
||||||
|
|
||||||
[BsonElement("date")]
|
|
||||||
public DateTime Date { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[BsonElement("issues")]
|
|
||||||
public List<string> Issues { get; set; } = new();
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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,20 +24,4 @@ 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,33 +91,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -65,26 +65,5 @@ public class UserPage
|
|||||||
[BsonElement("status")]
|
[BsonElement("status")]
|
||||||
public PageStatus Status { get; set; } = PageStatus.Active;
|
public PageStatus Status { get; set; } = PageStatus.Active;
|
||||||
|
|
||||||
[BsonElement("previewToken")]
|
|
||||||
public string? PreviewToken { get; set; }
|
|
||||||
|
|
||||||
[BsonElement("previewTokenExpiry")]
|
|
||||||
public DateTime? PreviewTokenExpiry { get; set; }
|
|
||||||
|
|
||||||
[BsonElement("moderationAttempts")]
|
|
||||||
public int ModerationAttempts { get; set; } = 0;
|
|
||||||
|
|
||||||
[BsonElement("moderationHistory")]
|
|
||||||
public List<ModerationHistory> ModerationHistory { get; set; } = new();
|
|
||||||
|
|
||||||
[BsonElement("approvedAt")]
|
|
||||||
public DateTime? ApprovedAt { get; set; }
|
|
||||||
|
|
||||||
[BsonElement("userScore")]
|
|
||||||
public int UserScore { get; set; } = 100;
|
|
||||||
|
|
||||||
[BsonElement("previewViewCount")]
|
|
||||||
public int PreviewViewCount { get; set; } = 0;
|
|
||||||
|
|
||||||
public string FullUrl => $"page/{Category}/{Slug}";
|
public string FullUrl => $"page/{Category}/{Slug}";
|
||||||
}
|
}
|
||||||
@ -8,10 +8,6 @@ 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;
|
|
||||||
using SendGrid;
|
|
||||||
using BCards.Web.Middleware;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@ -49,10 +45,6 @@ builder.Services.Configure<GoogleAuthSettings>(
|
|||||||
builder.Services.Configure<MicrosoftAuthSettings>(
|
builder.Services.Configure<MicrosoftAuthSettings>(
|
||||||
builder.Configuration.GetSection("Authentication:Microsoft"));
|
builder.Configuration.GetSection("Authentication:Microsoft"));
|
||||||
|
|
||||||
// Adicionar configurações
|
|
||||||
builder.Services.Configure<ModerationSettings>(
|
|
||||||
builder.Configuration.GetSection("Moderation"));
|
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
@ -77,19 +69,6 @@ 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
|
||||||
@ -113,8 +92,6 @@ builder.Services.AddScoped<IUserRepository, UserRepository>();
|
|||||||
builder.Services.AddScoped<IUserPageRepository, UserPageRepository>();
|
builder.Services.AddScoped<IUserPageRepository, UserPageRepository>();
|
||||||
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||||
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
||||||
builder.Services.AddSingleton<IModerationAuthService, ModerationAuthService>();
|
|
||||||
//builder.Services.AddScoped<IModerationAuthService, ModerationAuthService>();
|
|
||||||
|
|
||||||
builder.Services.AddScoped<IUserPageService, UserPageService>();
|
builder.Services.AddScoped<IUserPageService, UserPageService>();
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
@ -122,19 +99,6 @@ 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>();
|
|
||||||
builder.Services.AddScoped<IModerationService, ModerationService>();
|
|
||||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
|
||||||
|
|
||||||
// Add HttpClient for OpenGraphService
|
|
||||||
builder.Services.AddHttpClient<OpenGraphService>();
|
|
||||||
|
|
||||||
// Add SendGrid
|
|
||||||
builder.Services.AddSingleton<ISendGridClient>(provider =>
|
|
||||||
{
|
|
||||||
var apiKey = builder.Configuration["SendGrid:ApiKey"];
|
|
||||||
return new SendGridClient(apiKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Background Services
|
// Background Services
|
||||||
builder.Services.AddHostedService<TrialExpirationService>();
|
builder.Services.AddHostedService<TrialExpirationService>();
|
||||||
@ -167,60 +131,45 @@ app.UseAuthorization();
|
|||||||
// Add custom middleware
|
// Add custom middleware
|
||||||
app.UseMiddleware<BCards.Web.Middleware.PlanLimitationMiddleware>();
|
app.UseMiddleware<BCards.Web.Middleware.PlanLimitationMiddleware>();
|
||||||
app.UseMiddleware<BCards.Web.Middleware.PageStatusMiddleware>();
|
app.UseMiddleware<BCards.Web.Middleware.PageStatusMiddleware>();
|
||||||
app.UseMiddleware<BCards.Web.Middleware.PreviewTokenMiddleware>();
|
|
||||||
app.UseMiddleware<ModerationAuthMiddleware>();
|
|
||||||
|
|
||||||
app.Use(async (context, next) =>
|
|
||||||
{
|
|
||||||
Console.WriteLine($"=== REQUEST DEBUG ===");
|
|
||||||
Console.WriteLine($"Path: {context.Request.Path}");
|
|
||||||
Console.WriteLine($"Query: {context.Request.QueryString}");
|
|
||||||
Console.WriteLine($"Method: {context.Request.Method}");
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.UseResponseCaching();
|
app.UseResponseCaching();
|
||||||
|
|
||||||
// Rotas específicas primeiro
|
// Rota padr<64>o primeiro (mais espec<65>fica)
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "userpage-preview-path",
|
name: "default",
|
||||||
pattern: "page/preview/{category}/{slug}",
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||||
defaults: new { controller = "UserPage", action = "Preview" },
|
|
||||||
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
|
||||||
|
|
||||||
app.MapControllerRoute(
|
//Rota customizada depois (mais gen<65>rica)
|
||||||
name: "userpage-click",
|
|
||||||
pattern: "page/click/{pageId}",
|
|
||||||
defaults: new { controller = "UserPage", action = "RecordClick" });
|
|
||||||
|
|
||||||
app.MapControllerRoute(
|
|
||||||
name: "moderation",
|
|
||||||
pattern: "moderation/{action=Dashboard}/{id?}",
|
|
||||||
defaults: new { controller = "Moderation" });
|
|
||||||
|
|
||||||
// Rota principal que vai pegar ?preview=token
|
|
||||||
//app.MapControllerRoute(
|
//app.MapControllerRoute(
|
||||||
// name: "userpage",
|
// name: "userpage",
|
||||||
// pattern: "page/{category}/{slug}",
|
// pattern: "page/{category}/{slug}",
|
||||||
// defaults: new { controller = "UserPage", action = "Display" },
|
// defaults: new { controller = "UserPage", action = "Display" },
|
||||||
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||||
|
// Rota para preview
|
||||||
|
app.MapControllerRoute(
|
||||||
|
name: "userpage-preview",
|
||||||
|
pattern: "page/preview/{category}/{slug}",
|
||||||
|
defaults: new { controller = "UserPage", action = "Preview" },
|
||||||
|
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||||
|
|
||||||
|
// Rota para click
|
||||||
|
app.MapControllerRoute(
|
||||||
|
name: "userpage-click",
|
||||||
|
pattern: "page/click/{pageId}",
|
||||||
|
defaults: new { controller = "UserPage", action = "RecordClick" });
|
||||||
|
|
||||||
|
// Rota principal (deve vir por último)
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "userpage",
|
name: "userpage",
|
||||||
pattern: "page/{category}/{slug}",
|
pattern: "page/{category}/{slug}",
|
||||||
defaults: new { controller = "UserPage", action = "Display" },
|
defaults: new { controller = "UserPage", action = "Display" },
|
||||||
constraints: new
|
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||||
{
|
|
||||||
category = @"^[a-zA-Z0-9\-\u00C0-\u017F]+$", // ← Aceita acentos
|
|
||||||
slug = @"^[a-z0-9-]+$"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rota padrão por último
|
// Rota padrão
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "default",
|
name: "default",
|
||||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||||
|
|
||||||
|
|
||||||
// Initialize default data
|
// Initialize default data
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using MongoDB.Driver;
|
|
||||||
|
|
||||||
namespace BCards.Web.Repositories;
|
namespace BCards.Web.Repositories;
|
||||||
|
|
||||||
@ -17,15 +16,4 @@ public interface IUserPageRepository
|
|||||||
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
|
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
|
||||||
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
|
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
|
||||||
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
|
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
|
||||||
Task<List<UserPage>> GetManyAsync(
|
|
||||||
FilterDefinition<UserPage> filter,
|
|
||||||
SortDefinition<UserPage>? sort = null,
|
|
||||||
int skip = 0,
|
|
||||||
int take = 20);
|
|
||||||
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20);
|
|
||||||
Task<long> CountAsync(FilterDefinition<UserPage> filter);
|
|
||||||
Task<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update);
|
|
||||||
Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update);
|
|
||||||
Task<bool> ApprovePageAsync(string pageId);
|
|
||||||
Task<bool> RejectPageAsync(string pageId, string reason, List<string> issues);
|
|
||||||
}
|
}
|
||||||
@ -116,105 +116,4 @@ public class UserPageRepository : IUserPageRepository
|
|||||||
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adicione estes métodos no UserPageRepository.cs
|
|
||||||
|
|
||||||
public async Task<List<UserPage>> GetManyAsync(
|
|
||||||
FilterDefinition<UserPage> filter,
|
|
||||||
SortDefinition<UserPage>? sort = null,
|
|
||||||
int skip = 0,
|
|
||||||
int take = 20)
|
|
||||||
{
|
|
||||||
var query = _pages.Find(filter);
|
|
||||||
|
|
||||||
if (sort != null)
|
|
||||||
query = query.Sort(sort);
|
|
||||||
|
|
||||||
return await query.Skip(skip).Limit(take).ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<long> CountAsync(FilterDefinition<UserPage> filter)
|
|
||||||
{
|
|
||||||
return await _pages.CountDocumentsAsync(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Método específico para moderação (mais simples)
|
|
||||||
public async Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20)
|
|
||||||
{
|
|
||||||
var filter = Builders<UserPage>.Filter.Eq(x => x.Status, BCards.Web.ViewModels.PageStatus.PendingModeration);
|
|
||||||
|
|
||||||
var sort = Builders<UserPage>.Sort
|
|
||||||
.Ascending("planLimitations.planType") // Premium primeiro
|
|
||||||
.Ascending(x => x.CreatedAt); // Mais antigos primeiro
|
|
||||||
|
|
||||||
return await _pages.Find(filter)
|
|
||||||
.Sort(sort)
|
|
||||||
.Skip(skip)
|
|
||||||
.Limit(take)
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adicione estes métodos no UserPageRepository.cs
|
|
||||||
|
|
||||||
public async Task<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update)
|
|
||||||
{
|
|
||||||
var combinedUpdate = Builders<UserPage>.Update
|
|
||||||
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
|
|
||||||
|
|
||||||
return await _pages.UpdateOneAsync(x => x.Id == id, combinedUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update)
|
|
||||||
{
|
|
||||||
var combinedUpdate = Builders<UserPage>.Update
|
|
||||||
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
|
|
||||||
|
|
||||||
return await _pages.UpdateManyAsync(filter, combinedUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Métodos específicos para moderação (mais fáceis de usar)
|
|
||||||
public async Task<bool> ApprovePageAsync(string pageId)
|
|
||||||
{
|
|
||||||
var update = Builders<UserPage>.Update
|
|
||||||
.Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Active)
|
|
||||||
.Set(x => x.ApprovedAt, DateTime.UtcNow)
|
|
||||||
.Set(x => x.PublishedAt, DateTime.UtcNow)
|
|
||||||
.Unset(x => x.PreviewToken)
|
|
||||||
.Unset(x => x.PreviewTokenExpiry)
|
|
||||||
.Set(x => x.UpdatedAt, DateTime.UtcNow);
|
|
||||||
|
|
||||||
var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update);
|
|
||||||
return result.ModifiedCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> RejectPageAsync(string pageId, string reason, List<string> issues)
|
|
||||||
{
|
|
||||||
var page = await GetByIdAsync(pageId);
|
|
||||||
if (page == null) return false;
|
|
||||||
|
|
||||||
// Adicionar à história de moderação
|
|
||||||
var historyEntry = new ModerationHistory
|
|
||||||
{
|
|
||||||
Attempt = page.ModerationAttempts + 1,
|
|
||||||
Status = "rejected",
|
|
||||||
Reason = reason,
|
|
||||||
Date = DateTime.UtcNow,
|
|
||||||
Issues = issues
|
|
||||||
};
|
|
||||||
|
|
||||||
page.ModerationHistory.Add(historyEntry);
|
|
||||||
|
|
||||||
var update = Builders<UserPage>.Update
|
|
||||||
.Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Rejected)
|
|
||||||
.Set(x => x.ModerationAttempts, page.ModerationAttempts + 1)
|
|
||||||
.Set(x => x.ModerationHistory, page.ModerationHistory)
|
|
||||||
.Unset(x => x.PreviewToken)
|
|
||||||
.Unset(x => x.PreviewTokenExpiry)
|
|
||||||
.Set(x => x.UpdatedAt, DateTime.UtcNow);
|
|
||||||
|
|
||||||
var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update);
|
|
||||||
return result.ModifiedCount > 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ using BCards.Web.Repositories;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using BCards.Web.Utils;
|
|
||||||
|
|
||||||
namespace BCards.Web.Services;
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
@ -23,7 +22,7 @@ public class CategoryService : ICategoryService
|
|||||||
|
|
||||||
public async Task<Category?> GetCategoryBySlugAsync(string slug)
|
public async Task<Category?> GetCategoryBySlugAsync(string slug)
|
||||||
{
|
{
|
||||||
return await _categoryRepository.GetBySlugAsync(SlugHelper.CreateSlug(slug));
|
return await _categoryRepository.GetBySlugAsync(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GenerateSlugAsync(string name)
|
public async Task<string> GenerateSlugAsync(string name)
|
||||||
|
|||||||
@ -1,207 +0,0 @@
|
|||||||
using SendGrid;
|
|
||||||
using SendGrid.Helpers.Mail;
|
|
||||||
|
|
||||||
namespace BCards.Web.Services;
|
|
||||||
|
|
||||||
public class EmailService : IEmailService
|
|
||||||
{
|
|
||||||
private readonly ISendGridClient _sendGridClient;
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly ILogger<EmailService> _logger;
|
|
||||||
|
|
||||||
public EmailService(
|
|
||||||
ISendGridClient sendGridClient,
|
|
||||||
IConfiguration configuration,
|
|
||||||
ILogger<EmailService> logger)
|
|
||||||
{
|
|
||||||
_sendGridClient = sendGridClient;
|
|
||||||
_configuration = configuration;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null)
|
|
||||||
{
|
|
||||||
var (subject, htmlContent) = status switch
|
|
||||||
{
|
|
||||||
"pending" => GetPendingModerationTemplate(userName, pageTitle, previewUrl),
|
|
||||||
"approved" => GetApprovedTemplate(userName, pageTitle),
|
|
||||||
"rejected" => GetRejectedTemplate(userName, pageTitle, reason),
|
|
||||||
_ => throw new ArgumentException($"Unknown status: {status}")
|
|
||||||
};
|
|
||||||
|
|
||||||
await SendEmailAsync(userEmail, subject, htmlContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName)
|
|
||||||
{
|
|
||||||
var moderatorEmail = _configuration["Moderation:ModeratorEmail"];
|
|
||||||
if (string.IsNullOrEmpty(moderatorEmail))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var priority = GetPriorityLabel(planType);
|
|
||||||
var subject = $"[{priority}] Nova página para moderação - {pageTitle}";
|
|
||||||
|
|
||||||
var htmlContent = $@"
|
|
||||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
|
||||||
<h2 style='color: #333;'>Nova página para moderação</h2>
|
|
||||||
<div style='background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
|
||||||
<p><strong>Título:</strong> {pageTitle}</p>
|
|
||||||
<p><strong>Usuário:</strong> {userName}</p>
|
|
||||||
<p><strong>Plano:</strong> {planType}</p>
|
|
||||||
<p><strong>Prioridade:</strong> <span style='color: {GetPriorityColor(planType)};'>{priority}</span></p>
|
|
||||||
<p><strong>ID da Página:</strong> {pageId}</p>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<a href='{_configuration["BaseUrl"]}/moderation/review/{pageId}'
|
|
||||||
style='background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;'>
|
|
||||||
Moderar Página
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>";
|
|
||||||
|
|
||||||
await SendEmailAsync(moderatorEmail, subject, htmlContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> SendEmailAsync(string to, string subject, string htmlContent)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var from = new EmailAddress(
|
|
||||||
_configuration["SendGrid:FromEmail"] ?? "ricardo.carneiro@jobmaker.com.br",
|
|
||||||
_configuration["SendGrid:FromName"] ?? "BCards");
|
|
||||||
|
|
||||||
var toEmail = new EmailAddress(to);
|
|
||||||
var msg = MailHelper.CreateSingleEmail(from, toEmail, subject, null, htmlContent);
|
|
||||||
|
|
||||||
var response = await _sendGridClient.SendEmailAsync(msg);
|
|
||||||
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.Accepted)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Email sent successfully to {Email}", to);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var content = await response.Body.ReadAsStringAsync();
|
|
||||||
_logger.LogWarning("Failed to send email to {Email}. Status: {StatusCode}", to, response.StatusCode);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error sending email to {Email}", to);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private (string subject, string htmlContent) GetPendingModerationTemplate(string userName, string pageTitle, string? previewUrl)
|
|
||||||
{
|
|
||||||
var subject = "📋 Sua página está sendo analisada - bcards.site";
|
|
||||||
var previewButton = !string.IsNullOrEmpty(previewUrl)
|
|
||||||
? $"<p><a href='{previewUrl}' style='background: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>Ver Preview</a></p>"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
var htmlContent = $@"
|
|
||||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
|
||||||
<h2 style='color: #333;'>Olá {userName}!</h2>
|
|
||||||
<p>Sua página <strong>'{pageTitle}'</strong> foi enviada para análise e estará disponível em breve!</p>
|
|
||||||
|
|
||||||
<div style='background: #e3f2fd; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
|
||||||
<p>🔍 <strong>Tempo estimado:</strong> 3-7 dias úteis</p>
|
|
||||||
<p>👀 <strong>Status:</strong> Em análise</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Nossa equipe verifica se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.</p>
|
|
||||||
|
|
||||||
{previewButton}
|
|
||||||
|
|
||||||
<hr style='margin: 30px 0;'>
|
|
||||||
<p style='color: #666; font-size: 14px;'>
|
|
||||||
Você receberá outro email assim que sua página for aprovada ou se precisar de ajustes.
|
|
||||||
</p>
|
|
||||||
</div>";
|
|
||||||
|
|
||||||
return (subject, htmlContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private (string subject, string htmlContent) GetApprovedTemplate(string userName, string pageTitle)
|
|
||||||
{
|
|
||||||
var subject = "✅ Sua página foi aprovada! - bcards.site";
|
|
||||||
var htmlContent = $@"
|
|
||||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
|
||||||
<h2 style='color: #28a745;'>Parabéns {userName}! 🎉</h2>
|
|
||||||
<p>Sua página <strong>'{pageTitle}'</strong> foi aprovada e já está no ar!</p>
|
|
||||||
|
|
||||||
<div style='background: #d4edda; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
|
||||||
<p>✅ <strong>Status:</strong> Aprovada</p>
|
|
||||||
<p>🌐 <strong>Sua página está online!</strong></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Agora você pode:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Compartilhar sua página nas redes sociais</li>
|
|
||||||
<li>Adicionar o link na sua bio</li>
|
|
||||||
<li>Acompanhar as estatísticas no painel</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
|
|
||||||
style='background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
|
|
||||||
Acessar Painel
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>";
|
|
||||||
|
|
||||||
return (subject, htmlContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private (string subject, string htmlContent) GetRejectedTemplate(string userName, string pageTitle, string? reason)
|
|
||||||
{
|
|
||||||
var subject = "⚠️ Sua página precisa de ajustes - bcards.site";
|
|
||||||
var reasonText = !string.IsNullOrEmpty(reason) ? $"<p><strong>Motivo:</strong> {reason}</p>" : "";
|
|
||||||
|
|
||||||
var htmlContent = $@"
|
|
||||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
|
||||||
<h2 style='color: #dc3545;'>Olá {userName}</h2>
|
|
||||||
<p>Sua página <strong>'{pageTitle}'</strong> não foi aprovada, mas você pode corrigir e reenviar!</p>
|
|
||||||
|
|
||||||
<div style='background: #f8d7da; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
|
||||||
<p>❌ <strong>Status:</strong> Necessita ajustes</p>
|
|
||||||
{reasonText}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Para que sua página seja aprovada, certifique-se de que:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Não contém conteúdo proibido ou suspeito</li>
|
|
||||||
<li>Todos os links estão funcionando</li>
|
|
||||||
<li>As informações são precisas</li>
|
|
||||||
<li>Segue nossos termos de uso</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
|
|
||||||
style='background: #dc3545; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
|
|
||||||
Editar Página
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>";
|
|
||||||
|
|
||||||
return (subject, htmlContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetPriorityLabel(string planType) => planType.ToLower() switch
|
|
||||||
{
|
|
||||||
"premium" => "ALTA",
|
|
||||||
"professional" => "ALTA",
|
|
||||||
"basic" => "MÉDIA",
|
|
||||||
_ => "BAIXA"
|
|
||||||
};
|
|
||||||
|
|
||||||
private string GetPriorityColor(string planType) => planType.ToLower() switch
|
|
||||||
{
|
|
||||||
"premium" => "#dc3545",
|
|
||||||
"professional" => "#fd7e14",
|
|
||||||
"basic" => "#ffc107",
|
|
||||||
_ => "#6c757d"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
namespace BCards.Web.Services;
|
|
||||||
|
|
||||||
public interface IEmailService
|
|
||||||
{
|
|
||||||
Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null);
|
|
||||||
Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName);
|
|
||||||
Task<bool> SendEmailAsync(string to, string subject, string htmlContent);
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
|
|
||||||
namespace BCards.Web.Services
|
|
||||||
{
|
|
||||||
public interface IModerationAuthService
|
|
||||||
{
|
|
||||||
bool IsUserModerator(ClaimsPrincipal user);
|
|
||||||
bool IsEmailModerator(string email);
|
|
||||||
List<string> GetModeratorEmails();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
using BCards.Web.Models;
|
|
||||||
|
|
||||||
namespace BCards.Web.Services;
|
|
||||||
|
|
||||||
public interface IModerationService
|
|
||||||
{
|
|
||||||
Task<string> GeneratePreviewTokenAsync(string pageId);
|
|
||||||
Task<bool> ValidatePreviewTokenAsync(string pageId, string token);
|
|
||||||
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20);
|
|
||||||
Task<UserPage?> GetPageForModerationAsync(string pageId);
|
|
||||||
Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null);
|
|
||||||
Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues);
|
|
||||||
Task<bool> CanUserCreatePageAsync(string userId);
|
|
||||||
Task<bool> IncrementPreviewViewAsync(string pageId);
|
|
||||||
Task<Dictionary<string, int>> GetModerationStatsAsync();
|
|
||||||
Task<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20);
|
|
||||||
Task<UserPage?> GetPageByPreviewTokenAsync(string token);
|
|
||||||
Task DeleteForModerationAsync(string pageId);
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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,7 +8,6 @@ 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();
|
||||||
}
|
}
|
||||||
@ -1,39 +0,0 @@
|
|||||||
using BCards.Web.Configuration;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Security.Claims;
|
|
||||||
|
|
||||||
namespace BCards.Web.Services
|
|
||||||
{
|
|
||||||
public class ModerationAuthService : IModerationAuthService
|
|
||||||
{
|
|
||||||
private readonly ModerationSettings _settings;
|
|
||||||
|
|
||||||
public ModerationAuthService(IOptions<ModerationSettings> settings)
|
|
||||||
{
|
|
||||||
_settings = settings.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsUserModerator(ClaimsPrincipal user)
|
|
||||||
{
|
|
||||||
if (!user.Identity?.IsAuthenticated == true)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var email = user.FindFirst(ClaimTypes.Email)?.Value;
|
|
||||||
return IsEmailModerator(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsEmailModerator(string? email)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(email))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return _settings.ModeratorEmails
|
|
||||||
.Any(moderatorEmail => moderatorEmail.Equals(email, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<string> GetModeratorEmails()
|
|
||||||
{
|
|
||||||
return _settings.ModeratorEmails.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
using BCards.Web.Models;
|
|
||||||
using BCards.Web.Repositories;
|
|
||||||
using BCards.Web.ViewModels;
|
|
||||||
using MongoDB.Driver;
|
|
||||||
|
|
||||||
namespace BCards.Web.Services;
|
|
||||||
|
|
||||||
public class ModerationService : IModerationService
|
|
||||||
{
|
|
||||||
private readonly IUserPageRepository _userPageRepository;
|
|
||||||
private readonly IUserRepository _userRepository;
|
|
||||||
private readonly ILogger<ModerationService> _logger;
|
|
||||||
|
|
||||||
public ModerationService(
|
|
||||||
IUserPageRepository userPageRepository,
|
|
||||||
IUserRepository userRepository,
|
|
||||||
ILogger<ModerationService> logger)
|
|
||||||
{
|
|
||||||
_userPageRepository = userPageRepository;
|
|
||||||
_userRepository = userRepository;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> GeneratePreviewTokenAsync(string pageId)
|
|
||||||
{
|
|
||||||
var token = Guid.NewGuid().ToString("N")[..16];
|
|
||||||
var expiry = DateTime.UtcNow.AddDays(30); // Token válido por 30 dias
|
|
||||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
|
||||||
page.PreviewToken = token;
|
|
||||||
page.PreviewTokenExpiry = expiry;
|
|
||||||
page.PreviewViewCount = 0;
|
|
||||||
|
|
||||||
await _userPageRepository.UpdateAsync(page);
|
|
||||||
|
|
||||||
_logger.LogInformation("Generated preview token for page {PageId}", pageId);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> ValidatePreviewTokenAsync(string pageId, string token)
|
|
||||||
{
|
|
||||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
|
||||||
if (page == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var isValid = page.PreviewToken == token &&
|
|
||||||
page.PreviewTokenExpiry > DateTime.UtcNow &&
|
|
||||||
page.PreviewViewCount < 50;
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20)
|
|
||||||
{
|
|
||||||
var filter = Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.PendingModeration);
|
|
||||||
|
|
||||||
// Ordenar por prioridade do plano e depois por data
|
|
||||||
var sort = Builders<UserPage>.Sort
|
|
||||||
.Ascending("planLimitations.planType")
|
|
||||||
.Ascending(p => p.CreatedAt);
|
|
||||||
|
|
||||||
var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take);
|
|
||||||
return pages.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<UserPage?> GetPageForModerationAsync(string pageId)
|
|
||||||
{
|
|
||||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
|
||||||
if (page?.Status != PageStatus.PendingModeration)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteForModerationAsync(string pageId)
|
|
||||||
{
|
|
||||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
|
||||||
await _userPageRepository.DeleteAsync(pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null)
|
|
||||||
{
|
|
||||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
|
||||||
if (page == null)
|
|
||||||
throw new ArgumentException("Page not found");
|
|
||||||
|
|
||||||
var moderationEntry = new ModerationHistory
|
|
||||||
{
|
|
||||||
Attempt = page.ModerationAttempts + 1,
|
|
||||||
Status = "approved",
|
|
||||||
ModeratorId = moderatorId,
|
|
||||||
Date = DateTime.UtcNow,
|
|
||||||
Reason = notes
|
|
||||||
};
|
|
||||||
|
|
||||||
page.ModerationHistory.Add(moderationEntry);
|
|
||||||
|
|
||||||
var update = Builders<UserPage>.Update
|
|
||||||
.Set(p => p.Status, PageStatus.Active)
|
|
||||||
.Set(p => p.ApprovedAt, DateTime.UtcNow)
|
|
||||||
.Set(p => p.ModerationAttempts, page.ModerationAttempts + 1)
|
|
||||||
.Set(p => p.ModerationHistory, page.ModerationHistory)
|
|
||||||
.Set(p => p.PublishedAt, DateTime.UtcNow)
|
|
||||||
.Unset(p => p.PreviewToken)
|
|
||||||
.Unset(p => p.PreviewTokenExpiry);
|
|
||||||
|
|
||||||
await _userPageRepository.UpdateAsync(pageId, update);
|
|
||||||
_logger.LogInformation("Page {PageId} approved by moderator {ModeratorId}", pageId, moderatorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues)
|
|
||||||
{
|
|
||||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
|
||||||
if (page == null)
|
|
||||||
throw new ArgumentException("Page not found");
|
|
||||||
|
|
||||||
var moderationEntry = new ModerationHistory
|
|
||||||
{
|
|
||||||
Attempt = page.ModerationAttempts + 1,
|
|
||||||
Status = "rejected",
|
|
||||||
ModeratorId = moderatorId,
|
|
||||||
Date = DateTime.UtcNow,
|
|
||||||
Reason = reason,
|
|
||||||
Issues = issues
|
|
||||||
};
|
|
||||||
|
|
||||||
page.ModerationHistory.Add(moderationEntry);
|
|
||||||
|
|
||||||
var newStatus = page.ModerationAttempts >= 2 ? PageStatus.Rejected : PageStatus.Inactive;
|
|
||||||
var userScoreDeduction = Math.Min(20, page.UserScore / 5);
|
|
||||||
|
|
||||||
var update = Builders<UserPage>.Update
|
|
||||||
.Set(p => p.Status, newStatus)
|
|
||||||
.Set(p => p.ModerationAttempts, page.ModerationAttempts + 1)
|
|
||||||
.Set(p => p.ModerationHistory, page.ModerationHistory)
|
|
||||||
.Set(p => p.UserScore, Math.Max(0, page.UserScore - userScoreDeduction))
|
|
||||||
.Unset(p => p.PreviewToken)
|
|
||||||
.Unset(p => p.PreviewTokenExpiry);
|
|
||||||
|
|
||||||
await _userPageRepository.UpdateAsync(pageId, update);
|
|
||||||
_logger.LogInformation("Page {PageId} rejected by moderator {ModeratorId}. Reason: {Reason}",
|
|
||||||
pageId, moderatorId, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> CanUserCreatePageAsync(string userId)
|
|
||||||
{
|
|
||||||
var user = await _userRepository.GetByIdAsync(userId);
|
|
||||||
if (user == null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
//var rejectedPages = userPages.Count(p => p.Status == PageStatus.Rejected);
|
|
||||||
|
|
||||||
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
|
|
||||||
filter &= Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected);
|
|
||||||
|
|
||||||
var rejectedPages = await _userPageRepository.CountAsync(filter);
|
|
||||||
|
|
||||||
// Usuários com mais de 2 páginas rejeitadas não podem criar novas
|
|
||||||
return rejectedPages < 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> IncrementPreviewViewAsync(string pageId)
|
|
||||||
{
|
|
||||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
|
||||||
if (page == null || page.PreviewViewCount >= 50)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var update = Builders<UserPage>.Update
|
|
||||||
.Inc(p => p.PreviewViewCount, 1);
|
|
||||||
|
|
||||||
await _userPageRepository.UpdateAsync(pageId, update);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Dictionary<string, int>> GetModerationStatsAsync()
|
|
||||||
{
|
|
||||||
var stats = new Dictionary<string, int>();
|
|
||||||
|
|
||||||
var pendingCount = await _userPageRepository.CountAsync(
|
|
||||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.PendingModeration));
|
|
||||||
|
|
||||||
var approvedToday = await _userPageRepository.CountAsync(
|
|
||||||
Builders<UserPage>.Filter.And(
|
|
||||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
|
|
||||||
Builders<UserPage>.Filter.Gte(p => p.ApprovedAt, DateTime.UtcNow.Date)));
|
|
||||||
|
|
||||||
var rejectedToday = await _userPageRepository.CountAsync(
|
|
||||||
Builders<UserPage>.Filter.And(
|
|
||||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected),
|
|
||||||
Builders<UserPage>.Filter.Gte(p => p.UpdatedAt, DateTime.UtcNow.Date)));
|
|
||||||
|
|
||||||
stats["pending"] = (int)pendingCount;
|
|
||||||
stats["approvedToday"] = (int)approvedToday;
|
|
||||||
stats["rejectedToday"] = (int)rejectedToday;
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20)
|
|
||||||
{
|
|
||||||
var filter = Builders<UserPage>.Filter.Or(
|
|
||||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
|
|
||||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected));
|
|
||||||
|
|
||||||
var sort = Builders<UserPage>.Sort.Descending(p => p.UpdatedAt);
|
|
||||||
|
|
||||||
var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take);
|
|
||||||
return pages.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<UserPage?> GetPageByPreviewTokenAsync(string token)
|
|
||||||
{
|
|
||||||
var filter = Builders<UserPage>.Filter.And(
|
|
||||||
Builders<UserPage>.Filter.Eq(p => p.PreviewToken, token),
|
|
||||||
Builders<UserPage>.Filter.Gt(p => p.PreviewTokenExpiry, DateTime.UtcNow));
|
|
||||||
|
|
||||||
var pages = await _userPageRepository.GetManyAsync(filter);
|
|
||||||
return pages.FirstOrDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,299 +0,0 @@
|
|||||||
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,6 +1,5 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -134,128 +133,6 @@ 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();
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
stripe listen --forward-to localhost:49178/webhook/stripe
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
using BCards.Web.Models;
|
|
||||||
using BCards.Web.ViewModels;
|
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
// Atributo de validação customizado para links
|
|
||||||
public class ConditionalRequiredAttribute : ValidationAttribute
|
|
||||||
{
|
|
||||||
private readonly string _dependentProperty;
|
|
||||||
private readonly object _targetValue;
|
|
||||||
|
|
||||||
public ConditionalRequiredAttribute(string dependentProperty, object targetValue)
|
|
||||||
{
|
|
||||||
_dependentProperty = dependentProperty;
|
|
||||||
_targetValue = targetValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
|
|
||||||
{
|
|
||||||
var dependentProperty = validationContext.ObjectType.GetProperty(_dependentProperty);
|
|
||||||
if (dependentProperty == null)
|
|
||||||
return ValidationResult.Success;
|
|
||||||
|
|
||||||
var dependentValue = dependentProperty.GetValue(validationContext.ObjectInstance);
|
|
||||||
|
|
||||||
// Se o valor dependente não é o target, não valida
|
|
||||||
if (!Equals(dependentValue, _targetValue))
|
|
||||||
return ValidationResult.Success;
|
|
||||||
|
|
||||||
// Se é o target value e o campo está vazio, retorna erro
|
|
||||||
if (value == null || string.IsNullOrWhiteSpace(value.ToString()))
|
|
||||||
return new ValidationResult(ErrorMessage ?? $"{validationContext.DisplayName} é obrigatório.");
|
|
||||||
|
|
||||||
return ValidationResult.Success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Método de extensão para validação personalizada no Controller
|
|
||||||
public static class ModelStateExtensions
|
|
||||||
{
|
|
||||||
public static void ValidateLinks(this ModelStateDictionary modelState, List<ManageLinkViewModel> links)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < links.Count; i++)
|
|
||||||
{
|
|
||||||
var link = links[i];
|
|
||||||
|
|
||||||
// Validação condicional baseada no tipo
|
|
||||||
if (link.Type == LinkType.Product)
|
|
||||||
{
|
|
||||||
// Para links de produto, ProductTitle é obrigatório
|
|
||||||
if (string.IsNullOrWhiteSpace(link.ProductTitle))
|
|
||||||
{
|
|
||||||
modelState.AddModelError($"Links[{i}].ProductTitle", "Título do produto é obrigatório");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title pode ser vazio para links de produto (será preenchido automaticamente)
|
|
||||||
modelState.Remove($"Links[{i}].Title");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Para links normais, Title é obrigatório
|
|
||||||
if (string.IsNullOrWhiteSpace(link.Title))
|
|
||||||
{
|
|
||||||
modelState.AddModelError($"Links[{i}].Title", "Título é obrigatório");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Campos de produto podem ser vazios para links normais
|
|
||||||
modelState.Remove($"Links[{i}].ProductTitle");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
using BCards.Web.Models;
|
|
||||||
using BCards.Web.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace BCards.Web.Utils
|
|
||||||
{
|
|
||||||
public class ModerationMenuViewComponent : ViewComponent
|
|
||||||
{
|
|
||||||
private readonly IModerationAuthService _moderationAuth;
|
|
||||||
|
|
||||||
public ModerationMenuViewComponent(IModerationAuthService moderationAuth)
|
|
||||||
{
|
|
||||||
_moderationAuth = moderationAuth;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IViewComponentResult Invoke()
|
|
||||||
{
|
|
||||||
var user = HttpContext.User;
|
|
||||||
var isModerator = user.Identity?.IsAuthenticated == true &&
|
|
||||||
_moderationAuth.IsUserModerator(user);
|
|
||||||
|
|
||||||
return View(isModerator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace BCards.Web.Utils
|
|
||||||
{
|
|
||||||
public static class SlugHelper
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Remove acentos e caracteres especiais, criando um slug limpo
|
|
||||||
/// </summary>
|
|
||||||
public static string RemoveAccents(string text)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
// Normalizar para NFD (decompor caracteres acentuados)
|
|
||||||
var normalizedString = text.Normalize(NormalizationForm.FormD);
|
|
||||||
var stringBuilder = new StringBuilder();
|
|
||||||
|
|
||||||
// Filtrar apenas caracteres que não são marcas diacríticas
|
|
||||||
foreach (var c in normalizedString)
|
|
||||||
{
|
|
||||||
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
|
||||||
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
|
||||||
{
|
|
||||||
stringBuilder.Append(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cria um slug limpo e URL-friendly
|
|
||||||
/// </summary>
|
|
||||||
public static string CreateSlug(string text)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
// 1. Remover acentos
|
|
||||||
var slug = RemoveAccents(text);
|
|
||||||
|
|
||||||
// 2. Converter para minúsculas
|
|
||||||
slug = slug.ToLowerInvariant();
|
|
||||||
|
|
||||||
// 3. Substituir espaços e caracteres especiais por hífen
|
|
||||||
slug = Regex.Replace(slug, @"[^a-z0-9\s-]", "");
|
|
||||||
|
|
||||||
// 4. Substituir múltiplos espaços por hífen único
|
|
||||||
slug = Regex.Replace(slug, @"[\s-]+", "-");
|
|
||||||
|
|
||||||
// 5. Remover hífens do início e fim
|
|
||||||
slug = slug.Trim('-');
|
|
||||||
|
|
||||||
// 6. Limitar tamanho (opcional)
|
|
||||||
if (slug.Length > 50)
|
|
||||||
slug = slug.Substring(0, 50).TrimEnd('-');
|
|
||||||
|
|
||||||
return slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cria uma categoria limpa (sem acentos, minúscula)
|
|
||||||
/// </summary>
|
|
||||||
public static string CreateCategorySlug(string category)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(category))
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
var slug = RemoveAccents(category);
|
|
||||||
slug = slug.ToLowerInvariant();
|
|
||||||
slug = Regex.Replace(slug, @"[^a-z0-9]", "");
|
|
||||||
|
|
||||||
return slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dicionário de conversões comuns para categorias brasileiras
|
|
||||||
/// </summary>
|
|
||||||
private static readonly Dictionary<string, string> CategoryMappings = new()
|
|
||||||
{
|
|
||||||
{ "saúde", "saude" },
|
|
||||||
{ "educação", "educacao" },
|
|
||||||
{ "tecnologia", "tecnologia" },
|
|
||||||
{ "negócios", "negocios" },
|
|
||||||
{ "serviços", "servicos" },
|
|
||||||
{ "alimentação", "alimentacao" },
|
|
||||||
{ "construção", "construcao" },
|
|
||||||
{ "automóveis", "automoveis" },
|
|
||||||
{ "beleza", "beleza" },
|
|
||||||
{ "esportes", "esportes" },
|
|
||||||
{ "música", "musica" },
|
|
||||||
{ "fotografia", "fotografia" }
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Converte categoria com mapeamento personalizado
|
|
||||||
/// </summary>
|
|
||||||
public static string ConvertCategory(string category)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(category))
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
var lowerCategory = category.ToLowerInvariant().Trim();
|
|
||||||
|
|
||||||
// Verificar mapeamento direto
|
|
||||||
if (CategoryMappings.ContainsKey(lowerCategory))
|
|
||||||
return CategoryMappings[lowerCategory];
|
|
||||||
|
|
||||||
// Fallback para conversão automática
|
|
||||||
return CreateCategorySlug(category);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
using BCards.Web.Services;
|
|
||||||
using System.Security.Claims;
|
|
||||||
|
|
||||||
namespace BCards.Web.Utils
|
|
||||||
{
|
|
||||||
public static class ViewExtensions
|
|
||||||
{
|
|
||||||
public static bool IsModerator(this ClaimsPrincipal user, IServiceProvider services)
|
|
||||||
{
|
|
||||||
var moderationAuth = services.GetRequiredService<IModerationAuthService>();
|
|
||||||
return moderationAuth.IsUserModerator(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -50,35 +50,19 @@ public class ManageLinkViewModel
|
|||||||
public string Id { get; set; } = "new";
|
public string Id { get; set; } = "new";
|
||||||
|
|
||||||
[Required(ErrorMessage = "Título é obrigatório")]
|
[Required(ErrorMessage = "Título é obrigatório")]
|
||||||
[StringLength(200, ErrorMessage = "Título deve ter no máximo 50 caracteres")]
|
[StringLength(50, ErrorMessage = "Título deve ter no máximo 50 caracteres")]
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Required(ErrorMessage = "URL é obrigatória")]
|
[Required(ErrorMessage = "URL é obrigatória")]
|
||||||
[Url(ErrorMessage = "URL inválida")]
|
[Url(ErrorMessage = "URL inválida")]
|
||||||
public string Url { get; set; } = string.Empty;
|
public string Url { get; set; } = string.Empty;
|
||||||
|
|
||||||
[StringLength(3000, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")]
|
[StringLength(100, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")]
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
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(200, 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(3000, 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
|
||||||
@ -100,10 +84,7 @@ public class UserPageSummary
|
|||||||
public int TotalClicks { get; set; } = 0;
|
public int TotalClicks { get; set; } = 0;
|
||||||
public int TotalViews { get; set; } = 0;
|
public int TotalViews { get; set; } = 0;
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public string? PreviewToken { get; set; } = string.Empty;
|
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}";
|
||||||
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}?preview={PreviewToken}";
|
|
||||||
public PageStatus? LastModerationStatus { get; set; } = PageStatus.PendingModeration;
|
|
||||||
public string Motive { get; set; } = string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PlanInfo
|
public class PlanInfo
|
||||||
@ -123,7 +104,5 @@ public enum PageStatus
|
|||||||
Active, // Funcionando normalmente
|
Active, // Funcionando normalmente
|
||||||
Expired, // Trial vencido -> 301 redirect
|
Expired, // Trial vencido -> 301 redirect
|
||||||
PendingPayment, // Pagamento atrasado -> aviso na página
|
PendingPayment, // Pagamento atrasado -> aviso na página
|
||||||
Inactive, // Pausada pelo usuário
|
Inactive // Pausada pelo usuário
|
||||||
PendingModeration = 4, // Aguardando moderação
|
|
||||||
Rejected = 5 // Rejeitada na moderação
|
|
||||||
}
|
}
|
||||||
@ -1,81 +0,0 @@
|
|||||||
using BCards.Web.Models;
|
|
||||||
|
|
||||||
namespace BCards.Web.ViewModels;
|
|
||||||
|
|
||||||
public class ModerationDashboardViewModel
|
|
||||||
{
|
|
||||||
public List<PendingPageViewModel> PendingPages { get; set; } = new();
|
|
||||||
public Dictionary<string, int> Stats { get; set; } = new();
|
|
||||||
public int CurrentPage { get; set; } = 1;
|
|
||||||
public int PageSize { get; set; } = 20;
|
|
||||||
public bool HasNextPage { get; set; } = false;
|
|
||||||
public bool HasPreviousPage => CurrentPage > 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ModerationPageViewModel
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
public string DisplayName { get; set; } = string.Empty;
|
|
||||||
public string Category { get; set; } = string.Empty;
|
|
||||||
public string Slug { get; set; } = string.Empty;
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public string Status { get; set; } = string.Empty;
|
|
||||||
public int ModerationAttempts { get; set; }
|
|
||||||
public string PlanType { get; set; } = string.Empty;
|
|
||||||
public string? PreviewUrl { get; set; }
|
|
||||||
public DateTime? ApprovedAt { get; set; }
|
|
||||||
public ModerationHistory? LastModerationEntry { get; set; }
|
|
||||||
|
|
||||||
public string PriorityLabel => PlanType.ToLower() switch
|
|
||||||
{
|
|
||||||
"premium" => "ALTA",
|
|
||||||
"professional" => "ALTA",
|
|
||||||
"basic" => "MÉDIA",
|
|
||||||
_ => "BAIXA"
|
|
||||||
};
|
|
||||||
|
|
||||||
public string PriorityColor => PlanType.ToLower() switch
|
|
||||||
{
|
|
||||||
"premium" => "danger",
|
|
||||||
"professional" => "warning",
|
|
||||||
"basic" => "info",
|
|
||||||
_ => "secondary"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ModerationReviewViewModel
|
|
||||||
{
|
|
||||||
public UserPage Page { get; set; } = new();
|
|
||||||
public User User { get; set; } = new();
|
|
||||||
public string? PreviewUrl { get; set; }
|
|
||||||
public List<ModerationCriterion> ModerationCriteria { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ModerationHistoryViewModel
|
|
||||||
{
|
|
||||||
public List<ModerationPageViewModel> Pages { get; set; } = new();
|
|
||||||
public int CurrentPage { get; set; } = 1;
|
|
||||||
public int PageSize { get; set; } = 20;
|
|
||||||
public bool HasNextPage { get; set; } = false;
|
|
||||||
public bool HasPreviousPage => CurrentPage > 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ModerationCriterion
|
|
||||||
{
|
|
||||||
public string Category { get; set; } = string.Empty;
|
|
||||||
public List<string> Items { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PendingPageViewModel
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = "";
|
|
||||||
public string DisplayName { get; set; } = "";
|
|
||||||
public string Slug { get; set; } = "";
|
|
||||||
public string Category { get; set; } = "";
|
|
||||||
public string PlanType { get; set; } = "";
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public int ModerationAttempts { get; set; }
|
|
||||||
public string PreviewUrl { get; set; } = "";
|
|
||||||
public string PriorityLabel { get; set; } = "";
|
|
||||||
public string PriorityColor { get; set; } = "";
|
|
||||||
}
|
|
||||||
@ -16,7 +16,7 @@
|
|||||||
@if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage))
|
@if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage))
|
||||||
{
|
{
|
||||||
<img src="@Model.CurrentUser.ProfileImage" alt="@Model.CurrentUser.Name"
|
<img src="@Model.CurrentUser.ProfileImage" alt="@Model.CurrentUser.Name"
|
||||||
class="rounded-circle" style="width: 60px; height: 60px; object-fit: cover;">
|
class="rounded-circle" style="width: 60px; height: 60px; object-fit: cover;">
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -28,30 +28,11 @@
|
|||||||
<div class="col-md-6 col-lg-4 mb-3">
|
<div class="col-md-6 col-lg-4 mb-3">
|
||||||
<div class="card h-100 @(page.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
|
<div class="card h-100 @(page.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="card-title">
|
<h6 class="card-title">@(page.DisplayName)</h6>
|
||||||
@(page.DisplayName)
|
|
||||||
<form method="post" action="/Admin/DeletePage/@(page.Id)" style="display: inline;" onsubmit="return confirm('Tem certeza que deseja excluir esta página?')">
|
|
||||||
@Html.AntiForgeryToken()
|
|
||||||
<button type="submit" class="btn btn-link text-danger p-0" title="Excluir página"
|
|
||||||
style="font-size: 12px; text-decoration: none;">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</h6>
|
|
||||||
<p class="text-muted small mb-2">@(page.Category)/@(page.Slug)</p>
|
<p class="text-muted small mb-2">@(page.Category)/@(page.Slug)</p>
|
||||||
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@{
|
@switch (page.Status)
|
||||||
var pageStatus = page.Status;
|
|
||||||
if (page.Status == BCards.Web.ViewModels.PageStatus.Inactive)
|
|
||||||
{
|
|
||||||
if (page.LastModerationStatus.HasValue)
|
|
||||||
{
|
|
||||||
pageStatus = page.LastModerationStatus.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@switch (pageStatus)
|
|
||||||
{
|
{
|
||||||
case BCards.Web.ViewModels.PageStatus.Active:
|
case BCards.Web.ViewModels.PageStatus.Active:
|
||||||
<span class="badge bg-success">Ativa</span>
|
<span class="badge bg-success">Ativa</span>
|
||||||
@ -65,14 +46,9 @@
|
|||||||
case BCards.Web.ViewModels.PageStatus.Inactive:
|
case BCards.Web.ViewModels.PageStatus.Inactive:
|
||||||
<span class="badge bg-secondary">Inativa</span>
|
<span class="badge bg-secondary">Inativa</span>
|
||||||
break;
|
break;
|
||||||
case BCards.Web.ViewModels.PageStatus.PendingModeration:
|
|
||||||
<span class="badge bg-warning">Aguardando</span>
|
|
||||||
break;
|
|
||||||
case BCards.Web.ViewModels.PageStatus.Rejected:
|
|
||||||
<span class="badge bg-danger">Rejeitada</span>
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.CurrentPlan.AllowsAnalytics)
|
@if (Model.CurrentPlan.AllowsAnalytics)
|
||||||
{
|
{
|
||||||
<div class="row text-center small mb-3">
|
<div class="row text-center small mb-3">
|
||||||
@ -89,40 +65,13 @@
|
|||||||
|
|
||||||
<div class="d-flex gap-1 flex-wrap">
|
<div class="d-flex gap-1 flex-wrap">
|
||||||
<a href="@Url.Action("ManagePage", new { id = page.Id })"
|
<a href="@Url.Action("ManagePage", new { id = page.Id })"
|
||||||
class="btn btn-sm btn-outline-primary flex-fill">Editar</a>
|
class="btn btn-sm btn-outline-primary flex-fill">Editar</a>
|
||||||
<a href="@(page.PublicUrl)" target="_blank"
|
<a href="@(page.PublicUrl)" target="_blank"
|
||||||
class="btn btn-sm btn-outline-success flex-fill">Ver</a>
|
class="btn btn-sm btn-outline-success flex-fill">Ver</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-transparent">
|
<div class="card-footer bg-transparent">
|
||||||
<small class="text-muted">Criada em @(page.CreatedAt.ToString("dd/MM/yyyy"))</small>
|
<small class="text-muted">Criada em @(page.CreatedAt.ToString("dd/MM/yyyy"))</small>
|
||||||
@if ((page.LastModerationStatus ?? page.Status) == BCards.Web.ViewModels.PageStatus.Rejected && !string.IsNullOrEmpty(page.Motive))
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger alert-dismissible fade show mt-2" role="alert">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="fas fa-exclamation-triangle me-2 mt-1"></i>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<strong>Motivo da rejeição:</strong><br>
|
|
||||||
<small>@(page.Motive)</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (page.LastModerationStatus == BCards.Web.ViewModels.PageStatus.Active && !string.IsNullOrEmpty(page.Motive))
|
|
||||||
{
|
|
||||||
<div class="alert alert-info alert-dismissible fade show mt-2" role="alert">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i class="fas fa-exclamation-triangle me-2 mt-1"></i>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<strong>Motivo:</strong><br>
|
|
||||||
<small>@(page.Motive)</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -129,48 +129,32 @@
|
|||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p class="text-muted mb-4">Escolha um tema que combine com sua personalidade ou marca:</p>
|
<p class="text-muted mb-4">Escolha um tema que combine com sua personalidade ou marca:</p>
|
||||||
|
|
||||||
@{
|
<div class="row">
|
||||||
var themeCount = 0;
|
@foreach (var theme in Model.AvailableThemes)
|
||||||
}
|
|
||||||
@foreach (var theme in Model.AvailableThemes)
|
|
||||||
{
|
|
||||||
@if (themeCount % 4 == 0)
|
|
||||||
{
|
{
|
||||||
@if (themeCount > 0)
|
<div class="col-md-4 col-lg-3 mb-3">
|
||||||
{
|
<div class="theme-card @(Model.SelectedTheme == theme.Name.ToLower() ? "selected" : "")" data-theme="@theme.Name.ToLower()">
|
||||||
@:</div>
|
<div class="theme-preview" style="background: @theme.BackgroundColor; color: @theme.TextColor;">
|
||||||
}
|
<div class="theme-header" style="background-color: @theme.PrimaryColor;">
|
||||||
@:<div class="row">
|
<div class="theme-avatar"></div>
|
||||||
}
|
<h6>@theme.Name</h6>
|
||||||
|
</div>
|
||||||
<div class="col-md-4 col-lg-3 mb-3">
|
<div class="theme-links">
|
||||||
<div class="theme-card @(Model.SelectedTheme.ToLower() == theme.Name.ToLower() ? "selected" : "")" data-theme="@theme.Name.ToLower()">
|
<div class="theme-link" style="background-color: @theme.PrimaryColor;"></div>
|
||||||
<div class="theme-preview" style="background: @theme.BackgroundColor; color: @theme.TextColor;">
|
<div class="theme-link" style="background-color: @theme.SecondaryColor;"></div>
|
||||||
<div class="theme-header" style="background-color: @theme.PrimaryColor;">
|
</div>
|
||||||
<div class="theme-avatar"></div>
|
|
||||||
<h6>@theme.Name</h6>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-links">
|
<div class="theme-name">
|
||||||
<div class="theme-link" style="background-color: @theme.PrimaryColor;"></div>
|
@theme.Name
|
||||||
<div class="theme-link" style="background-color: @theme.SecondaryColor;"></div>
|
@if (theme.IsPremium)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning">Premium</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-name">
|
|
||||||
@theme.Name
|
|
||||||
@if (theme.IsPremium)
|
|
||||||
{
|
|
||||||
<span class="badge bg-warning">Premium</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
</div>
|
||||||
themeCount++;
|
|
||||||
}
|
|
||||||
@if (Model.AvailableThemes.Any())
|
|
||||||
{
|
|
||||||
@:</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<input asp-for="SelectedTheme" type="hidden">
|
<input asp-for="SelectedTheme" type="hidden">
|
||||||
|
|
||||||
@ -210,109 +194,39 @@
|
|||||||
"instagram"
|
"instagram"
|
||||||
};
|
};
|
||||||
var match = myList.FirstOrDefault(stringToCheck => Model.Links[i].Icon.Contains(stringToCheck));
|
var match = myList.FirstOrDefault(stringToCheck => Model.Links[i].Icon.Contains(stringToCheck));
|
||||||
if (match==null)
|
if (match==null) {
|
||||||
{
|
<div class="link-input-group" data-link="@i">
|
||||||
if (Model.Links[i].Type==LinkType.Normal)
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
{
|
<h6 class="mb-0">Link @(i + 1)</h6>
|
||||||
<div class="link-input-group" data-link="@i">
|
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<i class="fas fa-trash"></i>
|
||||||
<h6 class="mb-0">Link @(i + 1)</h6>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
</div>
|
||||||
<i class="fas fa-trash"></i>
|
<div class="row">
|
||||||
</button>
|
<div class="col-md-6">
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Título</label>
|
|
||||||
<input asp-for="Links[i].Title" class="form-control link-title" placeholder="Ex: Meu Site">
|
|
||||||
<span asp-validation-for="Links[i].Title" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">URL</label>
|
|
||||||
<input asp-for="Links[i].Url" class="form-control link-url" placeholder="https://exemplo.com">
|
|
||||||
<span asp-validation-for="Links[i].Url" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Descrição (opcional)</label>
|
<label class="form-label">Título</label>
|
||||||
<input asp-for="Links[i].Description" class="form-control link-description" placeholder="Breve descrição do link">
|
<input asp-for="Links[i].Title" class="form-control link-title" placeholder="Ex: Meu Site">
|
||||||
|
<span asp-validation-for="Links[i].Title" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
<input asp-for="Links[i].Id" type="hidden">
|
|
||||||
<input asp-for="Links[i].Icon" type="hidden">
|
|
||||||
<input asp-for="Links[i].Order" type="hidden">
|
|
||||||
<input asp-for="Links[i].IsActive" type="hidden" value="true">
|
|
||||||
</div>
|
</div>
|
||||||
}
|
<div class="col-md-6">
|
||||||
else
|
<div class="mb-2">
|
||||||
{
|
<label class="form-label">URL</label>
|
||||||
<div class="link-input-group product-link-preview" data-link="@i">
|
<input asp-for="Links[i].Url" class="form-control link-url" placeholder="https://exemplo.com">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<span asp-validation-for="Links[i].Url" class="text-danger"></span>
|
||||||
<h6 class="mb-0">
|
|
||||||
<i class="fas fa-shopping-bag me-2 text-success"></i>Link de Produto @(i + 1)
|
|
||||||
</h6>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card border-success">
|
|
||||||
<div class="row g-0">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="p-3 text-center">
|
|
||||||
@if (!string.IsNullOrEmpty(Model.Links[i].ProductImage))
|
|
||||||
{
|
|
||||||
<img src="@Model.Links[i].ProductImage"
|
|
||||||
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>';" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<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">@Model.Links[i].Title</h6>
|
|
||||||
@if (!string.IsNullOrEmpty(Model.Links[i].ProductPrice))
|
|
||||||
{
|
|
||||||
<p class="card-text"><strong class="text-success">@Model.Links[i].ProductPrice</strong></p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(Model.Links[i].ProductDescription))
|
|
||||||
{
|
|
||||||
<p class="card-text small text-muted">@Model.Links[i].ProductDescription</p>
|
|
||||||
}
|
|
||||||
<small class="text-muted d-block">
|
|
||||||
<i class="fas fa-external-link-alt me-1"></i>
|
|
||||||
@(Model.Links[i].Url.Length > 50 ? $"{Model.Links[i].Url.Substring(0, 50)}..." : Model.Links[i].Url)
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden fields for form submission -->
|
|
||||||
<input type="hidden" name="Links[@i].Id" value="@Model.Links[i].Id">
|
|
||||||
<input type="hidden" name="Links[@i].Title" value="@Model.Links[i].Title">
|
|
||||||
<input type="hidden" name="Links[@i].Url" value="@Model.Links[i].Url">
|
|
||||||
<input type="hidden" name="Links[@i].Description" value="@Model.Links[i].Description">
|
|
||||||
<input type="hidden" name="Links[@i].Type" value="Product">
|
|
||||||
<input type="hidden" name="Links[@i].ProductTitle" value="@Model.Links[i].Title">
|
|
||||||
<input type="hidden" name="Links[@i].ProductDescription" value="@Model.Links[i].ProductDescription">
|
|
||||||
<input type="hidden" name="Links[@i].ProductPrice" value="@Model.Links[i].ProductPrice">
|
|
||||||
<input type="hidden" name="Links[@i].ProductImage" value="@Model.Links[i].ProductImage">
|
|
||||||
<input type="hidden" name="Links[@i].Icon" value="fas fa-shopping-bag">
|
|
||||||
<input type="hidden" name="Links[@i].Order" value="@i">
|
|
||||||
<input type="hidden" name="Links[@i].IsActive" value="true">
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Descrição (opcional)</label>
|
||||||
|
<input asp-for="Links[i].Description" class="form-control link-description" placeholder="Breve descrição do link">
|
||||||
|
</div>
|
||||||
|
<input asp-for="Links[i].Id" type="hidden">
|
||||||
|
<input asp-for="Links[i].Icon" type="hidden">
|
||||||
|
<input asp-for="Links[i].Order" type="hidden">
|
||||||
|
<input asp-for="Links[i].IsActive" type="hidden" value="true">
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@ -441,126 +355,41 @@
|
|||||||
</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 class="form-label">Tipo de Link</label>
|
<label for="linkTitle" class="form-label">Título do Link</label>
|
||||||
<div class="d-flex gap-2">
|
<input type="text" class="form-control" id="linkTitle" placeholder="Ex: Meu Site, Portfólio, Instagram..." required>
|
||||||
<div class="form-check flex-fill">
|
<div class="form-text">Nome que aparecerá no botão</div>
|
||||||
<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>
|
||||||
|
|
||||||
<!-- Seção para Link Normal -->
|
<div class="mb-3">
|
||||||
<div id="normalLinkSection">
|
<label for="linkUrl" class="form-label">URL</label>
|
||||||
<div class="mb-3">
|
<input type="url" class="form-control" id="linkUrl" placeholder="https://exemplo.com" required>
|
||||||
<label for="linkTitle" class="form-label">Título do Link</label>
|
<div class="form-text">Link completo incluindo https://</div>
|
||||||
<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>
|
||||||
|
|
||||||
<!-- Seção para Link de Produto -->
|
<div class="mb-3">
|
||||||
<div id="productLinkSection" style="display: none;">
|
<label for="linkDescription" class="form-label">Descrição (opcional)</label>
|
||||||
<div class="mb-3">
|
<input type="text" class="form-control" id="linkDescription" placeholder="Breve descrição do link">
|
||||||
<label for="productUrl" class="form-label">URL do Produto</label>
|
<div class="form-text">Texto adicional que aparece abaixo do título</div>
|
||||||
<div class="input-group">
|
</div>
|
||||||
<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="mb-3">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<label for="linkIcon" class="form-label">Ícone (opcional)</label>
|
||||||
<span class="visually-hidden">Carregando...</span>
|
<select class="form-select" id="linkIcon">
|
||||||
</div>
|
<option value="">Sem ícone</option>
|
||||||
<p class="mt-2 text-muted">Extraindo informações do produto...</p>
|
<option value="fas fa-globe">🌐 Site</option>
|
||||||
</div>
|
<option value="fas fa-shopping-cart">🛒 Loja</option>
|
||||||
|
<option value="fas fa-briefcase">💼 Portfólio</option>
|
||||||
<div class="row">
|
<option value="fas fa-envelope">✉️ Email</option>
|
||||||
<div class="col-md-8">
|
<option value="fas fa-phone">📞 Telefone</option>
|
||||||
<div class="mb-3">
|
<option value="fas fa-map-marker-alt">📍 Localização</option>
|
||||||
<label for="productTitle" class="form-label">Título do Produto</label>
|
<option value="fab fa-youtube">📺 YouTube</option>
|
||||||
<input type="text" class="form-control" id="productTitle" maxlength="100" placeholder="Nome do produto">
|
<option value="fab fa-linkedin">💼 LinkedIn</option>
|
||||||
</div>
|
<option value="fab fa-github">💻 GitHub</option>
|
||||||
<div class="mb-3">
|
<option value="fas fa-download">⬇️ Download</option>
|
||||||
<label for="productDescription" class="form-label">Descrição (Opcional)</label>
|
<option value="fas fa-calendar">📅 Agenda</option>
|
||||||
<textarea class="form-control" id="productDescription" rows="2" maxlength="200" placeholder="Breve descrição do produto"></textarea>
|
<option value="fas fa-heart">❤️ Favorito</option>
|
||||||
</div>
|
</select>
|
||||||
<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>
|
||||||
@ -672,32 +501,6 @@
|
|||||||
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 {
|
||||||
@ -725,51 +528,46 @@
|
|||||||
|
|
||||||
// Add link functionality via modal
|
// Add link functionality via modal
|
||||||
$('#addLinkBtn').on('click', function() {
|
$('#addLinkBtn').on('click', function() {
|
||||||
const maxlinks = @Model.MaxLinksAllowed;
|
if (linkCount >= @Model.MaxLinksAllowed) {
|
||||||
if (linkCount >= maxlinks+4) {
|
|
||||||
alert('Você atingiu o limite de links para seu plano atual.');
|
alert('Você atingiu o limite de links para seu plano atual.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle between link types
|
// Save link from modal
|
||||||
$('input[name="linkType"]').on('change', function() {
|
$(document).on('click', '#saveLinkBtn', function() {
|
||||||
const linkType = $(this).val();
|
console.log('Save button clicked');
|
||||||
if (linkType === 'Product') {
|
|
||||||
$('#normalLinkSection').hide();
|
|
||||||
$('#productLinkSection').show();
|
|
||||||
} else {
|
|
||||||
$('#normalLinkSection').show();
|
|
||||||
$('#productLinkSection').hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract product data
|
const title = $('#linkTitle').val().trim();
|
||||||
$('#extractProductBtn').on('click', function() {
|
const url = $('#linkUrl').val().trim();
|
||||||
const url = $('#productUrl').val().trim();
|
const description = $('#linkDescription').val().trim();
|
||||||
|
const icon = $('#linkIcon').val();
|
||||||
|
|
||||||
if (!url) {
|
console.log('Values:', { title, url, description, icon });
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
extractProductData(url);
|
addLinkInput(title, url, description, icon);
|
||||||
});
|
|
||||||
|
|
||||||
// Save link from modal
|
// Clear modal form
|
||||||
$(document).on('click', '#saveLinkBtn', function() {
|
$('#addLinkForm')[0].reset();
|
||||||
const linkType = $('input[name="linkType"]:checked').val();
|
|
||||||
|
|
||||||
if (linkType === 'Product') {
|
// Close modal using Bootstrap 5 syntax
|
||||||
saveProductLink();
|
var modal = bootstrap.Modal.getInstance(document.getElementById('addLinkModal'));
|
||||||
} else {
|
if (modal) {
|
||||||
saveNormalLink();
|
modal.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markStepComplete(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove link functionality
|
// Remove link functionality
|
||||||
@ -865,7 +663,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLinkInput(title = '', url = '', description = '', icon = '', linkType = 'Normal', id='new') {
|
function addLinkInput(title = '', url = '', description = '', icon = '') {
|
||||||
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
|
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
|
||||||
|
|
||||||
const linkHtml = `
|
const linkHtml = `
|
||||||
@ -897,8 +695,7 @@
|
|||||||
<label class="form-label">Descrição (opcional)</label>
|
<label class="form-label">Descrição (opcional)</label>
|
||||||
<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="${id}">
|
<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">
|
||||||
@ -915,209 +712,5 @@
|
|||||||
$(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, id='new') {
|
|
||||||
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="${id}">
|
|
||||||
<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>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (TempData["Error"] != null)
|
|
||||||
{
|
|
||||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
|
||||||
<div class="toast show" role="alert">
|
|
||||||
<div class="toast-header">
|
|
||||||
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
|
||||||
<strong class="me-auto">Atenção</strong>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
|
||||||
</div>
|
|
||||||
<div class="toast-body">
|
|
||||||
@TempData["Error"]
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|||||||
@ -26,9 +26,6 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<a asp-controller="Auth" asp-action="Login" class="btn btn-outline-light btn-lg px-4">
|
|
||||||
Entrar
|
|
||||||
</a>
|
|
||||||
<a asp-controller="Auth" asp-action="Login" class="btn btn-light btn-lg px-4">
|
<a asp-controller="Auth" asp-action="Login" class="btn btn-light btn-lg px-4">
|
||||||
Começar Grátis
|
Começar Grátis
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -100,7 +100,7 @@
|
|||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
||||||
<input type="hidden" name="planType" value="Basic" />
|
<input type="hidden" name="planType" value="basic" />
|
||||||
<button type="submit" class="btn btn-outline-primary w-100">Escolher Básico</button>
|
<button type="submit" class="btn btn-outline-primary w-100">Escolher Básico</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@ -154,7 +154,7 @@
|
|||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
||||||
<input type="hidden" name="planType" value="Professional" />
|
<input type="hidden" name="planType" value="professional" />
|
||||||
<button type="submit" class="btn btn-warning w-100">Escolher Profissional</button>
|
<button type="submit" class="btn btn-warning w-100">Escolher Profissional</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@ -212,7 +212,7 @@
|
|||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
||||||
<input type="hidden" name="planType" value="Premium" />
|
<input type="hidden" name="planType" value="premium" />
|
||||||
<button type="submit" class="btn btn-primary w-100 fw-bold">Escolher Premium</button>
|
<button type="submit" class="btn btn-primary w-100 fw-bold">Escolher Premium</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@ -343,51 +343,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (TempData["Success"] != null)
|
|
||||||
{
|
|
||||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
|
||||||
<div class="toast show" role="alert">
|
|
||||||
<div class="toast-header">
|
|
||||||
<i class="fas fa-check-circle text-success me-2"></i>
|
|
||||||
<strong class="me-auto">Sucesso</strong>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
|
||||||
</div>
|
|
||||||
<div class="toast-body">
|
|
||||||
@TempData["Success"]
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (TempData["Error"] != null)
|
|
||||||
{
|
|
||||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
|
||||||
<div class="toast show" role="alert">
|
|
||||||
<div class="toast-header">
|
|
||||||
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
|
||||||
<strong class="me-auto">Atenção</strong>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
|
||||||
</div>
|
|
||||||
<div class="toast-body">
|
|
||||||
@TempData["Error"]
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (TempData["Info"] != null)
|
|
||||||
{
|
|
||||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
|
||||||
<div class="toast show" role="alert">
|
|
||||||
<div class="toast-header">
|
|
||||||
<i class="fas fa-exclamation-triangle text-primary me-2"></i>
|
|
||||||
<strong class="me-auto">Atenção</strong>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
|
||||||
</div>
|
|
||||||
<div class="toast-body">
|
|
||||||
@TempData["Info"]
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
@using BCards.Web.ViewModels
|
|
||||||
@model ModerationDashboardViewModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Dashboard de Moderação";
|
|
||||||
Layout = "_Layout";
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<h1>Dashboard de Moderação</h1>
|
|
||||||
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h4>Sistema de Moderação</h4>
|
|
||||||
<p>Páginas pendentes: <strong>@Model.PendingPages.Count</strong></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (Model.PendingPages.Any())
|
|
||||||
{
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>Páginas Pendentes</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Nome</th>
|
|
||||||
<th>Categoria</th>
|
|
||||||
<th>Criada em</th>
|
|
||||||
<th>Ações</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var pageItem in Model.PendingPages)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@pageItem.DisplayName</td>
|
|
||||||
<td>@pageItem.Category</td>
|
|
||||||
<td>@pageItem.CreatedAt.ToString("dd/MM/yyyy")</td>
|
|
||||||
<td>
|
|
||||||
<a href="/moderation/review/@pageItem.Id" class="btn btn-sm btn-primary">
|
|
||||||
Moderar
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="alert alert-success">
|
|
||||||
<h4>✅ Nenhuma página pendente!</h4>
|
|
||||||
<p>Todas as páginas foram processadas.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
@using BCards.Web.ViewModels
|
|
||||||
@model ModerationHistoryViewModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Histórico de Moderação";
|
|
||||||
Layout = "_Layout";
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1>Histórico de Moderação</h1>
|
|
||||||
<a href="/moderation/dashboard" class="btn btn-outline-primary">
|
|
||||||
<i class="fas fa-arrow-left"></i> Voltar
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h4>Histórico</h4>
|
|
||||||
<p>Páginas processadas: <strong>@Model.Pages.Count</strong></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (Model.Pages.Any())
|
|
||||||
{
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>Páginas Processadas</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Nome</th>
|
|
||||||
<th>Categoria</th>
|
|
||||||
<th>Processada em</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var pageItem in Model.Pages)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
@if (pageItem.Status == "Active")
|
|
||||||
{
|
|
||||||
<span class="badge bg-success">Aprovada</span>
|
|
||||||
}
|
|
||||||
else if (pageItem.Status == "Rejected")
|
|
||||||
{
|
|
||||||
<span class="badge bg-danger">Rejeitada</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="badge bg-secondary">@pageItem.Status</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>@pageItem.DisplayName</td>
|
|
||||||
<td>@pageItem.Category</td>
|
|
||||||
<td>
|
|
||||||
@if (pageItem.ApprovedAt.HasValue)
|
|
||||||
{
|
|
||||||
@pageItem.ApprovedAt.Value.ToString("dd/MM/yyyy")
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="text-muted">Pendente</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="alert alert-success">
|
|
||||||
<h4>📋 Nenhum histórico ainda</h4>
|
|
||||||
<p>Ainda não há páginas processadas.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@ -1,267 +0,0 @@
|
|||||||
@using BCards.Web.ViewModels
|
|
||||||
@model ModerationReviewViewModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Revisar Página";
|
|
||||||
Layout = "_Layout";
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1>Moderar Página</h1>
|
|
||||||
<a href="/moderation/dashboard" class="btn btn-outline-secondary">
|
|
||||||
<i class="fas fa-arrow-left"></i> Voltar
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<!-- Informações da Página -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">Informações da Página</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Nome:</strong> @Model.Page.DisplayName
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Categoria:</strong>
|
|
||||||
<span class="badge bg-light text-dark">@Model.Page.Category</span>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Slug:</strong> @Model.Page.Slug
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Tipo:</strong> @Model.Page.BusinessType
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Plano:</strong>
|
|
||||||
<span class="badge bg-info">@Model.Page.PlanLimitations.PlanType</span>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Criado em:</strong> @Model.Page.CreatedAt.ToString("dd/MM/yyyy HH:mm")
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Tentativas:</strong> @Model.Page.ModerationAttempts
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Total de Links:</strong> @Model.Page.Links.Count
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Informações do Usuário -->
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">Informações do Usuário</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Nome:</strong> @Model.User.Name
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Email:</strong> @Model.User.Email
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Score:</strong> @Model.Page.UserScore
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Membro desde:</strong> @Model.User.CreatedAt.ToString("dd/MM/yyyy")
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Preview da Página -->
|
|
||||||
@if (!string.IsNullOrEmpty(Model.PreviewUrl))
|
|
||||||
{
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">Preview da Página</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<a href="@Model.PreviewUrl" target="_blank" class="btn btn-info btn-block">
|
|
||||||
<i class="fas fa-external-link-alt"></i> Abrir Preview
|
|
||||||
</a>
|
|
||||||
<small class="text-muted mt-2 d-block">
|
|
||||||
Visualizações: @Model.Page.PreviewViewCount/50
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Conteúdo da Página -->
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">Conteúdo da Página</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
@if (!string.IsNullOrEmpty(Model.Page.Bio))
|
|
||||||
{
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Biografia:</strong>
|
|
||||||
<p>@Model.Page.Bio</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Links (@Model.Page.Links.Count):</strong>
|
|
||||||
<div class="mt-2">
|
|
||||||
@foreach (var link in Model.Page.Links.OrderBy(l => l.Order))
|
|
||||||
{
|
|
||||||
<div class="card mb-2">
|
|
||||||
<div class="card-body py-2">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<strong>@link.Title</strong>
|
|
||||||
@if (link.Type == LinkType.Product)
|
|
||||||
{
|
|
||||||
<span class="badge bg-success ms-2">Produto</span>
|
|
||||||
}
|
|
||||||
<br>
|
|
||||||
<small class="text-muted">@link.Url</small>
|
|
||||||
@if (!string.IsNullOrEmpty(link.Description))
|
|
||||||
{
|
|
||||||
<br>
|
|
||||||
<small>@link.Description</small>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="@link.Url" target="_blank" class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="fas fa-external-link-alt"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Critérios de Moderação -->
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">Critérios de Moderação</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form id="moderationForm">
|
|
||||||
@foreach (var criterion in Model.ModerationCriteria)
|
|
||||||
{
|
|
||||||
<div class="mb-4">
|
|
||||||
<h6 class="text-danger">🚫 @criterion.Category</h6>
|
|
||||||
@foreach (var item in criterion.Items)
|
|
||||||
{
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" name="issues" value="@item" id="issue_@(item.GetHashCode())">
|
|
||||||
<label class="form-check-label" for="issue_@(item.GetHashCode())">
|
|
||||||
@item
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Histórico de Moderação -->
|
|
||||||
@if (Model.Page.ModerationHistory.Any())
|
|
||||||
{
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">Histórico de Moderação</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
@foreach (var history in Model.Page.ModerationHistory.OrderByDescending(h => h.Date))
|
|
||||||
{
|
|
||||||
<div class="mb-3 pb-3 border-bottom">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<strong>Tentativa @history.Attempt</strong>
|
|
||||||
<small class="text-muted">@history.Date.ToString("dd/MM/yyyy HH:mm")</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="badge bg-@(history.Status == "approved" ? "success" : "danger")">
|
|
||||||
@(history.Status == "approved" ? "Aprovada" : "Rejeitada")
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@if (!string.IsNullOrEmpty(history.Reason))
|
|
||||||
{
|
|
||||||
<div class="mt-2">
|
|
||||||
<strong>Motivo:</strong> @history.Reason
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (history.Issues.Any())
|
|
||||||
{
|
|
||||||
<div class="mt-2">
|
|
||||||
<strong>Problemas:</strong>
|
|
||||||
<ul class="mb-0">
|
|
||||||
@foreach (var issue in history.Issues)
|
|
||||||
{
|
|
||||||
<li>@issue</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Ações de Moderação -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">Ações de Moderação</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<form asp-action="Approve" asp-route-id="@Model.Page.Id" method="post">
|
|
||||||
@Html.AntiForgeryToken()
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="notes" class="form-label">Notas (opcional)</label>
|
|
||||||
<textarea class="form-control" id="notes" name="notes" rows="3"
|
|
||||||
placeholder="Observações sobre a aprovação..."></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success btn-block">
|
|
||||||
<i class="fas fa-check"></i> Aprovar Página
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<form asp-action="Reject" asp-route-id="@Model.Page.Id" method="post">
|
|
||||||
@Html.AntiForgeryToken()
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="reason" class="form-label">Motivo da Rejeição *</label>
|
|
||||||
<textarea class="form-control" id="reason" name="reason" rows="3"
|
|
||||||
placeholder="Explique o motivo da rejeição..." required></textarea>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="selectedIssues" name="issues" value="">
|
|
||||||
<button type="submit" class="btn btn-danger btn-block">
|
|
||||||
<i class="fas fa-times"></i> Rejeitar Página
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<script>
|
|
||||||
document.querySelector('form[asp-action="Reject"]').addEventListener('submit', function(e) {
|
|
||||||
const checkedIssues = Array.from(document.querySelectorAll('input[name="issues"]:checked'))
|
|
||||||
.map(cb => cb.value);
|
|
||||||
document.getElementById('selectedIssues').value = JSON.stringify(checkedIssues);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
@model bool
|
|
||||||
|
|
||||||
@if (Model)
|
|
||||||
{
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle text-warning fw-bold" href="#" id="moderationDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="fas fa-shield-alt me-1"></i>Moderação
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu" aria-labelledby="moderationDropdown">
|
|
||||||
<li>
|
|
||||||
<h6 class="dropdown-header">
|
|
||||||
<i class="fas fa-shield-alt me-2"></i>Área de Moderação
|
|
||||||
</h6>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="/moderation/dashboard">
|
|
||||||
<i class="fas fa-tachometer-alt me-2"></i>Dashboard
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="/moderation/history">
|
|
||||||
<i class="fas fa-history me-2"></i>Histórico
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item text-muted small" href="#" onclick="return false;">
|
|
||||||
<i class="fas fa-info-circle me-2"></i>Você é um moderador
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
@ -31,7 +31,6 @@
|
|||||||
<meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital" />
|
<meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital" />
|
||||||
}
|
}
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
||||||
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
||||||
@ -57,30 +56,21 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Pricing">Planos</a>
|
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Pricing">Planos</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@* Menu de Moderação via ViewComponent *@
|
|
||||||
@await Component.InvokeAsync("ModerationMenu")
|
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Dashboard">
|
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Dashboard">Dashboard</a>
|
||||||
<i class="fas fa-user me-1"></i>Dashboard
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Logout">
|
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Logout">Sair</a>
|
||||||
<i class="fas fa-sign-out-alt me-1"></i>Sair
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Login">
|
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Login">Entrar</a>
|
||||||
<i class="fas fa-sign-in-alt me-1"></i>Entrar
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
@model BCards.Web.Models.PageTheme
|
@model BCards.Web.Models.PageTheme
|
||||||
|
|
||||||
@{
|
@{
|
||||||
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"
|
};
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@ -16,27 +14,28 @@
|
|||||||
--secondary-color: @theme.SecondaryColor;
|
--secondary-color: @theme.SecondaryColor;
|
||||||
--background-color: @theme.BackgroundColor;
|
--background-color: @theme.BackgroundColor;
|
||||||
--text-color: @theme.TextColor;
|
--text-color: @theme.TextColor;
|
||||||
--card-bg: rgba(255, 255, 255, 0.95);
|
|
||||||
--border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-page {
|
.user-page {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
min-height: 100vh;
|
@if (!string.IsNullOrEmpty(theme.BackgroundImage))
|
||||||
padding: 2rem 0;
|
{
|
||||||
|
@:background-image: url('@theme.BackgroundImage');
|
||||||
|
@:background-size: cover;
|
||||||
|
@:background-position: center;
|
||||||
|
@:background-attachment: fixed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card {
|
.profile-card {
|
||||||
background-color: var(--card-bg);
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-image {
|
.profile-image {
|
||||||
@ -53,20 +52,15 @@
|
|||||||
height: 120px;
|
height: 120px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 4px solid var(--primary-color);
|
border: 4px solid var(--primary-color);
|
||||||
background-color: var(--card-bg);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 3rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-name {
|
.profile-name {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 1rem 0 0.5rem 0;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-bio {
|
.profile-bio {
|
||||||
@ -76,331 +70,74 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== LINKS CONTAINER ========== */
|
|
||||||
.links-container {
|
.links-container {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== UNIVERSAL LINK STYLE (TODOS OS LINKS IGUAIS) ========== */
|
.link-button {
|
||||||
.universal-link {
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.universal-link:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.universal-link-header {
|
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
color: white !important;
|
color: white !important;
|
||||||
padding: 0.75rem 1rem;
|
border: none;
|
||||||
display: flex;
|
padding: 1rem 2rem;
|
||||||
align-items: center;
|
border-radius: 50px;
|
||||||
justify-content: space-between;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
display: block;
|
||||||
text-decoration: none !important;
|
margin-bottom: 1rem;
|
||||||
transition: background-color 0.3s ease;
|
text-align: center;
|
||||||
position: relative;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.universal-link-header:hover {
|
.link-button:hover {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||||
color: white !important;
|
color: white !important;
|
||||||
text-decoration: none !important;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.universal-link-content {
|
.link-button:active {
|
||||||
display: flex;
|
transform: translateY(0);
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Thumbnail para produtos */
|
|
||||||
.link-thumbnail {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 6px;
|
|
||||||
object-fit: cover;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ícone para links normais */
|
|
||||||
.link-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-text-container {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-title {
|
.link-title {
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: white;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.2;
|
|
||||||
/* Truncate long titles */
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-subtitle {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.1;
|
|
||||||
/* Truncate long subtitles */
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Seta de expansão */
|
|
||||||
.expand-arrow {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
border-radius: 6px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-arrow:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-arrow i {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-arrow.expanded i {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Conteúdo expandido */
|
|
||||||
.universal-link-details {
|
|
||||||
padding: 0;
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
max-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: max-height 0.3s ease-out, padding 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.universal-link-details.show {
|
|
||||||
max-height: 400px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Imagem expandida para produtos */
|
|
||||||
.expanded-image {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 200px;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
display: block;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded-description {
|
|
||||||
color: var(--text-color);
|
|
||||||
opacity: 0.8;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
|
|
||||||
max-height: 150px; /* Ajuste conforme necessário */
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 0.5rem; /* Espaço para a scrollbar */
|
|
||||||
|
|
||||||
/* Styling da scrollbar (opcional) */
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--primary-color) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded-description::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded-description::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded-description::-webkit-scrollbar-thumb {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded-price {
|
|
||||||
color: #28a745;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.25rem;
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded-action {
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== FOOTER ========== */
|
|
||||||
.profile-footer {
|
|
||||||
margin-top: 2rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-promo {
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-promo:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-promo-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-description {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-promo-header i {
|
.link-icon {
|
||||||
transition: transform 0.3s ease;
|
font-size: 1.2rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-promo-header.expanded i {
|
.profile-footer {
|
||||||
transform: rotate(180deg);
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-promo-content {
|
.profile-footer a {
|
||||||
margin-top: 0.5rem;
|
|
||||||
max-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: max-height 0.3s ease-out, margin-top 0.3s ease;
|
|
||||||
color: var(--text-color);
|
|
||||||
opacity: 0.8;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-promo-content.show {
|
|
||||||
max-height: 200px;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-promo-button {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white !important;
|
|
||||||
border: none;
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-decoration: none !important;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-promo-button:hover {
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
color: white !important;
|
|
||||||
text-decoration: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-credits {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-color);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-credits a {
|
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-credits a:hover {
|
.profile-footer a:hover {
|
||||||
color: var(--secondary-color);
|
color: var(--secondary-color);
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== ANIMATIONS ========== */
|
/* Responsive Design */
|
||||||
@@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
max-height: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
max-height: 400px;
|
|
||||||
padding-top: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== RESPONSIVE DESIGN ========== */
|
|
||||||
@@media (max-width: 768px) {
|
@@media (max-width: 768px) {
|
||||||
.user-page {
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-card {
|
.profile-card {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin: 0 1rem;
|
margin: 1rem;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -414,39 +151,28 @@
|
|||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.universal-link-header {
|
.profile-bio {
|
||||||
padding: 0.65rem 0.8rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-title {
|
.link-button {
|
||||||
|
padding: 0.875rem 1.5rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-subtitle {
|
.link-title {
|
||||||
font-size: 0.8rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-thumbnail,
|
.link-description {
|
||||||
.link-icon {
|
font-size: 0.85rem;
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-arrow {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-arrow i {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@media (max-width: 480px) {
|
@@media (max-width: 480px) {
|
||||||
.profile-card {
|
.profile-card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 0 0.5rem;
|
margin: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-image,
|
.profile-image,
|
||||||
@ -459,57 +185,31 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.universal-link-header {
|
.link-button {
|
||||||
padding: 0.6rem 0.8rem;
|
padding: 0.75rem 1.25rem;
|
||||||
}
|
|
||||||
|
|
||||||
.link-title {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-subtitle {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-thumbnail,
|
|
||||||
.link-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-arrow {
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded-image {
|
|
||||||
max-width: 150px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== DARK THEME COMPATIBILITY ========== */
|
/* Dark theme adjustments */
|
||||||
.user-page[data-theme="dark"] .profile-card,
|
@@media (prefers-color-scheme: dark) {
|
||||||
.user-page[data-theme="dark"] .universal-link,
|
.user-page[data-theme="dark"] .profile-card {
|
||||||
.user-page[data-theme="dark"] .footer-promo {
|
background-color: rgba(17, 24, 39, 0.95);
|
||||||
background-color: rgba(31, 41, 55, 0.95);
|
color: #f9fafb;
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-page[data-theme="dark"] .universal-link-details,
|
/* Animation for link buttons */
|
||||||
.user-page[data-theme="dark"] .footer-promo-content {
|
.link-button::before {
|
||||||
background-color: rgba(31, 41, 55, 0.95);
|
content: '';
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
|
transition: left 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Accessibility */
|
.link-button:hover::before {
|
||||||
.universal-link-header:focus,
|
left: 100%;
|
||||||
.expand-arrow:focus {
|
|
||||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smooth scroll for mobile */
|
|
||||||
.universal-link-details {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
@ -42,9 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" />
|
||||||
|
|
||||||
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
||||||
|
|
||||||
@await RenderSectionAsync("Styles", required: false)
|
@await RenderSectionAsync("Styles", required: false)
|
||||||
|
|||||||
@ -8,18 +8,20 @@
|
|||||||
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Styles {
|
@if (!isPreview)
|
||||||
|
{
|
||||||
|
@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))
|
||||||
var partialOutput = await Html.PartialAsync("_ThemeStyles", Model.Theme);
|
|
||||||
using (var writer = new System.IO.StringWriter())
|
|
||||||
{
|
|
||||||
partialOutput.WriteTo(writer, HtmlEncoder);
|
|
||||||
@Html.Raw(writer.ToString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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">
|
||||||
@ -34,8 +36,8 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="profile-image-placeholder mb-3 mx-auto">
|
<div class="profile-image-placeholder mb-3 mx-auto d-flex align-items-center justify-content-center">
|
||||||
👤
|
<i class="fs-1">👤</i>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,7 +49,7 @@
|
|||||||
<p class="profile-bio">@Model.Bio</p>
|
<p class="profile-bio">@Model.Bio</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Links Container -->
|
<!-- Links -->
|
||||||
<div class="links-container">
|
<div class="links-container">
|
||||||
@if (Model.Links?.Any(l => l.IsActive) == true)
|
@if (Model.Links?.Any(l => l.IsActive) == true)
|
||||||
{
|
{
|
||||||
@ -56,122 +58,24 @@
|
|||||||
var link = Model.Links[i];
|
var link = Model.Links[i];
|
||||||
if (link.IsActive)
|
if (link.IsActive)
|
||||||
{
|
{
|
||||||
var hasExpandableContent = (!string.IsNullOrEmpty(link.Description) ||
|
<a href="@link.Url"
|
||||||
(link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductDescription)));
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
<!-- Universal Link Style (TODOS OS LINKS IGUAIS) -->
|
class="link-button"
|
||||||
<div class="universal-link" data-link-id="@i">
|
data-link-index="@i"
|
||||||
<!-- Header clicável (vai para o link) -->
|
onclick="recordClick('@Model.Id', @i)">
|
||||||
<a href="@link.Url"
|
@if (!string.IsNullOrEmpty(link.Icon))
|
||||||
class="universal-link-header"
|
|
||||||
onclick="recordClick('@Model.Id', @i)"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
|
|
||||||
<div class="universal-link-content">
|
|
||||||
@if (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductImage))
|
|
||||||
{
|
|
||||||
<!-- Thumbnail para produtos -->
|
|
||||||
<img src="@link.ProductImage"
|
|
||||||
alt="@(link.ProductTitle ?? link.Title)"
|
|
||||||
class="link-thumbnail"
|
|
||||||
loading="lazy"
|
|
||||||
onerror="this.style.display='none'">
|
|
||||||
}
|
|
||||||
else if (!string.IsNullOrEmpty(link.Icon))
|
|
||||||
{
|
|
||||||
<!-- Ícone para links normais -->
|
|
||||||
<div class="link-icon">
|
|
||||||
<i class="@link.Icon"></i>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<!-- Ícone padrão se não tiver -->
|
|
||||||
<div class="link-icon">
|
|
||||||
<i class="fas fa-link"></i>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="link-text-container">
|
|
||||||
<div class="link-title">
|
|
||||||
@if (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductTitle))
|
|
||||||
{
|
|
||||||
@link.ProductTitle
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@link.Title
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductPrice))
|
|
||||||
{
|
|
||||||
<div class="link-subtitle">@link.ProductPrice</div>
|
|
||||||
}
|
|
||||||
else if (!string.IsNullOrEmpty(link.Description) && link.Description.Length > 50)
|
|
||||||
{
|
|
||||||
<div class="link-subtitle">@(link.Description.Substring(0, 50))...</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (hasExpandableContent)
|
|
||||||
{
|
|
||||||
<!-- Seta de expansão (só aparece se tem conteúdo expandível) -->
|
|
||||||
<button class="expand-arrow"
|
|
||||||
type="button"
|
|
||||||
onclick="event.preventDefault(); event.stopPropagation(); toggleLinkDetails(@i)">
|
|
||||||
<i class="fas fa-chevron-down"></i>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
@if (hasExpandableContent)
|
|
||||||
{
|
{
|
||||||
<!-- Conteúdo expandível -->
|
<i class="@link.Icon"></i>
|
||||||
<div class="universal-link-details" id="details-@i">
|
|
||||||
@if (link.Type == BCards.Web.Models.LinkType.Product)
|
|
||||||
{
|
|
||||||
<!-- Conteúdo expandido para produtos -->
|
|
||||||
@if (!string.IsNullOrEmpty(link.ProductImage))
|
|
||||||
{
|
|
||||||
<img src="@link.ProductImage"
|
|
||||||
alt="@(link.ProductTitle ?? link.Title)"
|
|
||||||
class="expanded-image"
|
|
||||||
loading="lazy">
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(link.ProductPrice))
|
|
||||||
{
|
|
||||||
<div class="expanded-price">@link.ProductPrice</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(link.ProductDescription))
|
|
||||||
{
|
|
||||||
<div class="expanded-description">
|
|
||||||
@link.ProductDescription
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<!-- Conteúdo expandido para links normais -->
|
|
||||||
@if (!string.IsNullOrEmpty(link.Description))
|
|
||||||
{
|
|
||||||
<div class="expanded-description">
|
|
||||||
@link.Description
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="expanded-action">
|
|
||||||
<i class="fas fa-external-link-alt"></i>
|
|
||||||
Clique no título acima para abrir
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
<div>
|
||||||
|
<div class="link-title">@link.Title</div>
|
||||||
|
@if (!string.IsNullOrEmpty(link.Description))
|
||||||
|
{
|
||||||
|
<div class="link-description">@link.Description</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -184,29 +88,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="profile-footer">
|
<div class="profile-footer mt-4 pt-3 border-top">
|
||||||
|
<small class="text-muted">
|
||||||
<!-- Promoção BCards -->
|
Criado com <a href="@Url.Action("Index", "Home")" class="text-decoration-none">BCards</a>
|
||||||
<div class="footer-promo" onclick="togglePromo(this)">
|
</small>
|
||||||
<div class="footer-promo-header">
|
|
||||||
<span>💡 Gostou desta página?</span>
|
|
||||||
<i class="fas fa-chevron-down"></i>
|
|
||||||
</div>
|
|
||||||
<div class="footer-promo-content">
|
|
||||||
Crie a sua própria página personalizada com <strong>BCards</strong>!
|
|
||||||
É rápido, fácil e profissional. Compartilhe todos os seus links em um só lugar.
|
|
||||||
<div class="mt-2">
|
|
||||||
<a href="@Url.Action("Index", "Home")" class="footer-promo-button">
|
|
||||||
<i class="fas fa-rocket"></i>
|
|
||||||
Criar Minha Página
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer-credits">
|
|
||||||
Criado com <a href="@Url.Action("Index", "Home")">BCards</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -223,8 +108,8 @@
|
|||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script>
|
<script>
|
||||||
// Função original de rastreamento de cliques
|
|
||||||
function recordClick(pageId, linkIndex) {
|
function recordClick(pageId, linkIndex) {
|
||||||
|
// Record click asynchronously
|
||||||
fetch('/click/' + pageId, {
|
fetch('/click/' + pageId, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -235,82 +120,5 @@
|
|||||||
console.log('Error recording click:', error);
|
console.log('Error recording click:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle link details (função universal para todos os links)
|
|
||||||
function toggleLinkDetails(linkIndex) {
|
|
||||||
const currentDetails = document.getElementById('details-' + linkIndex);
|
|
||||||
const currentArrow = document.querySelector('[data-link-id="' + linkIndex + '"] .expand-arrow');
|
|
||||||
|
|
||||||
if (!currentDetails || !currentArrow) return;
|
|
||||||
|
|
||||||
const isCurrentlyExpanded = currentDetails.classList.contains('show');
|
|
||||||
|
|
||||||
// Fechar todos os outros links primeiro (auto-close)
|
|
||||||
const allDetails = document.querySelectorAll('.universal-link-details');
|
|
||||||
const allArrows = document.querySelectorAll('.expand-arrow');
|
|
||||||
|
|
||||||
allDetails.forEach(details => {
|
|
||||||
details.classList.remove('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
allArrows.forEach(arrow => {
|
|
||||||
arrow.classList.remove('expanded');
|
|
||||||
const icon = arrow.querySelector('i');
|
|
||||||
if (icon) {
|
|
||||||
icon.style.transform = 'rotate(0deg)';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Se não estava expandido, expandir este
|
|
||||||
if (!isCurrentlyExpanded) {
|
|
||||||
currentDetails.classList.add('show');
|
|
||||||
currentArrow.classList.add('expanded');
|
|
||||||
const icon = currentArrow.querySelector('i');
|
|
||||||
if (icon) {
|
|
||||||
icon.style.transform = 'rotate(180deg)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle footer promo
|
|
||||||
function togglePromo(element) {
|
|
||||||
const content = element.querySelector('.footer-promo-content');
|
|
||||||
const arrow = element.querySelector('.footer-promo-header i');
|
|
||||||
|
|
||||||
if (content.classList.contains('show')) {
|
|
||||||
content.classList.remove('show');
|
|
||||||
arrow.style.transform = 'rotate(0deg)';
|
|
||||||
element.querySelector('.footer-promo-header').classList.remove('expanded');
|
|
||||||
} else {
|
|
||||||
content.classList.add('show');
|
|
||||||
arrow.style.transform = 'rotate(180deg)';
|
|
||||||
element.querySelector('.footer-promo-header').classList.add('expanded');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize page
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Garantir que todos os accordions comecem fechados
|
|
||||||
const allDetails = document.querySelectorAll('.universal-link-details, .footer-promo-content');
|
|
||||||
allDetails.forEach(detail => {
|
|
||||||
detail.classList.remove('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
const allArrows = document.querySelectorAll('.expand-arrow i, .footer-promo-header i');
|
|
||||||
allArrows.forEach(arrow => {
|
|
||||||
arrow.style.transform = 'rotate(0deg)';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Adicionar eventos de teclado para acessibilidade
|
|
||||||
const expandButtons = document.querySelectorAll('.expand-arrow');
|
|
||||||
expandButtons.forEach(button => {
|
|
||||||
button.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
button.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
@ -1,30 +0,0 @@
|
|||||||
@{
|
|
||||||
ViewData["Title"] = "Página Rejeitada";
|
|
||||||
Layout = "_Layout";
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body text-center py-5">
|
|
||||||
<div class="mb-4">
|
|
||||||
<i class="fas fa-times-circle text-danger fa-4x"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="h4 mb-3">Página Rejeitada</h2>
|
|
||||||
<p class="lead mb-4">
|
|
||||||
Esta página foi rejeitada na moderação e não está disponível publicamente.
|
|
||||||
</p>
|
|
||||||
<p class="text-muted mb-4">
|
|
||||||
O conteúdo não atende aos nossos termos de uso ou padrões de qualidade.
|
|
||||||
<br>
|
|
||||||
<strong>Proprietário:</strong> Verifique seu painel para mais detalhes
|
|
||||||
</p>
|
|
||||||
<a href="/" class="btn btn-primary">
|
|
||||||
<i class="fas fa-home"></i> Voltar ao Início
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
@{
|
|
||||||
ViewData["Title"] = "Página em Análise";
|
|
||||||
Layout = "_Layout";
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body text-center py-5">
|
|
||||||
<div class="mb-4">
|
|
||||||
<i class="fas fa-hourglass-half text-warning fa-4x"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="h4 mb-3">Página em Análise</h2>
|
|
||||||
<p class="lead mb-4">
|
|
||||||
Esta página está sendo analisada por nossa equipe de moderação.
|
|
||||||
</p>
|
|
||||||
<p class="text-muted mb-4">
|
|
||||||
Estamos verificando se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.
|
|
||||||
<br>
|
|
||||||
<strong>Tempo estimado:</strong> 3-7 dias úteis
|
|
||||||
</p>
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
<strong>Proprietário da página:</strong> Verifique seu email para o link de preview
|
|
||||||
</div>
|
|
||||||
<a href="/" class="btn btn-primary">
|
|
||||||
<i class="fas fa-home"></i> Voltar ao Início
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
@{
|
|
||||||
ViewData["Title"] = "Preview Expirado";
|
|
||||||
Layout = "_Layout";
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-body text-center py-5">
|
|
||||||
<div class="mb-4">
|
|
||||||
<i class="fas fa-clock text-warning fa-4x"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="h4 mb-3">Preview Expirado</h2>
|
|
||||||
<p class="lead mb-4">
|
|
||||||
O link de preview que você acessou não é mais válido.
|
|
||||||
</p>
|
|
||||||
<p class="text-muted mb-4">
|
|
||||||
Isso pode acontecer se:
|
|
||||||
<br>
|
|
||||||
• O link expirou (30 dias)
|
|
||||||
<br>
|
|
||||||
• Excedeu o limite de visualizações (50)
|
|
||||||
<br>
|
|
||||||
• A página já foi processada
|
|
||||||
</p>
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
<strong>Proprietário:</strong> Acesse seu painel para ver o status atual
|
|
||||||
</div>
|
|
||||||
<a href="/" class="btn btn-primary">
|
|
||||||
<i class="fas fa-home"></i> Voltar ao Início
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -11,14 +11,14 @@
|
|||||||
"DatabaseName": "BCardsDB"
|
"DatabaseName": "BCardsDB"
|
||||||
},
|
},
|
||||||
"Stripe": {
|
"Stripe": {
|
||||||
"PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS",
|
"PublishableKey": "pk_test_your_publishable_key_here",
|
||||||
"SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO",
|
"SecretKey": "sk_test_your_secret_key_here",
|
||||||
"WebhookSecret": "whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543"
|
"WebhookSecret": "whsec_your_webhook_secret_here"
|
||||||
},
|
},
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Google": {
|
"Google": {
|
||||||
"ClientId": "472850008574-nmeepbdt4hunsk5c8krpbdmd3olc4jv6.apps.googleusercontent.com",
|
"ClientId": "your_google_client_id",
|
||||||
"ClientSecret": "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"
|
"ClientSecret": "your_google_client_secret"
|
||||||
},
|
},
|
||||||
"Microsoft": {
|
"Microsoft": {
|
||||||
"ClientId": "b411606a-e574-4f59-b7cd-10dd941b9fa3",
|
"ClientId": "b411606a-e574-4f59-b7cd-10dd941b9fa3",
|
||||||
@ -27,42 +27,22 @@
|
|||||||
},
|
},
|
||||||
"Plans": {
|
"Plans": {
|
||||||
"Basic": {
|
"Basic": {
|
||||||
"PriceId": "price_1RjUskBMIadsOxJVgLwlVo1y",
|
"PriceId": "price_basic_monthly",
|
||||||
"Price": 9.90,
|
"Price": 9.90,
|
||||||
"MaxLinks": 5,
|
"MaxLinks": 5,
|
||||||
"Features": [ "basic_themes", "simple_analytics" ]
|
"Features": ["basic_themes", "simple_analytics"]
|
||||||
},
|
},
|
||||||
"Professional": {
|
"Professional": {
|
||||||
"PriceId": "price_1RjUv9BMIadsOxJVORqlM4E9",
|
"PriceId": "price_professional_monthly",
|
||||||
"Price": 24.90,
|
"Price": 24.90,
|
||||||
"MaxLinks": 15,
|
"MaxLinks": 15,
|
||||||
"Features": [ "all_themes", "advanced_analytics", "custom_domain" ]
|
"Features": ["all_themes", "advanced_analytics", "custom_domain"]
|
||||||
},
|
},
|
||||||
"Premium": {
|
"Premium": {
|
||||||
"PriceId": "price_1RjUw0BMIadsOxJVmdouNV1g",
|
"PriceId": "price_premium_monthly",
|
||||||
"Price": 29.90,
|
"Price": 29.90,
|
||||||
"MaxLinks": -1,
|
"MaxLinks": -1,
|
||||||
"Features": [ "custom_themes", "full_analytics", "multiple_domains", "priority_support" ]
|
"Features": ["custom_themes", "full_analytics", "multiple_domains", "priority_support"]
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"Moderation": {
|
|
||||||
"PriorityTimeframes": {
|
|
||||||
"Trial": "7.00:00:00",
|
|
||||||
"Basic": "7.00:00:00",
|
|
||||||
"Professional": "3.00:00:00",
|
|
||||||
"Premium": "1.00:00:00"
|
|
||||||
},
|
|
||||||
"MaxAttempts": 3,
|
|
||||||
"ModeratorEmail": "ricardo.carneiro@jobmaker.com.br",
|
|
||||||
"ModeratorEmails": [
|
|
||||||
"rrcgoncalves@gmail.com",
|
|
||||||
"rirocarneiro@gmail.com"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"SendGrid": {
|
|
||||||
"ApiKey": "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",
|
|
||||||
"FromEmail": "ricardo.carneiro@jobmaker.com.br",
|
|
||||||
"FromName": "Ricardo Carneiro"
|
|
||||||
},
|
|
||||||
"BaseUrl": "https://bcards.site"
|
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user