Compare commits
2 Commits
0803a3bcc9
...
b6a5329a6b
| Author | SHA1 | Date | |
|---|---|---|---|
| b6a5329a6b | |||
| 230c6a958d |
@ -259,6 +259,7 @@ Antes de seu perfil ficar público:
|
||||
- Logo personalizado
|
||||
- Cores customizadas
|
||||
- Suporte prioritário
|
||||
- Upload de PDFs (até 5 arquivos)
|
||||
|
||||
**Economia Real:**
|
||||
- LinkTree Pro anual: ~$108 + IOF = ~R$ 540-600
|
||||
|
||||
@ -174,6 +174,7 @@ O Dr. Pedro usa seu BCard para:
|
||||
- Logo do escritório
|
||||
- Cores personalizadas
|
||||
- Suporte prioritário
|
||||
- Upload de PDFs para contratos, petições e materiais exclusivos
|
||||
|
||||
## Conclusão
|
||||
|
||||
|
||||
@ -119,6 +119,7 @@ Clique em todos os seus links periodicamente para garantir que estão funcionand
|
||||
- Temas personalizados
|
||||
- Logo personalizado
|
||||
- Suporte prioritário
|
||||
- Upload de PDFs para materiais extras
|
||||
|
||||
## Conclusão
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ Um clone profissional do LinkTree desenvolvido em ASP.NET Core MVC, focado no me
|
||||
### 🎯 Planos e Pricing (Estratégia Decoy)
|
||||
- **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)*
|
||||
- **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
|
||||
|
||||
|
||||
@ -5,9 +5,13 @@ using BCards.Web.ViewModels;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BCards.Web.Controllers;
|
||||
|
||||
@ -23,9 +27,11 @@ public class AdminController : Controller
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly ILivePageService _livePageService;
|
||||
private readonly IImageStorageService _imageStorage;
|
||||
private readonly IDocumentStorageService _documentStorage;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IDowngradeService _downgradeService;
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AdminController(
|
||||
IAuthService authService,
|
||||
@ -36,9 +42,11 @@ public class AdminController : Controller
|
||||
IEmailService emailService,
|
||||
ILivePageService livePageService,
|
||||
IImageStorageService imageStorage,
|
||||
IDocumentStorageService documentStorage,
|
||||
IPaymentService paymentService,
|
||||
IDowngradeService downgradeService,
|
||||
ILogger<AdminController> logger)
|
||||
ILogger<AdminController> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_authService = authService;
|
||||
_userPageService = userPageService;
|
||||
@ -48,9 +56,11 @@ public class AdminController : Controller
|
||||
_emailService = emailService;
|
||||
_livePageService = livePageService;
|
||||
_imageStorage = imageStorage;
|
||||
_documentStorage = documentStorage;
|
||||
_paymentService = paymentService;
|
||||
_downgradeService = downgradeService;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
|
||||
@ -175,7 +185,11 @@ public class AdminController : Controller
|
||||
AvailableCategories = categories,
|
||||
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
|
||||
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(),
|
||||
AllowProductLinks = planLimitations.AllowProductLinks
|
||||
AllowProductLinks = planLimitations.AllowProductLinks,
|
||||
AllowDocumentUpload = planLimitations.AllowDocumentUpload,
|
||||
MaxDocumentsAllowed = planLimitations.MaxDocuments,
|
||||
Documents = new List<ManageDocumentViewModel>(),
|
||||
DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay()
|
||||
};
|
||||
return View(model);
|
||||
}
|
||||
@ -214,6 +228,12 @@ public class AdminController : Controller
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
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)
|
||||
CleanSocialMediaFields(model);
|
||||
@ -255,13 +275,13 @@ public class AdminController : Controller
|
||||
TempData["ImageError"] = errorMessage;
|
||||
|
||||
// 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.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||
model.MaxLinksAllowed = userPlanType.GetMaxLinksPerPage();
|
||||
model.AllowProductLinks = planLimitations.AllowProductLinks;
|
||||
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
|
||||
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
|
||||
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
|
||||
|
||||
// Preservar ProfileImageId existente se estava editando
|
||||
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)
|
||||
{
|
||||
var sbError = new StringBuilder();
|
||||
@ -297,6 +319,10 @@ public class AdminController : Controller
|
||||
model.Slug = slug;
|
||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||
model.AllowProductLinks = planLimitations.AllowProductLinks;
|
||||
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
|
||||
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
|
||||
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
|
||||
return View(model);
|
||||
}
|
||||
|
||||
@ -304,7 +330,6 @@ public class AdminController : Controller
|
||||
{
|
||||
// CRITICAL: Check if user can create new page (validate MaxPages limit)
|
||||
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();
|
||||
|
||||
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.";
|
||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
|
||||
return View(model);
|
||||
}
|
||||
|
||||
@ -327,6 +353,7 @@ public class AdminController : Controller
|
||||
ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra.");
|
||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
|
||||
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.");
|
||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
|
||||
return View(model);
|
||||
}
|
||||
|
||||
@ -344,6 +372,7 @@ public class AdminController : Controller
|
||||
{
|
||||
// Create new page
|
||||
var userPage = await MapToUserPage(model, user.Id);
|
||||
userPage.Documents = processedDocuments;
|
||||
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
|
||||
|
||||
// Set status to Creating for new pages
|
||||
@ -362,6 +391,7 @@ public class AdminController : Controller
|
||||
ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
|
||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
|
||||
TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}";
|
||||
return View(model);
|
||||
}
|
||||
@ -371,7 +401,15 @@ public class AdminController : Controller
|
||||
// Update existing page
|
||||
var existingPage = await _userPageService.GetPageByIdAsync(model.Id);
|
||||
if (existingPage == null || existingPage.UserId != user.Id)
|
||||
{
|
||||
await CleanupNewDocumentsAsync(newFileIds);
|
||||
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)
|
||||
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
|
||||
existingPage.Status = ViewModels.PageStatus.Creating;
|
||||
@ -405,6 +443,21 @@ public class AdminController : Controller
|
||||
|
||||
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"
|
||||
|
||||
// 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)
|
||||
{
|
||||
var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString());
|
||||
|
||||
return new ManagePageViewModel
|
||||
{
|
||||
Id = page.Id,
|
||||
@ -772,10 +827,23 @@ public class AdminController : Controller
|
||||
ProductDescription = l.ProductDescription,
|
||||
ProductDataCachedAt = l.ProductDataCachedAt
|
||||
}).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,
|
||||
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
|
||||
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;
|
||||
}
|
||||
|
||||
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.Category = model.Category;
|
||||
@ -1077,6 +1336,8 @@ public class AdminController : Controller
|
||||
}
|
||||
|
||||
page.Links.AddRange(socialLinks);
|
||||
|
||||
page.Documents = documents ?? new List<PageDocument>();
|
||||
}
|
||||
|
||||
[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,
|
||||
SpecialModeration = source.SpecialModeration,
|
||||
OGExtractionsUsedToday = 0,
|
||||
LastExtractionDate = null
|
||||
LastExtractionDate = null,
|
||||
AllowDocumentUpload = source.AllowDocumentUpload,
|
||||
MaxDocuments = source.MaxDocuments
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -90,7 +90,9 @@ public class PlanLimitationMiddleware
|
||||
AllowCustomDomain = false,
|
||||
AllowMultipleDomains = false,
|
||||
PrioritySupport = false,
|
||||
PlanType = "basic"
|
||||
PlanType = "basic",
|
||||
AllowDocumentUpload = false,
|
||||
MaxDocuments = 0
|
||||
},
|
||||
"professional" => new Models.PlanLimitations
|
||||
{
|
||||
@ -100,7 +102,9 @@ public class PlanLimitationMiddleware
|
||||
AllowCustomDomain = true,
|
||||
AllowMultipleDomains = false,
|
||||
PrioritySupport = false,
|
||||
PlanType = "professional"
|
||||
PlanType = "professional",
|
||||
AllowDocumentUpload = false,
|
||||
MaxDocuments = 0
|
||||
},
|
||||
"premium" => new Models.PlanLimitations
|
||||
{
|
||||
@ -110,7 +114,21 @@ public class PlanLimitationMiddleware
|
||||
AllowCustomDomain = true,
|
||||
AllowMultipleDomains = 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
|
||||
{
|
||||
@ -120,7 +138,9 @@ public class PlanLimitationMiddleware
|
||||
AllowCustomDomain = false,
|
||||
AllowMultipleDomains = false,
|
||||
PrioritySupport = false,
|
||||
PlanType = "free"
|
||||
PlanType = "free",
|
||||
AllowDocumentUpload = false,
|
||||
MaxDocuments = 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
string BusinessType { get; }
|
||||
PageTheme Theme { get; }
|
||||
List<LinkItem> Links { get; }
|
||||
List<PageDocument> Documents { get; }
|
||||
SeoSettings SeoSettings { get; }
|
||||
string Language { get; }
|
||||
DateTime CreatedAt { get; }
|
||||
|
||||
@ -47,6 +47,9 @@ public class LivePage : IPageDisplay
|
||||
[BsonElement("links")]
|
||||
public List<LinkItem> Links { get; set; } = new();
|
||||
|
||||
[BsonElement("documents")]
|
||||
public List<PageDocument> Documents { get; set; } = new();
|
||||
|
||||
[BsonElement("seoSettings")]
|
||||
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")]
|
||||
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
|
||||
{
|
||||
PlanType.Trial => 0.00m,
|
||||
PlanType.Basic => 5.90m,
|
||||
PlanType.Professional => 12.90m,
|
||||
PlanType.Premium => 19.90m,
|
||||
PlanType.PremiumAffiliate => 29.90m,
|
||||
PlanType.Basic => 12.90m,
|
||||
PlanType.Professional => 25.90m,
|
||||
PlanType.Premium => 29.90m,
|
||||
PlanType.PremiumAffiliate => 34.90m,
|
||||
_ => 0.00m
|
||||
};
|
||||
}
|
||||
|
||||
@ -44,6 +44,9 @@ public class UserPage : IPageDisplay
|
||||
[BsonElement("links")]
|
||||
public List<LinkItem> Links { get; set; } = new();
|
||||
|
||||
[BsonElement("documents")]
|
||||
public List<PageDocument> Documents { get; set; } = new();
|
||||
|
||||
[BsonElement("seoSettings")]
|
||||
public SeoSettings SeoSettings { get; set; } = new();
|
||||
|
||||
|
||||
@ -483,6 +483,7 @@ builder.Services.AddScoped<IEmailService, EmailService>();
|
||||
builder.Services.AddScoped<IDowngradeService, DowngradeService>();
|
||||
|
||||
builder.Services.AddScoped<IImageStorageService, GridFSImageStorage>();
|
||||
builder.Services.AddScoped<IDocumentStorageService, GridFSDocumentStorage>();
|
||||
|
||||
// Support Area - Rating and Contact System
|
||||
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)
|
||||
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.ValueCountLimit = int.MaxValue;
|
||||
options.KeyLengthLimit = int.MaxValue;
|
||||
options.BufferBody = true;
|
||||
options.BufferBodyLengthLimit = 5 * 1024 * 1024; // 5MB
|
||||
options.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5MB
|
||||
options.BufferBodyLengthLimit = maxUploadSize;
|
||||
options.MultipartBodyLengthLimit = maxUploadSize;
|
||||
options.MultipartHeadersLengthLimit = 16384;
|
||||
});
|
||||
|
||||
// Configure Kestrel server limits for larger requests
|
||||
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.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);
|
||||
}
|
||||
@ -51,6 +51,8 @@ public class PlanConfiguration
|
||||
public bool AllowProductLinks { get; set; }
|
||||
public bool AllowAnalytics { get; set; }
|
||||
public bool? SpecialModeration { get; set; }
|
||||
public bool AllowDocumentUpload { get; set; }
|
||||
public int MaxDocuments { get; set; }
|
||||
public List<string> Features { get; set; } = new();
|
||||
public string Interval { get; set; } = "month";
|
||||
public PlanType BasePlanType { get; set; }
|
||||
|
||||
@ -59,6 +59,7 @@ public class LivePageService : ILivePageService
|
||||
BusinessType = userPage.BusinessType,
|
||||
Theme = userPage.Theme,
|
||||
Links = userPage.Links,
|
||||
Documents = userPage.Documents,
|
||||
SeoSettings = userPage.SeoSettings,
|
||||
Language = userPage.Language,
|
||||
Analytics = new LivePageAnalytics
|
||||
|
||||
@ -47,7 +47,9 @@ public class PlanConfigurationService : IPlanConfigurationService
|
||||
PrioritySupport = false,
|
||||
AllowProductLinks = false,
|
||||
MaxProductLinks = 0,
|
||||
PlanType = "trial"
|
||||
PlanType = "trial",
|
||||
AllowDocumentUpload = false,
|
||||
MaxDocuments = 0
|
||||
},
|
||||
PlanType.Basic => new PlanLimitations
|
||||
{
|
||||
@ -59,7 +61,9 @@ public class PlanConfigurationService : IPlanConfigurationService
|
||||
PrioritySupport = false,
|
||||
AllowProductLinks = GetConfigValue(PlanType.Basic, "AllowProductLinks", false),
|
||||
MaxProductLinks = 0,
|
||||
PlanType = "basic"
|
||||
PlanType = "basic",
|
||||
AllowDocumentUpload = GetConfigValue(PlanType.Basic, "AllowDocumentUpload", false),
|
||||
MaxDocuments = GetConfigValue(PlanType.Basic, "MaxDocuments", 0)
|
||||
},
|
||||
PlanType.Professional => new PlanLimitations
|
||||
{
|
||||
@ -71,7 +75,9 @@ public class PlanConfigurationService : IPlanConfigurationService
|
||||
PrioritySupport = false,
|
||||
AllowProductLinks = GetConfigValue(PlanType.Professional, "AllowProductLinks", false),
|
||||
MaxProductLinks = 0,
|
||||
PlanType = "professional"
|
||||
PlanType = "professional",
|
||||
AllowDocumentUpload = GetConfigValue(PlanType.Professional, "AllowDocumentUpload", false),
|
||||
MaxDocuments = GetConfigValue(PlanType.Professional, "MaxDocuments", 0)
|
||||
},
|
||||
PlanType.Premium => new PlanLimitations
|
||||
{
|
||||
@ -83,7 +89,9 @@ public class PlanConfigurationService : IPlanConfigurationService
|
||||
PrioritySupport = true,
|
||||
AllowProductLinks = GetConfigValue(PlanType.Premium, "AllowProductLinks", false),
|
||||
MaxProductLinks = 0,
|
||||
PlanType = "premium"
|
||||
PlanType = "premium",
|
||||
AllowDocumentUpload = GetConfigValue(PlanType.Premium, "AllowDocumentUpload", true),
|
||||
MaxDocuments = GetConfigValue(PlanType.Premium, "MaxDocuments", 5)
|
||||
},
|
||||
PlanType.PremiumAffiliate => new PlanLimitations
|
||||
{
|
||||
@ -95,9 +103,11 @@ public class PlanConfigurationService : IPlanConfigurationService
|
||||
PrioritySupport = true,
|
||||
AllowProductLinks = GetConfigValue(PlanType.PremiumAffiliate, "AllowProductLinks", true),
|
||||
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 }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -206,7 +206,7 @@ public class TrialExpirationService : BackgroundService
|
||||
|
||||
• Básico - R$ 12,90/mês - 5 links, analytics básicos
|
||||
• Profissional - R$ 25,90/mês - 15 links, todos os temas, analytics avançados
|
||||
• Premium - R$ 29,90/mês - Links ilimitados, temas premium, analytics completos
|
||||
• Premium - R$ 29,90/mês - Links ilimitados, temas premium, analytics completos, upload de PDFs
|
||||
|
||||
Seus dados estão seguros e serão restaurados assim que você escolher um plano.
|
||||
|
||||
|
||||
@ -43,6 +43,7 @@ public class ManagePageViewModel
|
||||
public string KawaiUrl { get; set; } = string.Empty;
|
||||
|
||||
public List<ManageLinkViewModel> Links { get; set; } = new();
|
||||
public List<ManageDocumentViewModel> Documents { get; set; } = new();
|
||||
|
||||
// Profile image fields
|
||||
public string? ProfileImageId { get; set; }
|
||||
@ -55,6 +56,9 @@ public class ManagePageViewModel
|
||||
// Plan limitations
|
||||
public int MaxLinksAllowed { get; set; } = 3;
|
||||
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());
|
||||
|
||||
/// <summary>
|
||||
@ -101,6 +105,28 @@ public class ManageLinkViewModel
|
||||
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 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -224,17 +224,158 @@
|
||||
Próximo <i class="fas fa-arrow-right ms-1"></i>
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Passo 3: Links Principais -->
|
||||
<!-- Passo 4: Links Principais -->
|
||||
<div class="accordion-item">
|
||||
<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">
|
||||
<i class="fas fa-link me-2"></i>
|
||||
Passo 3: Links Principais
|
||||
<span class="badge bg-success ms-auto me-3" id="step3Status" style="display: none;">✓</span>
|
||||
Passo 4: Links Principais
|
||||
<span class="badge bg-success ms-auto me-3" id="step4Status" style="display: none;">✓</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseLinks" class="accordion-collapse collapse" aria-labelledby="headingLinks" data-bs-parent="#pageWizard">
|
||||
@ -371,10 +512,10 @@
|
||||
</button>
|
||||
|
||||
<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
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@ -399,13 +540,13 @@
|
||||
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/","") : "";
|
||||
}
|
||||
<!-- Passo 4: Redes Sociais (Opcional) -->
|
||||
<!-- Passo 5: Redes Sociais (Opcional) -->
|
||||
<div class="accordion-item">
|
||||
<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">
|
||||
<i class="fab fa-twitter me-2"></i>
|
||||
Passo 4: Redes Sociais (Opcional)
|
||||
<span class="badge bg-success ms-auto me-3" id="step4Status" style="display: none;">✓</span>
|
||||
Passo 5: Redes Sociais (Opcional)
|
||||
<span class="badge bg-success ms-auto me-3" id="step5Status" style="display: none;">✓</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseSocial" class="accordion-collapse collapse" aria-labelledby="headingSocial" data-bs-parent="#pageWizard">
|
||||
@ -590,7 +731,7 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
@ -1039,7 +1180,9 @@
|
||||
|
||||
<script>
|
||||
let linkCount = @Model.Links.Count;
|
||||
let documentCount = @(Model.Documents?.Count ?? 0);
|
||||
let currentStep = 1;
|
||||
const totalSteps = 5;
|
||||
|
||||
$(document).ready(function() {
|
||||
// Initialize social media fields
|
||||
@ -1051,6 +1194,9 @@
|
||||
// Initialize URL input handlers
|
||||
initializeUrlInputs();
|
||||
|
||||
// Initialize document handlers
|
||||
initializeDocumentHandlers();
|
||||
|
||||
// Check for validation errors and show toast + open accordion
|
||||
checkValidationErrors();
|
||||
|
||||
@ -1097,7 +1243,7 @@
|
||||
const maxlinks = @Model.MaxLinksAllowed;
|
||||
// Links do modal são limitados pelo MaxLinksAllowed (redes sociais são separadas e opcionais)
|
||||
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;
|
||||
}
|
||||
});
|
||||
@ -1189,7 +1335,7 @@
|
||||
}
|
||||
|
||||
function getStepName(step) {
|
||||
const names = ['', 'Basic', 'Theme', 'Links', 'Social'];
|
||||
const names = ['', 'Basic', 'Theme', 'Documents', 'Links', 'Social'];
|
||||
return names[step];
|
||||
}
|
||||
|
||||
@ -1216,9 +1362,9 @@
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
const progress = (currentStep / 4) * 100;
|
||||
const progress = (currentStep / totalSteps) * 100;
|
||||
$('.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() {
|
||||
@ -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() {
|
||||
const title = $('#linkTitle').val().trim();
|
||||
const url = $('#linkUrl').val().trim();
|
||||
|
||||
@ -269,6 +269,10 @@
|
||||
<i class="text-success me-2">✓</i>
|
||||
Suporte prioritário
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Upload de PDFs (até 5 arquivos)
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-muted me-2">✗</i>
|
||||
<span class="text-muted">Links de produto</span>
|
||||
@ -354,6 +358,10 @@
|
||||
<i class="text-success me-2">✓</i>
|
||||
10 links afiliados
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Upload de PDFs (até 10 arquivos)
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">* 20 temas básicos + 20 temas premium exclusivos</small>
|
||||
|
||||
@ -44,6 +44,25 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
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">
|
||||
<p>Nenhum link disponível no momento.</p>
|
||||
@ -392,7 +465,11 @@
|
||||
|
||||
function toggleLinkDetails(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;
|
||||
|
||||
const isCurrentlyExpanded = currentDetails.classList.contains('show');
|
||||
|
||||
@ -22,101 +22,117 @@
|
||||
"Basic": {
|
||||
"Name": "Básico",
|
||||
"PriceId": "price_1RycPaBMIadsOxJVKioZZofK",
|
||||
"Price": 5.90,
|
||||
"Price": 12.90,
|
||||
"MaxPages": 3,
|
||||
"MaxLinks": 8,
|
||||
"AllowPremiumThemes": false,
|
||||
"AllowProductLinks": false,
|
||||
"AllowAnalytics": true,
|
||||
"AllowDocumentUpload": false,
|
||||
"MaxDocuments": 0,
|
||||
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ],
|
||||
"Interval": "month"
|
||||
},
|
||||
"Professional": {
|
||||
"Name": "Profissional",
|
||||
"PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj",
|
||||
"Price": 12.90,
|
||||
"Price": 25.90,
|
||||
"MaxPages": 5,
|
||||
"MaxLinks": 20,
|
||||
"AllowPremiumThemes": false,
|
||||
"AllowProductLinks": false,
|
||||
"AllowAnalytics": true,
|
||||
"AllowDocumentUpload": false,
|
||||
"MaxDocuments": 0,
|
||||
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ],
|
||||
"Interval": "month"
|
||||
},
|
||||
"Premium": {
|
||||
"Name": "Premium",
|
||||
"PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu",
|
||||
"Price": 19.90,
|
||||
"Price": 29.90,
|
||||
"MaxPages": 15,
|
||||
"MaxLinks": -1,
|
||||
"AllowPremiumThemes": true,
|
||||
"AllowProductLinks": false,
|
||||
"AllowAnalytics": true,
|
||||
"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"
|
||||
},
|
||||
"PremiumAffiliate": {
|
||||
"Name": "Premium+Afiliados",
|
||||
"PriceId": "price_1RycTaBMIadsOxJVeDLseXQq",
|
||||
"Price": 29.90,
|
||||
"Price": 34.90,
|
||||
"MaxPages": 15,
|
||||
"MaxLinks": -1,
|
||||
"AllowPremiumThemes": true,
|
||||
"AllowProductLinks": true,
|
||||
"AllowAnalytics": 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"
|
||||
},
|
||||
"BasicYearly": {
|
||||
"Name": "Básico Anual",
|
||||
"PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS",
|
||||
"Price": 59.00,
|
||||
"Price": 129.00,
|
||||
"MaxPages": 3,
|
||||
"MaxLinks": 8,
|
||||
"AllowPremiumThemes": false,
|
||||
"AllowProductLinks": false,
|
||||
"AllowAnalytics": true,
|
||||
"AllowDocumentUpload": false,
|
||||
"MaxDocuments": 0,
|
||||
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ],
|
||||
"Interval": "year"
|
||||
},
|
||||
"ProfessionalYearly": {
|
||||
"Name": "Profissional Anual",
|
||||
"PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm",
|
||||
"Price": 129.00,
|
||||
"Price": 259.00,
|
||||
"MaxPages": 5,
|
||||
"MaxLinks": 20,
|
||||
"AllowPremiumThemes": false,
|
||||
"AllowProductLinks": false,
|
||||
"AllowAnalytics": true,
|
||||
"AllowDocumentUpload": false,
|
||||
"MaxDocuments": 0,
|
||||
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ],
|
||||
"Interval": "year"
|
||||
},
|
||||
"PremiumYearly": {
|
||||
"Name": "Premium Anual",
|
||||
"PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m",
|
||||
"Price": 199.00,
|
||||
"Price": 299.00,
|
||||
"MaxPages": 15,
|
||||
"MaxLinks": -1,
|
||||
"AllowPremiumThemes": true,
|
||||
"AllowProductLinks": false,
|
||||
"AllowAnalytics": true,
|
||||
"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"
|
||||
},
|
||||
"PremiumAffiliateYearly": {
|
||||
"Name": "Premium+Afiliados Anual",
|
||||
"PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1",
|
||||
"Price": 299.00,
|
||||
"Price": 349.00,
|
||||
"MaxPages": 15,
|
||||
"MaxLinks": -1,
|
||||
"AllowPremiumThemes": true,
|
||||
"AllowProductLinks": true,
|
||||
"AllowAnalytics": 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"
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user