feat: upload de PDF

This commit is contained in:
Ricardo Carneiro 2025-11-03 00:04:16 -03:00
parent 0803a3bcc9
commit 230c6a958d
21 changed files with 1196 additions and 257 deletions

View File

@ -18,7 +18,7 @@ Um clone profissional do LinkTree desenvolvido em ASP.NET Core MVC, focado no me
### 🎯 Planos e Pricing (Estratégia Decoy) ### 🎯 Planos e Pricing (Estratégia Decoy)
- **Básico** (R$ 12,90/mês): 5 links, temas básicos, analytics simples - **Básico** (R$ 12,90/mês): 5 links, temas básicos, analytics simples
- **Profissional** (R$ 25,90/mês): 15 links, todos os temas, analytics avançado, domínio personalizado *(DECOY)* - **Profissional** (R$ 25,90/mês): 15 links, todos os temas, analytics avançado, domínio personalizado *(DECOY)*
- **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios - **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios, upload de PDFs
## 🛠️ Tecnologias ## 🛠️ Tecnologias

View File

@ -5,9 +5,13 @@ using BCards.Web.ViewModels;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Configuration;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Linq;
using MongoDB.Bson;
using System.Collections.Generic;
namespace BCards.Web.Controllers; namespace BCards.Web.Controllers;
@ -23,9 +27,11 @@ public class AdminController : Controller
private readonly IEmailService _emailService; private readonly IEmailService _emailService;
private readonly ILivePageService _livePageService; private readonly ILivePageService _livePageService;
private readonly IImageStorageService _imageStorage; private readonly IImageStorageService _imageStorage;
private readonly IDocumentStorageService _documentStorage;
private readonly IPaymentService _paymentService; private readonly IPaymentService _paymentService;
private readonly IDowngradeService _downgradeService; private readonly IDowngradeService _downgradeService;
private readonly ILogger<AdminController> _logger; private readonly ILogger<AdminController> _logger;
private readonly IConfiguration _configuration;
public AdminController( public AdminController(
IAuthService authService, IAuthService authService,
@ -36,9 +42,11 @@ public class AdminController : Controller
IEmailService emailService, IEmailService emailService,
ILivePageService livePageService, ILivePageService livePageService,
IImageStorageService imageStorage, IImageStorageService imageStorage,
IDocumentStorageService documentStorage,
IPaymentService paymentService, IPaymentService paymentService,
IDowngradeService downgradeService, IDowngradeService downgradeService,
ILogger<AdminController> logger) ILogger<AdminController> logger,
IConfiguration configuration)
{ {
_authService = authService; _authService = authService;
_userPageService = userPageService; _userPageService = userPageService;
@ -48,9 +56,11 @@ public class AdminController : Controller
_emailService = emailService; _emailService = emailService;
_livePageService = livePageService; _livePageService = livePageService;
_imageStorage = imageStorage; _imageStorage = imageStorage;
_documentStorage = documentStorage;
_paymentService = paymentService; _paymentService = paymentService;
_downgradeService = downgradeService; _downgradeService = downgradeService;
_logger = logger; _logger = logger;
_configuration = configuration;
} }
@ -175,7 +185,11 @@ public class AdminController : Controller
AvailableCategories = categories, AvailableCategories = categories,
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(),
AllowProductLinks = planLimitations.AllowProductLinks AllowProductLinks = planLimitations.AllowProductLinks,
AllowDocumentUpload = planLimitations.AllowDocumentUpload,
MaxDocumentsAllowed = planLimitations.MaxDocuments,
Documents = new List<ManageDocumentViewModel>(),
DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay()
}; };
return View(model); return View(model);
} }
@ -214,6 +228,12 @@ public class AdminController : Controller
return RedirectToAction("Login", "Auth"); return RedirectToAction("Login", "Auth");
userId = user.Id; userId = user.Id;
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var parsedPlan) ? parsedPlan : PlanType.Trial;
var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString());
model.AllowProductLinks = planLimitations.AllowProductLinks;
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
// Limpar campos de redes sociais que são apenas espaços (tratados como vazios) // Limpar campos de redes sociais que são apenas espaços (tratados como vazios)
CleanSocialMediaFields(model); CleanSocialMediaFields(model);
@ -255,13 +275,13 @@ public class AdminController : Controller
TempData["ImageError"] = errorMessage; TempData["ImageError"] = errorMessage;
// Preservar dados do form e repopular dropdowns // Preservar dados do form e repopular dropdowns
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString());
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(); model.MaxLinksAllowed = userPlanType.GetMaxLinksPerPage();
model.AllowProductLinks = planLimitations.AllowProductLinks; model.AllowProductLinks = planLimitations.AllowProductLinks;
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
// Preservar ProfileImageId existente se estava editando // Preservar ProfileImageId existente se estava editando
if (!model.IsNewPage && !string.IsNullOrEmpty(model.Id)) if (!model.IsNewPage && !string.IsNullOrEmpty(model.Id))
@ -277,6 +297,8 @@ public class AdminController : Controller
} }
} }
var (processedDocuments, removedFileIds, newFileIds) = await BuildDocumentsAsync(model, planLimitations);
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
var sbError = new StringBuilder(); var sbError = new StringBuilder();
@ -297,6 +319,10 @@ public class AdminController : Controller
model.Slug = slug; model.Slug = slug;
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.AllowProductLinks = planLimitations.AllowProductLinks;
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model); return View(model);
} }
@ -304,7 +330,6 @@ public class AdminController : Controller
{ {
// CRITICAL: Check if user can create new page (validate MaxPages limit) // CRITICAL: Check if user can create new page (validate MaxPages limit)
var existingPages = await _userPageService.GetUserPagesAsync(user.Id); var existingPages = await _userPageService.GetUserPagesAsync(user.Id);
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var maxPages = userPlanType.GetMaxPages(); var maxPages = userPlanType.GetMaxPages();
if (existingPages.Count >= maxPages) if (existingPages.Count >= maxPages)
@ -312,6 +337,7 @@ public class AdminController : Controller
TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas."; TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas.";
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model); return View(model);
} }
@ -327,6 +353,7 @@ public class AdminController : Controller
ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra."); ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model); return View(model);
} }
@ -337,6 +364,7 @@ public class AdminController : Controller
ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual."); ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model); return View(model);
} }
@ -344,6 +372,7 @@ public class AdminController : Controller
{ {
// Create new page // Create new page
var userPage = await MapToUserPage(model, user.Id); var userPage = await MapToUserPage(model, user.Id);
userPage.Documents = processedDocuments;
_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 Creating for new pages // Set status to Creating for new pages
@ -362,6 +391,7 @@ 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();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}"; TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}";
return View(model); return View(model);
} }
@ -371,7 +401,15 @@ public class AdminController : Controller
// Update existing page // Update existing page
var existingPage = await _userPageService.GetPageByIdAsync(model.Id); var existingPage = await _userPageService.GetPageByIdAsync(model.Id);
if (existingPage == null || existingPage.UserId != user.Id) if (existingPage == null || existingPage.UserId != user.Id)
{
await CleanupNewDocumentsAsync(newFileIds);
return NotFound(); return NotFound();
}
if (!planLimitations.AllowDocumentUpload && existingPage.Documents?.Any() == true)
{
removedFileIds.AddRange(existingPage.Documents.Select(d => d.FileId));
}
// Check if user can create pages (for users with rejected pages) // Check if user can create pages (for users with rejected pages)
var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id); var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id);
@ -397,7 +435,7 @@ public class AdminController : Controller
} }
} }
await UpdateUserPageFromModel(existingPage, model); await UpdateUserPageFromModel(existingPage, model, processedDocuments);
// Set status to PendingModeration for updates // Set status to PendingModeration for updates
existingPage.Status = ViewModels.PageStatus.Creating; existingPage.Status = ViewModels.PageStatus.Creating;
@ -405,6 +443,21 @@ public class AdminController : Controller
await _userPageService.UpdatePageAsync(existingPage); await _userPageService.UpdatePageAsync(existingPage);
if (removedFileIds.Count > 0)
{
foreach (var fileId in removedFileIds)
{
try
{
await _documentStorage.DeleteDocumentAsync(fileId);
}
catch (Exception cleanupEx)
{
_logger.LogWarning(cleanupEx, "Erro ao remover documento antigo {FileId}", fileId);
}
}
}
// Token será gerado apenas quando usuário clicar "Testar Página" // Token será gerado apenas quando usuário clicar "Testar Página"
// Send email to user // Send email to user
@ -745,6 +798,8 @@ public class AdminController : Controller
private async Task<ManagePageViewModel> MapToManageViewModel(UserPage page, List<Category> categories, List<PageTheme> themes, PlanType userPlanType) private async Task<ManagePageViewModel> MapToManageViewModel(UserPage page, List<Category> categories, List<PageTheme> themes, PlanType userPlanType)
{ {
var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString());
return new ManagePageViewModel return new ManagePageViewModel
{ {
Id = page.Id, Id = page.Id,
@ -772,10 +827,23 @@ public class AdminController : Controller
ProductDescription = l.ProductDescription, ProductDescription = l.ProductDescription,
ProductDataCachedAt = l.ProductDataCachedAt ProductDataCachedAt = l.ProductDataCachedAt
}).ToList() ?? new List<ManageLinkViewModel>(), }).ToList() ?? new List<ManageLinkViewModel>(),
Documents = page.Documents?.Select((d, index) => new ManageDocumentViewModel
{
Id = string.IsNullOrEmpty(d.Id) ? $"doc_{index}" : d.Id,
DocumentId = d.FileId,
Title = d.Title,
Description = d.Description,
FileName = d.FileName,
FileSize = d.FileSize,
UploadedAt = d.UploadedAt
}).ToList() ?? new List<ManageDocumentViewModel>(),
AvailableCategories = categories, AvailableCategories = categories,
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(),
AllowProductLinks = (await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString())).AllowProductLinks AllowProductLinks = planLimitations.AllowProductLinks,
AllowDocumentUpload = planLimitations.AllowDocumentUpload,
MaxDocumentsAllowed = planLimitations.MaxDocuments,
DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay()
}; };
} }
@ -922,7 +990,198 @@ public class AdminController : Controller
return userPage; return userPage;
} }
private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model) private string GetDocumentUploadPlansDisplay()
{
var sections = _configuration.GetSection("Plans").GetChildren();
var friendlyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var section in sections)
{
if (!bool.TryParse(section["AllowDocumentUpload"], out var allow) || !allow)
continue;
var key = section.Key ?? string.Empty;
var normalizedKey = key.EndsWith("Yearly", StringComparison.OrdinalIgnoreCase)
? key[..^6]
: key;
if (Enum.TryParse<PlanType>(normalizedKey, true, out var planType))
{
friendlyNames.Add(planType.GetDisplayName());
}
else
{
var display = section["Name"] ?? key;
if (!string.IsNullOrWhiteSpace(display))
{
friendlyNames.Add(display);
}
}
}
if (friendlyNames.Count == 0)
return "planos com suporte a documentos";
var orderedNames = friendlyNames.OrderBy(name => name).ToList();
return orderedNames.Count switch
{
1 => orderedNames[0],
2 => string.Join(" e ", orderedNames),
_ => $"{string.Join(", ", orderedNames.Take(orderedNames.Count - 1))} e {orderedNames.Last()}"
};
}
private async Task<(List<PageDocument> Documents, List<string> RemovedFileIds, List<string> NewlyUploadedFileIds)> BuildDocumentsAsync(ManagePageViewModel model, PlanLimitations planLimitations)
{
var documents = new List<PageDocument>();
var removedFileIds = new List<string>();
var newFileIds = new List<string>();
if (!planLimitations.AllowDocumentUpload)
{
return (documents, removedFileIds, newFileIds);
}
if (model.Documents == null || model.Documents.Count == 0)
return (documents, removedFileIds, newFileIds);
for (int index = 0; index < model.Documents.Count; index++)
{
var docVm = model.Documents[index];
if (docVm == null)
continue;
var hasExisting = !string.IsNullOrEmpty(docVm.DocumentId);
if (docVm.MarkForRemoval)
{
if (hasExisting && docVm.DocumentId != null)
removedFileIds.Add(docVm.DocumentId);
continue;
}
if (string.IsNullOrWhiteSpace(docVm.Title))
{
ModelState.AddModelError($"Documents[{index}].Title", "Título é obrigatório");
continue;
}
string fileId = docVm.DocumentId?.Trim() ?? string.Empty;
string fileName = docVm.FileName?.Trim() ?? string.Empty;
long fileSize = docVm.FileSize;
var uploadedAt = docVm.UploadedAt ?? DateTime.UtcNow;
if (hasExisting && string.IsNullOrEmpty(docVm.Id))
{
docVm.Id = ObjectId.GenerateNewId().ToString();
}
if (docVm.DocumentFile != null && docVm.DocumentFile.Length > 0)
{
if (!docVm.DocumentFile.ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Envie um arquivo em PDF.");
continue;
}
if (docVm.DocumentFile.Length > 10 * 1024 * 1024)
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Arquivo muito grande. Tamanho máximo: 10MB.");
continue;
}
using var memoryStream = new MemoryStream();
await docVm.DocumentFile.CopyToAsync(memoryStream);
var documentBytes = memoryStream.ToArray();
try
{
fileId = await _documentStorage.SaveDocumentAsync(documentBytes, docVm.DocumentFile.FileName, docVm.DocumentFile.ContentType);
fileName = docVm.DocumentFile.FileName;
fileSize = docVm.DocumentFile.Length;
uploadedAt = DateTime.UtcNow;
newFileIds.Add(fileId);
if (hasExisting && docVm.DocumentId != null)
removedFileIds.Add(docVm.DocumentId);
}
catch (ArgumentException ex)
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", ex.Message);
continue;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao salvar PDF do usuário");
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Erro ao salvar o PDF. Tente novamente.");
continue;
}
}
else if (!hasExisting)
{
// Nenhum arquivo associado: ignorar
continue;
}
if (string.IsNullOrEmpty(fileId))
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Falha ao processar o documento.");
continue;
}
documents.Add(new PageDocument
{
Id = string.IsNullOrEmpty(docVm.Id) || !ObjectId.TryParse(docVm.Id, out _) ? ObjectId.GenerateNewId().ToString() : docVm.Id,
FileId = fileId,
Title = docVm.Title,
Description = docVm.Description,
FileName = fileName,
FileSize = fileSize,
UploadedAt = uploadedAt
});
docVm.DocumentId = fileId;
docVm.FileName = fileName;
docVm.FileSize = fileSize;
docVm.UploadedAt = uploadedAt;
if (string.IsNullOrEmpty(docVm.Id) || !ObjectId.TryParse(docVm.Id, out _))
{
docVm.Id = documents.Last().Id;
}
}
if (planLimitations.MaxDocuments > 0 && documents.Count > planLimitations.MaxDocuments)
{
ModelState.AddModelError("Documents", $"Você pode enviar no máximo {planLimitations.MaxDocuments} documento(s) com seu plano.");
}
return (documents, removedFileIds, newFileIds);
}
private async Task CleanupNewDocumentsAsync(IEnumerable<string> documentIds)
{
if (documentIds == null)
return;
foreach (var docId in documentIds)
{
if (string.IsNullOrEmpty(docId))
continue;
try
{
await _documentStorage.DeleteDocumentAsync(docId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Erro ao limpar documento temporário {DocumentId}", docId);
}
}
}
private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model, List<PageDocument> documents)
{ {
page.DisplayName = model.DisplayName; page.DisplayName = model.DisplayName;
page.Category = model.Category; page.Category = model.Category;
@ -1077,6 +1336,8 @@ public class AdminController : Controller
} }
page.Links.AddRange(socialLinks); page.Links.AddRange(socialLinks);
page.Documents = documents ?? new List<PageDocument>();
} }
[HttpPost] [HttpPost]

View File

@ -0,0 +1,64 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Route("api/[controller]")]
[ApiController]
public class DocumentController : ControllerBase
{
private readonly IDocumentStorageService _documentStorage;
private readonly ILogger<DocumentController> _logger;
public DocumentController(IDocumentStorageService documentStorage, ILogger<DocumentController> logger)
{
_documentStorage = documentStorage;
_logger = logger;
}
[HttpGet("{documentId}")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
public async Task<IActionResult> GetDocument(string documentId)
{
try
{
if (string.IsNullOrEmpty(documentId))
return BadRequest("Documento inválido.");
var documentBytes = await _documentStorage.GetDocumentAsync(documentId);
if (documentBytes == null)
return NotFound();
Response.Headers["Cache-Control"] = "public, max-age=31536000";
Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R");
Response.Headers["ETag"] = $"\"{documentId}\"";
return File(documentBytes, "application/pdf");
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao obter documento {DocumentId}", documentId);
return NotFound();
}
}
[HttpDelete("{documentId}")]
public async Task<IActionResult> DeleteDocument(string documentId)
{
if (string.IsNullOrEmpty(documentId))
return BadRequest();
var deleted = await _documentStorage.DeleteDocumentAsync(documentId);
return deleted ? Ok(new { success = true }) : NotFound();
}
[HttpHead("{documentId}")]
public async Task<IActionResult> DocumentExists(string documentId)
{
if (string.IsNullOrEmpty(documentId))
return BadRequest();
var exists = await _documentStorage.DocumentExistsAsync(documentId);
return exists ? Ok() : NotFound();
}
}

View File

@ -149,7 +149,9 @@ public class TestToolsController : ControllerBase
AllowProductLinks = source.AllowProductLinks, AllowProductLinks = source.AllowProductLinks,
SpecialModeration = source.SpecialModeration, SpecialModeration = source.SpecialModeration,
OGExtractionsUsedToday = 0, OGExtractionsUsedToday = 0,
LastExtractionDate = null LastExtractionDate = null,
AllowDocumentUpload = source.AllowDocumentUpload,
MaxDocuments = source.MaxDocuments
}; };
} }

View File

@ -90,7 +90,9 @@ public class PlanLimitationMiddleware
AllowCustomDomain = false, AllowCustomDomain = false,
AllowMultipleDomains = false, AllowMultipleDomains = false,
PrioritySupport = false, PrioritySupport = false,
PlanType = "basic" PlanType = "basic",
AllowDocumentUpload = false,
MaxDocuments = 0
}, },
"professional" => new Models.PlanLimitations "professional" => new Models.PlanLimitations
{ {
@ -100,7 +102,9 @@ public class PlanLimitationMiddleware
AllowCustomDomain = true, AllowCustomDomain = true,
AllowMultipleDomains = false, AllowMultipleDomains = false,
PrioritySupport = false, PrioritySupport = false,
PlanType = "professional" PlanType = "professional",
AllowDocumentUpload = false,
MaxDocuments = 0
}, },
"premium" => new Models.PlanLimitations "premium" => new Models.PlanLimitations
{ {
@ -110,7 +114,21 @@ public class PlanLimitationMiddleware
AllowCustomDomain = true, AllowCustomDomain = true,
AllowMultipleDomains = true, AllowMultipleDomains = true,
PrioritySupport = true, PrioritySupport = true,
PlanType = "premium" PlanType = "premium",
AllowDocumentUpload = true,
MaxDocuments = 5
},
"premiumaffiliate" => new Models.PlanLimitations
{
MaxLinks = -1,
AllowCustomThemes = true,
AllowAnalytics = true,
AllowCustomDomain = true,
AllowMultipleDomains = true,
PrioritySupport = true,
PlanType = "premiumaffiliate",
AllowDocumentUpload = true,
MaxDocuments = 10
}, },
_ => new Models.PlanLimitations _ => new Models.PlanLimitations
{ {
@ -120,7 +138,9 @@ public class PlanLimitationMiddleware
AllowCustomDomain = false, AllowCustomDomain = false,
AllowMultipleDomains = false, AllowMultipleDomains = false,
PrioritySupport = false, PrioritySupport = false,
PlanType = "free" PlanType = "free",
AllowDocumentUpload = false,
MaxDocuments = 0
} }
}; };
} }

View File

@ -16,6 +16,7 @@
string BusinessType { get; } string BusinessType { get; }
PageTheme Theme { get; } PageTheme Theme { get; }
List<LinkItem> Links { get; } List<LinkItem> Links { get; }
List<PageDocument> Documents { get; }
SeoSettings SeoSettings { get; } SeoSettings SeoSettings { get; }
string Language { get; } string Language { get; }
DateTime CreatedAt { get; } DateTime CreatedAt { get; }

View File

@ -47,6 +47,9 @@ public class LivePage : IPageDisplay
[BsonElement("links")] [BsonElement("links")]
public List<LinkItem> Links { get; set; } = new(); public List<LinkItem> Links { get; set; } = new();
[BsonElement("documents")]
public List<PageDocument> Documents { get; set; } = new();
[BsonElement("seoSettings")] [BsonElement("seoSettings")]
public SeoSettings SeoSettings { get; set; } = new(); public SeoSettings SeoSettings { get; set; } = new();

View File

@ -0,0 +1,30 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class PageDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
[BsonElement("fileId")]
[BsonRepresentation(BsonType.ObjectId)]
public string FileId { get; set; } = string.Empty;
[BsonElement("title")]
public string Title { get; set; } = string.Empty;
[BsonElement("description")]
public string Description { get; set; } = string.Empty;
[BsonElement("fileName")]
public string FileName { get; set; } = string.Empty;
[BsonElement("fileSize")]
public long FileSize { get; set; }
[BsonElement("uploadedAt")]
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -43,4 +43,10 @@ public class PlanLimitations
[BsonElement("lastExtractionDate")] [BsonElement("lastExtractionDate")]
public DateTime? LastExtractionDate { get; set; } public DateTime? LastExtractionDate { get; set; }
[BsonElement("allowDocumentUpload")]
public bool AllowDocumentUpload { get; set; } = false;
[BsonElement("maxDocuments")]
public int MaxDocuments { get; set; } = 0;
} }

View File

@ -31,10 +31,10 @@ public static class PlanTypeExtensions
return planType switch return planType switch
{ {
PlanType.Trial => 0.00m, PlanType.Trial => 0.00m,
PlanType.Basic => 5.90m, PlanType.Basic => 12.90m,
PlanType.Professional => 12.90m, PlanType.Professional => 25.90m,
PlanType.Premium => 19.90m, PlanType.Premium => 29.90m,
PlanType.PremiumAffiliate => 29.90m, PlanType.PremiumAffiliate => 34.90m,
_ => 0.00m _ => 0.00m
}; };
} }

View File

@ -44,6 +44,9 @@ public class UserPage : IPageDisplay
[BsonElement("links")] [BsonElement("links")]
public List<LinkItem> Links { get; set; } = new(); public List<LinkItem> Links { get; set; } = new();
[BsonElement("documents")]
public List<PageDocument> Documents { get; set; } = new();
[BsonElement("seoSettings")] [BsonElement("seoSettings")]
public SeoSettings SeoSettings { get; set; } = new(); public SeoSettings SeoSettings { get; set; } = new();

View File

@ -483,6 +483,7 @@ builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IDowngradeService, DowngradeService>(); builder.Services.AddScoped<IDowngradeService, DowngradeService>();
builder.Services.AddScoped<IImageStorageService, GridFSImageStorage>(); builder.Services.AddScoped<IImageStorageService, GridFSImageStorage>();
builder.Services.AddScoped<IDocumentStorageService, GridFSDocumentStorage>();
// Support Area - Rating and Contact System // Support Area - Rating and Contact System
builder.Services.Configure<BCards.Web.Configuration.SupportSettings>( builder.Services.Configure<BCards.Web.Configuration.SupportSettings>(
@ -498,20 +499,21 @@ builder.Services.AddScoped<BCards.Web.Areas.Tutoriais.Services.IMarkdownService,
// Configure upload limits for file handling (images up to 5MB) // Configure upload limits for file handling (images up to 5MB)
builder.Services.Configure<FormOptions>(options => builder.Services.Configure<FormOptions>(options =>
{ {
options.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5MB var maxUploadSize = 12 * 1024 * 1024; // 12MB
options.MultipartBodyLengthLimit = maxUploadSize;
options.ValueLengthLimit = int.MaxValue; options.ValueLengthLimit = int.MaxValue;
options.ValueCountLimit = int.MaxValue; options.ValueCountLimit = int.MaxValue;
options.KeyLengthLimit = int.MaxValue; options.KeyLengthLimit = int.MaxValue;
options.BufferBody = true; options.BufferBody = true;
options.BufferBodyLengthLimit = 5 * 1024 * 1024; // 5MB options.BufferBodyLengthLimit = maxUploadSize;
options.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5MB options.MultipartBodyLengthLimit = maxUploadSize;
options.MultipartHeadersLengthLimit = 16384; options.MultipartHeadersLengthLimit = 16384;
}); });
// Configure Kestrel server limits for larger requests // Configure Kestrel server limits for larger requests
builder.Services.Configure<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions>(options => builder.Services.Configure<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions>(options =>
{ {
options.Limits.MaxRequestBodySize = 5 * 1024 * 1024; // 5MB options.Limits.MaxRequestBodySize = 12 * 1024 * 1024; // 12MB
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(2); options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(2);
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2); options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
}); });

View File

@ -0,0 +1,93 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
namespace BCards.Web.Services;
public class GridFSDocumentStorage : IDocumentStorageService
{
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private static readonly string[] ALLOWED_TYPES = { "application/pdf" };
private readonly GridFSBucket _gridFS;
private readonly ILogger<GridFSDocumentStorage> _logger;
public GridFSDocumentStorage(IMongoDatabase database, ILogger<GridFSDocumentStorage> logger)
{
_gridFS = new GridFSBucket(database, new GridFSBucketOptions
{
BucketName = "page_documents"
});
_logger = logger;
}
public async Task<string> SaveDocumentAsync(byte[] documentBytes, string fileName, string contentType)
{
if (documentBytes == null || documentBytes.Length == 0)
throw new ArgumentException("Documento inválido.");
if (documentBytes.Length > MAX_FILE_SIZE)
throw new ArgumentException($"Arquivo muito grande. Tamanho máximo permitido: {MAX_FILE_SIZE / (1024 * 1024)}MB.");
if (!ALLOWED_TYPES.Contains(contentType.ToLower()))
throw new ArgumentException("Tipo de arquivo não suportado. Envie um PDF.");
var uniqueFileName = $"document_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}.pdf";
var options = new GridFSUploadOptions
{
Metadata = new BsonDocument
{
{ "originalFileName", fileName },
{ "contentType", contentType },
{ "uploadDate", DateTime.UtcNow },
{ "size", documentBytes.Length }
}
};
var fileId = await _gridFS.UploadFromBytesAsync(uniqueFileName, documentBytes, options);
_logger.LogInformation("PDF salvo no GridFS: {FileId}", fileId);
return fileId.ToString();
}
public async Task<byte[]?> GetDocumentAsync(string documentId)
{
if (!ObjectId.TryParse(documentId, out var objectId))
return null;
try
{
return await _gridFS.DownloadAsBytesAsync(objectId);
}
catch (GridFSFileNotFoundException)
{
return null;
}
}
public async Task<bool> DeleteDocumentAsync(string documentId)
{
if (!ObjectId.TryParse(documentId, out var objectId))
return false;
try
{
await _gridFS.DeleteAsync(objectId);
return true;
}
catch (GridFSFileNotFoundException)
{
return false;
}
}
public async Task<bool> DocumentExistsAsync(string documentId)
{
if (!ObjectId.TryParse(documentId, out var objectId))
return false;
var filter = Builders<GridFSFileInfo>.Filter.Eq("_id", objectId);
var fileInfo = await _gridFS.Find(filter).FirstOrDefaultAsync();
return fileInfo != null;
}
}

View File

@ -0,0 +1,9 @@
namespace BCards.Web.Services;
public interface IDocumentStorageService
{
Task<string> SaveDocumentAsync(byte[] documentBytes, string fileName, string contentType);
Task<byte[]?> GetDocumentAsync(string documentId);
Task<bool> DeleteDocumentAsync(string documentId);
Task<bool> DocumentExistsAsync(string documentId);
}

View File

@ -59,6 +59,7 @@ public class LivePageService : ILivePageService
BusinessType = userPage.BusinessType, BusinessType = userPage.BusinessType,
Theme = userPage.Theme, Theme = userPage.Theme,
Links = userPage.Links, Links = userPage.Links,
Documents = userPage.Documents,
SeoSettings = userPage.SeoSettings, SeoSettings = userPage.SeoSettings,
Language = userPage.Language, Language = userPage.Language,
Analytics = new LivePageAnalytics Analytics = new LivePageAnalytics

View File

@ -47,7 +47,9 @@ public class PlanConfigurationService : IPlanConfigurationService
PrioritySupport = false, PrioritySupport = false,
AllowProductLinks = false, AllowProductLinks = false,
MaxProductLinks = 0, MaxProductLinks = 0,
PlanType = "trial" PlanType = "trial",
AllowDocumentUpload = false,
MaxDocuments = 0
}, },
PlanType.Basic => new PlanLimitations PlanType.Basic => new PlanLimitations
{ {
@ -59,7 +61,9 @@ public class PlanConfigurationService : IPlanConfigurationService
PrioritySupport = false, PrioritySupport = false,
AllowProductLinks = GetConfigValue(PlanType.Basic, "AllowProductLinks", false), AllowProductLinks = GetConfigValue(PlanType.Basic, "AllowProductLinks", false),
MaxProductLinks = 0, MaxProductLinks = 0,
PlanType = "basic" PlanType = "basic",
AllowDocumentUpload = GetConfigValue(PlanType.Basic, "AllowDocumentUpload", false),
MaxDocuments = GetConfigValue(PlanType.Basic, "MaxDocuments", 0)
}, },
PlanType.Professional => new PlanLimitations PlanType.Professional => new PlanLimitations
{ {
@ -71,7 +75,9 @@ public class PlanConfigurationService : IPlanConfigurationService
PrioritySupport = false, PrioritySupport = false,
AllowProductLinks = GetConfigValue(PlanType.Professional, "AllowProductLinks", false), AllowProductLinks = GetConfigValue(PlanType.Professional, "AllowProductLinks", false),
MaxProductLinks = 0, MaxProductLinks = 0,
PlanType = "professional" PlanType = "professional",
AllowDocumentUpload = GetConfigValue(PlanType.Professional, "AllowDocumentUpload", false),
MaxDocuments = GetConfigValue(PlanType.Professional, "MaxDocuments", 0)
}, },
PlanType.Premium => new PlanLimitations PlanType.Premium => new PlanLimitations
{ {
@ -83,7 +89,9 @@ public class PlanConfigurationService : IPlanConfigurationService
PrioritySupport = true, PrioritySupport = true,
AllowProductLinks = GetConfigValue(PlanType.Premium, "AllowProductLinks", false), AllowProductLinks = GetConfigValue(PlanType.Premium, "AllowProductLinks", false),
MaxProductLinks = 0, MaxProductLinks = 0,
PlanType = "premium" PlanType = "premium",
AllowDocumentUpload = GetConfigValue(PlanType.Premium, "AllowDocumentUpload", true),
MaxDocuments = GetConfigValue(PlanType.Premium, "MaxDocuments", 5)
}, },
PlanType.PremiumAffiliate => new PlanLimitations PlanType.PremiumAffiliate => new PlanLimitations
{ {
@ -95,9 +103,11 @@ public class PlanConfigurationService : IPlanConfigurationService
PrioritySupport = true, PrioritySupport = true,
AllowProductLinks = GetConfigValue(PlanType.PremiumAffiliate, "AllowProductLinks", true), AllowProductLinks = GetConfigValue(PlanType.PremiumAffiliate, "AllowProductLinks", true),
MaxProductLinks = 10, MaxProductLinks = 10,
PlanType = "premiumaffiliate" PlanType = "premiumaffiliate",
AllowDocumentUpload = GetConfigValue(PlanType.PremiumAffiliate, "AllowDocumentUpload", true),
MaxDocuments = GetConfigValue(PlanType.PremiumAffiliate, "MaxDocuments", 10)
}, },
_ => new PlanLimitations { PlanType = "trial" } _ => new PlanLimitations { PlanType = "trial", AllowDocumentUpload = false, MaxDocuments = 0 }
}; };
} }

View File

@ -43,6 +43,7 @@ public class ManagePageViewModel
public string KawaiUrl { get; set; } = string.Empty; public string KawaiUrl { get; set; } = string.Empty;
public List<ManageLinkViewModel> Links { get; set; } = new(); public List<ManageLinkViewModel> Links { get; set; } = new();
public List<ManageDocumentViewModel> Documents { get; set; } = new();
// Profile image fields // Profile image fields
public string? ProfileImageId { get; set; } public string? ProfileImageId { get; set; }
@ -55,6 +56,9 @@ public class ManagePageViewModel
// Plan limitations // Plan limitations
public int MaxLinksAllowed { get; set; } = 3; public int MaxLinksAllowed { get; set; } = 3;
public bool AllowProductLinks { get; set; } = false; public bool AllowProductLinks { get; set; } = false;
public int MaxDocumentsAllowed { get; set; } = 0;
public bool AllowDocumentUpload { get; set; } = false;
public string DocumentUploadPlansDisplay { get; set; } = "planos com suporte a documentos";
public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower()); public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower());
/// <summary> /// <summary>
@ -101,6 +105,28 @@ public class ManageLinkViewModel
public DateTime? ProductDataCachedAt { get; set; } public DateTime? ProductDataCachedAt { get; set; }
} }
public class ManageDocumentViewModel
{
// Campos opcionais - preenchidos pelo model binding ou pelo controller
public string? Id { get; set; }
public string? DocumentId { get; set; }
public string? FileName { get; set; }
[Required(ErrorMessage = "Título é obrigatório")]
[StringLength(120, ErrorMessage = "Título deve ter no máximo 120 caracteres")]
public string Title { get; set; } = string.Empty;
[StringLength(300, ErrorMessage = "Descrição deve ter no máximo 300 caracteres")]
public string Description { get; set; } = string.Empty;
public long FileSize { get; set; }
public DateTime? UploadedAt { get; set; }
public IFormFile? DocumentFile { get; set; }
public bool MarkForRemoval { get; set; }
public bool ReplaceExisting => DocumentFile != null && !string.IsNullOrEmpty(DocumentId);
}
public class DashboardViewModel public class DashboardViewModel
{ {
public User CurrentUser { get; set; } = new(); public User CurrentUser { get; set; } = new();

View File

@ -29,7 +29,7 @@
<div class="progress-bar" role="progressbar" style="width: 25%" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress-bar" role="progressbar" style="width: 25%" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100"></div>
</div> </div>
<div class="d-flex justify-content-between mt-2"> <div class="d-flex justify-content-between mt-2">
<small class="text-muted">Passo 1 de 4</small> <small class="text-muted">Passo 1 de 5</small>
<small class="text-muted">Informações Básicas</small> <small class="text-muted">Informações Básicas</small>
</div> </div>
</div> </div>
@ -228,13 +228,154 @@
</div> </div>
</div> </div>
<!-- Passo 3: Links Principais --> <!-- Passo 3: Documentos PDF (Premium) -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingDocuments">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDocuments" aria-expanded="false" aria-controls="collapseDocuments">
<i class="fas fa-file-pdf me-2"></i>
Passo 3: Documentos PDF (Premium)
<span class="badge bg-success ms-auto me-3" id="step3Status" style="display: none;">✓</span>
</button>
</h2>
<div id="collapseDocuments" class="accordion-collapse collapse" aria-labelledby="headingDocuments" data-bs-parent="#pageWizard">
<div class="accordion-body">
@if (Model.AllowDocumentUpload)
{
<p class="text-muted mb-3">Anexe PDFs com apresentações, catálogos ou materiais exclusivos para quem acessar sua página Premium.</p>
@if (Model.MaxDocumentsAllowed > 0)
{
<div class="alert alert-light border-start border-3 border-primary py-2 small">
<i class="fas fa-info-circle me-2 text-primary"></i>
Você pode anexar até <strong>@Model.MaxDocumentsAllowed</strong> documento(s) no seu plano atual.
</div>
}
<div id="documentsContainer">
@if (Model.Documents != null && Model.Documents.Count > 0)
{
for (var i = 0; i < Model.Documents.Count; i++)
{
<div class="document-input-group border rounded p-3 mb-3" data-document="@i">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h6 class="mb-1">Documento @(i + 1)</h6>
@if (Model.Documents[i].UploadedAt.HasValue)
{
<small class="text-muted">Atualizado em @Model.Documents[i].UploadedAt.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm")</small>
}
</div>
<div class="btn-group">
@if (!string.IsNullOrEmpty(Model.Documents[i].DocumentId))
{
<a class="btn btn-sm btn-outline-primary" href="/api/document/@Model.Documents[i].DocumentId" target="_blank">
<i class="fas fa-file-pdf me-1"></i> Ver PDF
</a>
}
<button type="button" class="btn btn-sm btn-outline-danger remove-document-btn">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Título <span class="text-danger">*</span></label>
<input asp-for="Documents[i].Title" class="form-control" placeholder="Ex: Apresentação de Serviços">
<span asp-validation-for="Documents[i].Title" class="text-danger"></span>
</div>
<div class="mb-3">
<label class="form-label">Descrição (opcional)</label>
<textarea asp-for="Documents[i].Description" class="form-control" rows="2" placeholder="Resumo do conteúdo"></textarea>
<span asp-validation-for="Documents[i].Description" class="text-danger"></span>
</div>
<div class="mb-3">
<label class="form-label">Arquivo PDF</label>
@if (!string.IsNullOrEmpty(Model.Documents[i].DocumentId))
{
<div class="bg-light border rounded p-3 mb-2 document-file-info">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>@Model.Documents[i].FileName</strong>
<div class="small text-muted">@((Model.Documents[i].FileSize / 1024.0).ToString("0.#")) KB</div>
</div>
<span class="badge bg-primary-subtle text-primary">PDF</span>
</div>
</div>
<input asp-for="Documents[i].DocumentFile" class="form-control" type="file" accept="application/pdf">
<small class="form-text text-muted">Envie outro PDF para substituir o arquivo atual (máx. 10MB).</small>
}
else
{
<input asp-for="Documents[i].DocumentFile" class="form-control" type="file" accept="application/pdf">
<small class="form-text text-muted">Envie um arquivo PDF (máx. 10MB).</small>
}
<span asp-validation-for="Documents[i].DocumentFile" class="text-danger"></span>
</div>
<input asp-for="Documents[i].Id" type="hidden">
<input asp-for="Documents[i].DocumentId" type="hidden">
<input asp-for="Documents[i].FileName" type="hidden">
<input asp-for="Documents[i].FileSize" type="hidden">
<input asp-for="Documents[i].UploadedAt" type="hidden">
<input asp-for="Documents[i].MarkForRemoval" type="hidden">
</div>
}
<div class="alert alert-info" id="documentsEmptyState" style="display: none;">
<i class="fas fa-folder-open me-2"></i>Nenhum documento adicionado ainda.
</div>
}
else
{
<div class="alert alert-info" id="documentsEmptyState">
<i class="fas fa-folder-open me-2"></i>Nenhum documento adicionado ainda.
</div>
}
</div>
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mt-3">
<small class="text-muted">Os documentos são exibidos em ordem de cadastro. Utilize títulos claros para facilitar o acesso.</small>
<button type="button" class="btn btn-outline-primary" id="addDocumentBtn">
<i class="fas fa-plus me-2"></i>Adicionar Documento
</button>
</div>
}
else
{
<div class="alert alert-warning d-flex align-items-start">
<i class="fas fa-crown me-2 mt-1"></i>
<div>
Upload de PDFs disponível apenas nos planos <strong>@Model.DocumentUploadPlansDisplay</strong>.
<div class="mt-2">
<a asp-controller="Home" asp-action="Pricing" class="btn btn-warning btn-sm">
<i class="fas fa-arrow-up me-1"></i>Fazer upgrade agora
</a>
</div>
</div>
</div>
}
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(2)">
<i class="fas fa-arrow-left me-1"></i> Anterior
</button>
<button type="button" class="btn btn-primary" onclick="nextStep(4)">
Próximo <i class="fas fa-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Passo 4: Links Principais -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header" id="headingLinks"> <h2 class="accordion-header" id="headingLinks">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLinks" aria-expanded="false" aria-controls="collapseLinks"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLinks" aria-expanded="false" aria-controls="collapseLinks">
<i class="fas fa-link me-2"></i> <i class="fas fa-link me-2"></i>
Passo 3: Links Principais Passo 4: Links Principais
<span class="badge bg-success ms-auto me-3" id="step3Status" style="display: none;">✓</span> <span class="badge bg-success ms-auto me-3" id="step4Status" style="display: none;">✓</span>
</button> </button>
</h2> </h2>
<div id="collapseLinks" class="accordion-collapse collapse" aria-labelledby="headingLinks" data-bs-parent="#pageWizard"> <div id="collapseLinks" class="accordion-collapse collapse" aria-labelledby="headingLinks" data-bs-parent="#pageWizard">
@ -371,10 +512,10 @@
</button> </button>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(2)"> <button type="button" class="btn btn-outline-secondary" onclick="previousStep(3)">
<i class="fas fa-arrow-left me-1"></i> Anterior <i class="fas fa-arrow-left me-1"></i> Anterior
</button> </button>
<button type="button" class="btn btn-primary" onclick="nextStep(4)"> <button type="button" class="btn btn-primary" onclick="nextStep(5)">
Próximo <i class="fas fa-arrow-right ms-1"></i> Próximo <i class="fas fa-arrow-right ms-1"></i>
</button> </button>
</div> </div>
@ -399,13 +540,13 @@
var discordUrl = discord !=null ? discord.Url.Replace("https://discord.gg/","").Replace("https://discord.com/invite/","") : ""; var discordUrl = discord !=null ? discord.Url.Replace("https://discord.gg/","").Replace("https://discord.com/invite/","") : "";
var kawaiUrl = kawai !=null ? kawai.Url.Replace("https://kawai.com/","").Replace("https://www.kawai.com/","") : ""; var kawaiUrl = kawai !=null ? kawai.Url.Replace("https://kawai.com/","").Replace("https://www.kawai.com/","") : "";
} }
<!-- Passo 4: Redes Sociais (Opcional) --> <!-- Passo 5: Redes Sociais (Opcional) -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header" id="headingSocial"> <h2 class="accordion-header" id="headingSocial">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseSocial" aria-expanded="false" aria-controls="collapseSocial"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseSocial" aria-expanded="false" aria-controls="collapseSocial">
<i class="fab fa-twitter me-2"></i> <i class="fab fa-twitter me-2"></i>
Passo 4: Redes Sociais (Opcional) Passo 5: Redes Sociais (Opcional)
<span class="badge bg-success ms-auto me-3" id="step4Status" style="display: none;">✓</span> <span class="badge bg-success ms-auto me-3" id="step5Status" style="display: none;">✓</span>
</button> </button>
</h2> </h2>
<div id="collapseSocial" class="accordion-collapse collapse" aria-labelledby="headingSocial" data-bs-parent="#pageWizard"> <div id="collapseSocial" class="accordion-collapse collapse" aria-labelledby="headingSocial" data-bs-parent="#pageWizard">
@ -590,7 +731,7 @@
</div> </div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(3)"> <button type="button" class="btn btn-outline-secondary" onclick="previousStep(4)">
<i class="fas fa-arrow-left me-1"></i> Anterior <i class="fas fa-arrow-left me-1"></i> Anterior
</button> </button>
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
@ -1039,7 +1180,9 @@
<script> <script>
let linkCount = @Model.Links.Count; let linkCount = @Model.Links.Count;
let documentCount = @(Model.Documents?.Count ?? 0);
let currentStep = 1; let currentStep = 1;
const totalSteps = 5;
$(document).ready(function() { $(document).ready(function() {
// Initialize social media fields // Initialize social media fields
@ -1051,6 +1194,9 @@
// Initialize URL input handlers // Initialize URL input handlers
initializeUrlInputs(); initializeUrlInputs();
// Initialize document handlers
initializeDocumentHandlers();
// Check for validation errors and show toast + open accordion // Check for validation errors and show toast + open accordion
checkValidationErrors(); checkValidationErrors();
@ -1097,7 +1243,7 @@
const maxlinks = @Model.MaxLinksAllowed; const maxlinks = @Model.MaxLinksAllowed;
// Links do modal são limitados pelo MaxLinksAllowed (redes sociais são separadas e opcionais) // Links do modal são limitados pelo MaxLinksAllowed (redes sociais são separadas e opcionais)
if (linkCount >= maxlinks) { if (linkCount >= maxlinks) {
alert('Você atingiu o limite de ' + maxlinks + ' links para seu plano atual. As redes sociais do Passo 4 não contam neste limite.'); alert('Você atingiu o limite de ' + maxlinks + ' links para seu plano atual. As redes sociais do Passo 5 não contam neste limite.');
return false; return false;
} }
}); });
@ -1189,7 +1335,7 @@
} }
function getStepName(step) { function getStepName(step) {
const names = ['', 'Basic', 'Theme', 'Links', 'Social']; const names = ['', 'Basic', 'Theme', 'Documents', 'Links', 'Social'];
return names[step]; return names[step];
} }
@ -1216,9 +1362,9 @@
} }
function updateProgress() { function updateProgress() {
const progress = (currentStep / 4) * 100; const progress = (currentStep / totalSteps) * 100;
$('.progress-bar').css('width', `${progress}%`).attr('aria-valuenow', progress); $('.progress-bar').css('width', `${progress}%`).attr('aria-valuenow', progress);
$('.progress').next().find('small').first().text(`Passo ${currentStep} de 4`); $('.progress').next().find('small').first().text(`Passo ${currentStep} de ${totalSteps}`);
} }
function generateSlug() { function generateSlug() {
@ -1359,6 +1505,167 @@
}); });
} }
function initializeDocumentHandlers() {
const $documentsContainer = $('#documentsContainer');
const $addDocumentBtn = $('#addDocumentBtn');
if (!$documentsContainer.length) {
return;
}
toggleDocumentEmptyState();
toggleDocumentAddButton();
if ($addDocumentBtn.length) {
$addDocumentBtn.on('click', function() {
addDocumentInput();
});
}
$(document).on('click', '.remove-document-btn', function() {
const $group = $(this).closest('.document-input-group');
const $markRemoval = $group.find('input[name$=".MarkForRemoval"]');
const hasExisting = $group.find('input[name$=".DocumentId"]').val();
if (hasExisting) {
$group.addClass('document-removed d-none');
if ($markRemoval.length) {
$markRemoval.val('true');
}
$group.find('input[type="file"]').val('');
} else {
$group.remove();
documentCount = Math.max(documentCount - 1, 0);
}
updateDocumentNumbers();
toggleDocumentEmptyState();
toggleDocumentAddButton();
});
$(document).on('change', '.document-input-group input[type="file"]', function(e) {
const file = e.target.files[0];
if (!file) {
return;
}
if (file.type !== 'application/pdf') {
alert('Envie um arquivo em PDF.');
$(this).val('');
return;
}
if (file.size > 10 * 1024 * 1024) {
alert('Arquivo muito grande. Tamanho máximo: 10MB.');
$(this).val('');
}
});
}
function toggleDocumentEmptyState() {
const $container = $('#documentsContainer');
const $emptyState = $('#documentsEmptyState');
if (!$container.length || !$emptyState.length) {
return;
}
const visibleDocs = $container.find('.document-input-group').not('.document-removed');
if (visibleDocs.length === 0) {
$emptyState.show();
} else {
$emptyState.hide();
}
}
function toggleDocumentAddButton() {
const $button = $('#addDocumentBtn');
if (!$button.length) {
return;
}
const maxDocs = @Model.MaxDocumentsAllowed;
if (maxDocs <= 0) {
$button.prop('disabled', false).removeAttr('title');
return;
}
const visibleDocs = $('#documentsContainer .document-input-group').not('.document-removed').length;
if (visibleDocs >= maxDocs) {
$button.prop('disabled', true).attr('title', 'Você atingiu o limite de documentos do seu plano.');
} else {
$button.prop('disabled', false).removeAttr('title');
}
}
function addDocumentInput() {
const $container = $('#documentsContainer');
if (!$container.length) {
return;
}
const $button = $('#addDocumentBtn');
if ($button.length && $button.prop('disabled')) {
return;
}
const existingIndexes = [];
$('input[name^="Documents["]').each(function() {
const match = $(this).attr('name').match(/Documents\[(\d+)\]/);
if (match) {
existingIndexes.push(parseInt(match[1]));
}
});
const nextIndex = existingIndexes.length > 0 ? Math.max(...existingIndexes) + 1 : 0;
const displayCount = $container.find('.document-input-group').length + 1;
const template = `
<div class="document-input-group border rounded p-3 mb-3" data-document="${nextIndex}">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h6 class="mb-1">Documento ${displayCount}</h6>
</div>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-danger remove-document-btn">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Título <span class="text-danger">*</span></label>
<input type="text" name="Documents[${nextIndex}].Title" class="form-control" placeholder="Ex: Apresentação de Serviços">
</div>
<div class="mb-3">
<label class="form-label">Descrição (opcional)</label>
<textarea name="Documents[${nextIndex}].Description" class="form-control" rows="2" placeholder="Resumo do conteúdo"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Arquivo PDF</label>
<input type="file" name="Documents[${nextIndex}].DocumentFile" class="form-control" accept="application/pdf">
<small class="form-text text-muted">Envie um arquivo PDF (máx. 10MB).</small>
</div>
<input type="hidden" name="Documents[${nextIndex}].Id" value="">
<input type="hidden" name="Documents[${nextIndex}].DocumentId" value="">
<input type="hidden" name="Documents[${nextIndex}].FileName" value="">
<input type="hidden" name="Documents[${nextIndex}].FileSize" value="0">
<input type="hidden" name="Documents[${nextIndex}].UploadedAt" value="">
<input type="hidden" name="Documents[${nextIndex}].MarkForRemoval" value="false">
</div>`;
$container.append(template);
documentCount++;
$('#documentsEmptyState').hide();
toggleDocumentEmptyState();
toggleDocumentAddButton();
}
function updateDocumentNumbers() {
$('#documentsContainer .document-input-group').not('.document-removed').each(function(index) {
$(this).attr('data-document', index);
$(this).find('h6').text('Documento ' + (index + 1));
});
}
function saveNormalLink() { function saveNormalLink() {
const title = $('#linkTitle').val().trim(); const title = $('#linkTitle').val().trim();
const url = $('#linkUrl').val().trim(); const url = $('#linkUrl').val().trim();

View File

@ -269,6 +269,10 @@
<i class="text-success me-2">✓</i> <i class="text-success me-2">✓</i>
Suporte prioritário Suporte prioritário
</li> </li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Upload de PDFs (até 5 arquivos)
</li>
<li class="mb-3"> <li class="mb-3">
<i class="text-muted me-2">✗</i> <i class="text-muted me-2">✗</i>
<span class="text-muted">Links de produto</span> <span class="text-muted">Links de produto</span>
@ -354,6 +358,10 @@
<i class="text-success me-2">✓</i> <i class="text-success me-2">✓</i>
10 links afiliados 10 links afiliados
</li> </li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Upload de PDFs (até 10 arquivos)
</li>
</ul> </ul>
<div class="mt-3"> <div class="mt-3">
<small class="text-muted">* 20 temas básicos + 20 temas premium exclusivos</small> <small class="text-muted">* 20 temas básicos + 20 temas premium exclusivos</small>

View File

@ -44,6 +44,25 @@
margin-top: 2rem; margin-top: 2rem;
} }
.documents-section .list-group-item {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: inherit;
transition: all 0.2s ease;
border-radius: 12px;
padding: 1rem;
}
.documents-section .list-group-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.documents-section .list-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.qrcode-toggle { .qrcode-toggle {
width: 100%; width: 100%;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
@ -309,7 +328,61 @@
} }
} }
} }
else
@* Documentos integrados no mesmo container de links *@
@if (Model.Documents?.Any() == true)
{
@for (int docIndex = 0; docIndex < Model.Documents.Count; docIndex++)
{
var document = Model.Documents[docIndex];
var hasDescription = !string.IsNullOrEmpty(document.Description);
var uniqueId = $"doc-{docIndex}";
<div class="universal-link" data-document-id="@uniqueId">
<a href="/api/document/@document.FileId"
class="universal-link-header"
target="_blank"
rel="noopener noreferrer">
<div class="universal-link-content">
<div class="link-icon">
<i class="fas fa-file-pdf"></i>
</div>
<div class="link-text-container">
<div class="link-title">@document.Title</div>
@if (hasDescription && document.Description.Length > 50)
{
<div class="link-subtitle">@(document.Description.Substring(0, 50))...</div>
}
</div>
</div>
@if (hasDescription)
{
<button class="expand-arrow"
type="button"
onclick="event.preventDefault(); event.stopPropagation(); toggleLinkDetails('@uniqueId')">
<i class="fas fa-chevron-down"></i>
</button>
}
</a>
@if (hasDescription)
{
<div class="universal-link-details" id="details-@uniqueId">
<div class="expanded-description">@document.Description</div>
<div class="expanded-action">
<i class="fas fa-external-link-alt"></i>
Clique no título acima para abrir o PDF
</div>
</div>
}
</div>
}
}
@if ((Model.Links?.Any(l => l.IsActive) != true) && (Model.Documents?.Any() != true))
{ {
<div class="text-muted"> <div class="text-muted">
<p>Nenhum link disponível no momento.</p> <p>Nenhum link disponível no momento.</p>
@ -392,7 +465,11 @@
function toggleLinkDetails(linkIndex) { function toggleLinkDetails(linkIndex) {
const currentDetails = document.getElementById('details-' + linkIndex); const currentDetails = document.getElementById('details-' + linkIndex);
const currentArrow = document.querySelector(`[data-link-id="${linkIndex}"] .expand-arrow`); // Suporta tanto data-link-id (links) quanto data-document-id (documentos)
let currentArrow = document.querySelector(`[data-link-id="${linkIndex}"] .expand-arrow`);
if (!currentArrow) {
currentArrow = document.querySelector(`[data-document-id="${linkIndex}"] .expand-arrow`);
}
if (!currentDetails || !currentArrow) return; if (!currentDetails || !currentArrow) return;
const isCurrentlyExpanded = currentDetails.classList.contains('show'); const isCurrentlyExpanded = currentDetails.classList.contains('show');

View File

@ -22,101 +22,117 @@
"Basic": { "Basic": {
"Name": "Básico", "Name": "Básico",
"PriceId": "price_1RycPaBMIadsOxJVKioZZofK", "PriceId": "price_1RycPaBMIadsOxJVKioZZofK",
"Price": 5.90, "Price": 12.90,
"MaxPages": 3, "MaxPages": 3,
"MaxLinks": 8, "MaxLinks": 8,
"AllowPremiumThemes": false, "AllowPremiumThemes": false,
"AllowProductLinks": false, "AllowProductLinks": false,
"AllowAnalytics": true, "AllowAnalytics": true,
"AllowDocumentUpload": false,
"MaxDocuments": 0,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ], "Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ],
"Interval": "month" "Interval": "month"
}, },
"Professional": { "Professional": {
"Name": "Profissional", "Name": "Profissional",
"PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj", "PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj",
"Price": 12.90, "Price": 25.90,
"MaxPages": 5, "MaxPages": 5,
"MaxLinks": 20, "MaxLinks": 20,
"AllowPremiumThemes": false, "AllowPremiumThemes": false,
"AllowProductLinks": false, "AllowProductLinks": false,
"AllowAnalytics": true, "AllowAnalytics": true,
"AllowDocumentUpload": false,
"MaxDocuments": 0,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ], "Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ],
"Interval": "month" "Interval": "month"
}, },
"Premium": { "Premium": {
"Name": "Premium", "Name": "Premium",
"PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu", "PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu",
"Price": 19.90, "Price": 29.90,
"MaxPages": 15, "MaxPages": 15,
"MaxLinks": -1, "MaxLinks": -1,
"AllowPremiumThemes": true, "AllowPremiumThemes": true,
"AllowProductLinks": false, "AllowProductLinks": false,
"AllowAnalytics": true, "AllowAnalytics": true,
"SpecialModeration": false, "SpecialModeration": false,
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados" ], "AllowDocumentUpload": true,
"MaxDocuments": 5,
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Upload de PDFs (até 5 arquivos)" ],
"Interval": "month" "Interval": "month"
}, },
"PremiumAffiliate": { "PremiumAffiliate": {
"Name": "Premium+Afiliados", "Name": "Premium+Afiliados",
"PriceId": "price_1RycTaBMIadsOxJVeDLseXQq", "PriceId": "price_1RycTaBMIadsOxJVeDLseXQq",
"Price": 29.90, "Price": 34.90,
"MaxPages": 15, "MaxPages": 15,
"MaxLinks": -1, "MaxLinks": -1,
"AllowPremiumThemes": true, "AllowPremiumThemes": true,
"AllowProductLinks": true, "AllowProductLinks": true,
"AllowAnalytics": true, "AllowAnalytics": true,
"SpecialModeration": true, "SpecialModeration": true,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados" ], "AllowDocumentUpload": true,
"MaxDocuments": 10,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Upload de PDFs (até 10 arquivos)" ],
"Interval": "month" "Interval": "month"
}, },
"BasicYearly": { "BasicYearly": {
"Name": "Básico Anual", "Name": "Básico Anual",
"PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS", "PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS",
"Price": 59.00, "Price": 129.00,
"MaxPages": 3, "MaxPages": 3,
"MaxLinks": 8, "MaxLinks": 8,
"AllowPremiumThemes": false, "AllowPremiumThemes": false,
"AllowProductLinks": false, "AllowProductLinks": false,
"AllowAnalytics": true, "AllowAnalytics": true,
"AllowDocumentUpload": false,
"MaxDocuments": 0,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ], "Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ],
"Interval": "year" "Interval": "year"
}, },
"ProfessionalYearly": { "ProfessionalYearly": {
"Name": "Profissional Anual", "Name": "Profissional Anual",
"PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm", "PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm",
"Price": 129.00, "Price": 259.00,
"MaxPages": 5, "MaxPages": 5,
"MaxLinks": 20, "MaxLinks": 20,
"AllowPremiumThemes": false, "AllowPremiumThemes": false,
"AllowProductLinks": false, "AllowProductLinks": false,
"AllowAnalytics": true, "AllowAnalytics": true,
"AllowDocumentUpload": false,
"MaxDocuments": 0,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ], "Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ],
"Interval": "year" "Interval": "year"
}, },
"PremiumYearly": { "PremiumYearly": {
"Name": "Premium Anual", "Name": "Premium Anual",
"PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m", "PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m",
"Price": 199.00, "Price": 299.00,
"MaxPages": 15, "MaxPages": 15,
"MaxLinks": -1, "MaxLinks": -1,
"AllowPremiumThemes": true, "AllowPremiumThemes": true,
"AllowProductLinks": false, "AllowProductLinks": false,
"AllowAnalytics": true, "AllowAnalytics": true,
"SpecialModeration": false, "SpecialModeration": false,
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Economize R$ 39,80 (2 meses grátis)" ], "AllowDocumentUpload": true,
"MaxDocuments": 5,
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Upload de PDFs (até 5 arquivos)", "Economize R$ 39,80 (2 meses grátis)" ],
"Interval": "year" "Interval": "year"
}, },
"PremiumAffiliateYearly": { "PremiumAffiliateYearly": {
"Name": "Premium+Afiliados Anual", "Name": "Premium+Afiliados Anual",
"PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1", "PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1",
"Price": 299.00, "Price": 349.00,
"MaxPages": 15, "MaxPages": 15,
"MaxLinks": -1, "MaxLinks": -1,
"AllowPremiumThemes": true, "AllowPremiumThemes": true,
"AllowProductLinks": true, "AllowProductLinks": true,
"AllowAnalytics": true, "AllowAnalytics": true,
"SpecialModeration": true, "SpecialModeration": true,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Economize R$ 59,80 (2 meses grátis)" ], "AllowDocumentUpload": true,
"MaxDocuments": 10,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Upload de PDFs (até 10 arquivos)", "Economize R$ 59,80 (2 meses grátis)" ],
"Interval": "year" "Interval": "year"
} }
}, },