feat: upload de PDF
This commit is contained in:
parent
0803a3bcc9
commit
230c6a958d
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
64
src/BCards.Web/Controllers/DocumentController.cs
Normal file
64
src/BCards.Web/Controllers/DocumentController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
30
src/BCards.Web/Models/PageDocument.cs
Normal file
30
src/BCards.Web/Models/PageDocument.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
93
src/BCards.Web/Services/GridFSDocumentStorage.cs
Normal file
93
src/BCards.Web/Services/GridFSDocumentStorage.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/BCards.Web/Services/IDocumentStorageService.cs
Normal file
9
src/BCards.Web/Services/IDocumentStorageService.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
@ -224,17 +224,158 @@
|
|||||||
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Passo 3: Links Principais -->
|
<!-- 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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user