feat/live-preview #8

Merged
ricardo merged 43 commits from feat/live-preview into main 2025-08-18 00:50:03 +00:00
3 changed files with 904 additions and 457 deletions
Showing only changes of commit 6bba003cb6 - Show all commits

View File

@ -138,6 +138,29 @@ public class AdminController : Controller
_logger.LogInformation($"ManagePage POST: IsNewPage={model.IsNewPage}, DisplayName={model.DisplayName}, Category={model.Category}, Links={model.Links?.Count ?? 0}");
// Filtrar links vazios ANTES da validação
if (model.Links != null)
{
model.Links = model.Links.Where(l => !l.IsEmpty).ToList();
// Reordenar os links restantes
for (int i = 0; i < model.Links.Count; i++)
{
model.Links[i].Order = i;
}
}
foreach (var link in model.Links)
{
if (string.IsNullOrEmpty(link.Id))
{
link.Id = Guid.NewGuid().ToString();
}
}
// Limpar ModelState de campos que foram removidos
RemoveEmptyLinkValidations();
if (!ModelState.IsValid)
{
_logger.LogWarning("ModelState is invalid:");
@ -169,16 +192,6 @@ public class AdminController : Controller
return View(model);
}
// Check if user can create the requested number of links
var activeLinksCount = model.Links?.Count ?? 0;
if (activeLinksCount > model.MaxLinksAllowed)
{
ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
return View(model);
}
try
{
// Create new page
@ -214,6 +227,27 @@ public class AdminController : Controller
return RedirectToAction("Dashboard");
}
private void RemoveEmptyLinkValidations()
{
var keysToRemove = ModelState.Keys
.Where(key => key.StartsWith("Links[") && (
key.Contains(".Title") ||
key.Contains(".Url") ||
key.Contains(".Description") ||
key.Contains(".ProductTitle") ||
key.Contains(".ProductDescription") ||
key.Contains(".ProductPrice") ||
key.Contains(".ProductImage")
))
.ToList();
foreach (var key in keysToRemove)
{
ModelState.Remove(key);
}
}
[HttpPost]
[Route("CreatePage")]
public async Task<IActionResult> CreatePage(CreatePageViewModel model)
@ -475,6 +509,39 @@ public class AdminController : Controller
private ManagePageViewModel MapToManageViewModel(UserPage page, List<Category> categories, List<PageTheme> themes, PlanType userPlanType)
{
// Separar links de redes sociais dos links normais
var socialMediaLinks = page.Links?.Where(l =>
l.Icon.Contains("whatsapp") ||
l.Icon.Contains("facebook") ||
l.Icon.Contains("twitter") ||
l.Icon.Contains("instagram")
).ToList() ?? new List<LinkItem>();
var regularLinks = page.Links?.Where(l =>
!l.Icon.Contains("whatsapp") &&
!l.Icon.Contains("facebook") &&
!l.Icon.Contains("twitter") &&
!l.Icon.Contains("instagram")
).ToList() ?? new List<LinkItem>();
// Extrair URLs das redes sociais
var whatsappLink = socialMediaLinks.FirstOrDefault(l => l.Icon.Contains("whatsapp"));
var facebookLink = socialMediaLinks.FirstOrDefault(l => l.Icon.Contains("facebook"));
var twitterLink = socialMediaLinks.FirstOrDefault(l => l.Icon.Contains("twitter"));
var instagramLink = socialMediaLinks.FirstOrDefault(l => l.Icon.Contains("instagram"));
// Extrair número do WhatsApp da URL
string whatsappNumber = "";
if (whatsappLink != null && whatsappLink.Url.Contains("wa.me/"))
{
var numberPart = whatsappLink.Url.Split("wa.me/").LastOrDefault();
if (!string.IsNullOrEmpty(numberPart))
{
// Formatar número para exibição (adicionar espaços, parênteses, etc.)
whatsappNumber = FormatWhatsAppNumber(numberPart);
}
}
return new ManagePageViewModel
{
Id = page.Id,
@ -485,9 +552,17 @@ public class AdminController : Controller
Bio = page.Bio,
Slug = page.Slug,
SelectedTheme = page.Theme?.Name ?? "minimalist",
Links = page.Links?.Select((l, index) => new ManageLinkViewModel
// Redes sociais
WhatsAppNumber = whatsappNumber,
FacebookUrl = facebookLink?.Url ?? "",
TwitterUrl = twitterLink?.Url ?? "",
InstagramUrl = instagramLink?.Url ?? "",
// Links regulares
Links = regularLinks.Select((l, index) => new ManageLinkViewModel
{
Id = $"link_{index}",
Id = page.Id + "_" + index, // ID único para identificação
Title = l.Title,
Url = l.Url,
Description = l.Description,
@ -500,7 +575,8 @@ public class AdminController : Controller
ProductPrice = l.ProductPrice,
ProductDescription = l.ProductDescription,
ProductDataCachedAt = l.ProductDataCachedAt
}).ToList() ?? new List<ManageLinkViewModel>(),
}).ToList(),
AvailableCategories = categories,
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage()
@ -524,44 +600,47 @@ public class AdminController : Controller
Links = new List<LinkItem>()
};
// Add regular links
// Add regular links (apenas links não vazios)
if (model.Links?.Any() == true)
{
userPage.Links.AddRange(model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url))
.Select((l, index) => new LinkItem
{
Title = l.Title,
Url = l.Url.ToLower(),
Description = l.Description,
Icon = l.Icon,
IsActive = l.IsActive,
Order = index,
Type = l.Type,
ProductTitle = l.ProductTitle,
ProductImage = l.ProductImage,
ProductPrice = l.ProductPrice,
ProductDescription = l.ProductDescription,
ProductDataCachedAt = l.ProductDataCachedAt
}));
var validLinks = model.Links.Where(l => !l.IsEmpty).ToList();
userPage.Links.AddRange(validLinks.Select((l, index) => new LinkItem
{
Title = l.Title,
Url = l.Url,
Description = l.Description ?? string.Empty,
Icon = l.Icon ?? string.Empty,
IsActive = l.IsActive,
Order = index,
Type = l.Type,
ProductTitle = l.ProductTitle ?? string.Empty,
ProductImage = l.ProductImage ?? string.Empty,
ProductPrice = l.ProductPrice ?? string.Empty,
ProductDescription = l.ProductDescription ?? string.Empty,
ProductDataCachedAt = l.ProductDataCachedAt
}));
}
// Add social media links
// Add social media links apenas se preenchidos
var socialLinks = new List<LinkItem>();
var currentOrder = userPage.Links.Count;
if (!string.IsNullOrEmpty(model.WhatsAppNumber))
if (!string.IsNullOrWhiteSpace(model.WhatsAppNumber))
{
var cleanNumber = model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "");
socialLinks.Add(new LinkItem
{
Title = "WhatsApp",
Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}",
Url = $"https://wa.me/{cleanNumber}",
Icon = "fab fa-whatsapp",
IsActive = true,
Order = currentOrder++
Order = currentOrder++,
Type = LinkType.Normal
});
}
if (!string.IsNullOrEmpty(model.FacebookUrl))
if (!string.IsNullOrWhiteSpace(model.FacebookUrl))
{
socialLinks.Add(new LinkItem
{
@ -569,11 +648,12 @@ public class AdminController : Controller
Url = model.FacebookUrl,
Icon = "fab fa-facebook",
IsActive = true,
Order = currentOrder++
Order = currentOrder++,
Type = LinkType.Normal
});
}
if (!string.IsNullOrEmpty(model.TwitterUrl))
if (!string.IsNullOrWhiteSpace(model.TwitterUrl))
{
socialLinks.Add(new LinkItem
{
@ -581,11 +661,12 @@ public class AdminController : Controller
Url = model.TwitterUrl,
Icon = "fab fa-x-twitter",
IsActive = true,
Order = currentOrder++
Order = currentOrder++,
Type = LinkType.Normal
});
}
if (!string.IsNullOrEmpty(model.InstagramUrl))
if (!string.IsNullOrWhiteSpace(model.InstagramUrl))
{
socialLinks.Add(new LinkItem
{
@ -593,7 +674,8 @@ public class AdminController : Controller
Url = model.InstagramUrl,
Icon = "fab fa-instagram",
IsActive = true,
Order = currentOrder++
Order = currentOrder++,
Type = LinkType.Normal
});
}
@ -610,47 +692,52 @@ public class AdminController : Controller
page.Slug = model.Slug;
page.UpdatedAt = DateTime.UtcNow;
// Update links
page.Links = new List<LinkItem>();
// Limpar todos os links existentes
page.Links.Clear();
// Add regular links
// Adicionar apenas links válidos (não vazios)
if (model.Links?.Any() == true)
{
page.Links.AddRange(model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url))
.Select((l, index) => new LinkItem
{
Title = l.Title,
Url = l.Url,
Description = l.Description,
Icon = l.Icon,
IsActive = l.IsActive,
Order = index,
Type = l.Type,
ProductTitle = l.ProductTitle,
ProductImage = l.ProductImage,
ProductPrice = l.ProductPrice,
ProductDescription = l.ProductDescription,
ProductDataCachedAt = l.ProductDataCachedAt
}));
var validLinks = model.Links.Where(l => !l.IsEmpty).ToList();
page.Links.AddRange(validLinks.Select((l, index) => new LinkItem
{
Title = l.Title,
Url = l.Url,
Description = l.Description ?? string.Empty,
Icon = l.Icon ?? string.Empty,
IsActive = l.IsActive,
Order = index,
Type = l.Type,
ProductTitle = l.ProductTitle ?? string.Empty,
ProductImage = l.ProductImage ?? string.Empty,
ProductPrice = l.ProductPrice ?? string.Empty,
ProductDescription = l.ProductDescription ?? string.Empty,
ProductDataCachedAt = l.ProductDataCachedAt,
CreatedAt = DateTime.UtcNow // Para novos links ou manter o existente se for uma atualização
}));
}
// Add social media links (same logic as create)
// Adicionar links de redes sociais apenas se preenchidos
var socialLinks = new List<LinkItem>();
var currentOrder = page.Links.Count;
if (!string.IsNullOrEmpty(model.WhatsAppNumber))
if (!string.IsNullOrWhiteSpace(model.WhatsAppNumber))
{
var cleanNumber = model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "");
socialLinks.Add(new LinkItem
{
Title = "WhatsApp",
Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}",
Url = $"https://wa.me/{cleanNumber}",
Icon = "fab fa-whatsapp",
IsActive = true,
Order = currentOrder++
Order = currentOrder++,
Type = LinkType.Normal,
CreatedAt = DateTime.UtcNow
});
}
if (!string.IsNullOrEmpty(model.FacebookUrl))
if (!string.IsNullOrWhiteSpace(model.FacebookUrl))
{
socialLinks.Add(new LinkItem
{
@ -658,11 +745,13 @@ public class AdminController : Controller
Url = model.FacebookUrl,
Icon = "fab fa-facebook",
IsActive = true,
Order = currentOrder++
Order = currentOrder++,
Type = LinkType.Normal,
CreatedAt = DateTime.UtcNow
});
}
if (!string.IsNullOrEmpty(model.TwitterUrl))
if (!string.IsNullOrWhiteSpace(model.TwitterUrl))
{
socialLinks.Add(new LinkItem
{
@ -670,11 +759,13 @@ public class AdminController : Controller
Url = model.TwitterUrl,
Icon = "fab fa-x-twitter",
IsActive = true,
Order = currentOrder++
Order = currentOrder++,
Type = LinkType.Normal,
CreatedAt = DateTime.UtcNow
});
}
if (!string.IsNullOrEmpty(model.InstagramUrl))
if (!string.IsNullOrWhiteSpace(model.InstagramUrl))
{
socialLinks.Add(new LinkItem
{
@ -682,10 +773,41 @@ public class AdminController : Controller
Url = model.InstagramUrl,
Icon = "fab fa-instagram",
IsActive = true,
Order = currentOrder++
Order = currentOrder++,
Type = LinkType.Normal,
CreatedAt = DateTime.UtcNow
});
}
page.Links.AddRange(socialLinks);
}
// Método helper para formatar número do WhatsApp
private string FormatWhatsAppNumber(string cleanNumber)
{
// Remove caracteres não numéricos
cleanNumber = new string(cleanNumber.Where(char.IsDigit).ToArray());
if (cleanNumber.Length >= 10)
{
// Formato brasileiro: +55 11 99999-9999
if (cleanNumber.StartsWith("55") && cleanNumber.Length >= 12)
{
var countryCode = cleanNumber.Substring(0, 2);
var areaCode = cleanNumber.Substring(2, 2);
var firstPart = cleanNumber.Substring(4, cleanNumber.Length - 8);
var lastPart = cleanNumber.Substring(cleanNumber.Length - 4);
return $"+{countryCode} {areaCode} {firstPart}-{lastPart}";
}
else
{
// Formato genérico
return "+" + cleanNumber;
}
}
return cleanNumber;
}
}

View File

@ -3,7 +3,7 @@ using BCards.Web.Models;
namespace BCards.Web.ViewModels;
public class ManagePageViewModel
public class ManagePageViewModel : IValidatableObject
{
public string Id { get; set; } = string.Empty;
public bool IsNewPage { get; set; } = true;
@ -26,12 +26,10 @@ public class ManagePageViewModel
[Required(ErrorMessage = "Tema é obrigatório")]
public string SelectedTheme { get; set; } = "minimalist";
// Redes sociais sem [Required] - serão validadas no método Validate
public string WhatsAppNumber { get; set; } = string.Empty;
public string FacebookUrl { get; set; } = string.Empty;
public string TwitterUrl { get; set; } = string.Empty;
public string InstagramUrl { get; set; } = string.Empty;
public List<ManageLinkViewModel> Links { get; set; } = new();
@ -43,44 +41,150 @@ public class ManagePageViewModel
// Plan limitations
public int MaxLinksAllowed { get; set; } = 3;
public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower());
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var results = new List<ValidationResult>();
// Validar URLs de redes sociais apenas se preenchidas
if (!string.IsNullOrWhiteSpace(FacebookUrl) && !Uri.TryCreate(FacebookUrl, UriKind.Absolute, out _))
{
results.Add(new ValidationResult("URL do Facebook inválida", new[] { nameof(FacebookUrl) }));
}
if (!string.IsNullOrWhiteSpace(TwitterUrl) && !Uri.TryCreate(TwitterUrl, UriKind.Absolute, out _))
{
results.Add(new ValidationResult("URL do Twitter inválida", new[] { nameof(TwitterUrl) }));
}
if (!string.IsNullOrWhiteSpace(InstagramUrl) && !Uri.TryCreate(InstagramUrl, UriKind.Absolute, out _))
{
results.Add(new ValidationResult("URL do Instagram inválida", new[] { nameof(InstagramUrl) }));
}
// Validar número do WhatsApp apenas se preenchido
if (!string.IsNullOrWhiteSpace(WhatsAppNumber))
{
var cleanNumber = WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "");
if (cleanNumber.Length < 10 || !cleanNumber.All(char.IsDigit))
{
results.Add(new ValidationResult("Número do WhatsApp inválido", new[] { nameof(WhatsAppNumber) }));
}
}
// Validar links ativos
var activeLinks = Links?.Where(l => !l.IsEmpty).ToList() ?? new List<ManageLinkViewModel>();
if (activeLinks.Count > MaxLinksAllowed)
{
results.Add(new ValidationResult($"Você pode ter no máximo {MaxLinksAllowed} links ativos", new[] { nameof(Links) }));
}
return results;
}
}
public class ManageLinkViewModel
public class ManageLinkViewModel : IValidatableObject
{
public string Id { get; set; } = "new";
[Required(ErrorMessage = "Título é obrigatório")]
[StringLength(50, ErrorMessage = "Título deve ter no máximo 50 caracteres")]
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Title { get; set; } = string.Empty;
[Required(ErrorMessage = "URL é obrigatória")]
[Url(ErrorMessage = "URL inválida")]
public string Url { get; set; } = string.Empty;
[StringLength(100, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")]
public string Description { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public int Order { get; set; } = 0;
public bool IsActive { get; set; } = true;
// Campos para Links de Produto
public LinkType Type { get; set; } = LinkType.Normal;
[StringLength(100, ErrorMessage = "Título do produto deve ter no máximo 100 caracteres")]
public string ProductTitle { get; set; } = string.Empty;
public string ProductImage { get; set; } = string.Empty;
[StringLength(50, ErrorMessage = "Preço deve ter no máximo 50 caracteres")]
public string ProductPrice { get; set; } = string.Empty;
[StringLength(200, ErrorMessage = "Descrição do produto deve ter no máximo 200 caracteres")]
public string ProductDescription { get; set; } = string.Empty;
public DateTime? ProductDataCachedAt { get; set; }
}
// Validação customizada
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var results = new List<ValidationResult>();
// Se tem título OU URL, então ambos são obrigatórios
bool hasTitle = !string.IsNullOrWhiteSpace(Title);
bool hasUrl = !string.IsNullOrWhiteSpace(Url);
if (hasTitle || hasUrl)
{
if (!hasTitle)
{
results.Add(new ValidationResult(
"Título é obrigatório quando URL é preenchida",
new[] { nameof(Title) }));
}
if (!hasUrl)
{
results.Add(new ValidationResult(
"URL é obrigatória quando título é preenchido",
new[] { nameof(Url) }));
}
else if (!Uri.TryCreate(Url, UriKind.Absolute, out _))
{
results.Add(new ValidationResult(
"URL inválida",
new[] { nameof(Url) }));
}
// Validações específicas para links de produto
if (Type == LinkType.Product && hasTitle && hasUrl)
{
if (string.IsNullOrWhiteSpace(ProductTitle))
{
results.Add(new ValidationResult(
"Título do produto é obrigatório para links de produto",
new[] { nameof(ProductTitle) }));
}
}
// Validações de tamanho apenas se os campos estão preenchidos
if (hasTitle && Title.Length > 50)
{
results.Add(new ValidationResult(
"Título deve ter no máximo 50 caracteres",
new[] { nameof(Title) }));
}
if (!string.IsNullOrWhiteSpace(Description) && Description.Length > 100)
{
results.Add(new ValidationResult(
"Descrição deve ter no máximo 100 caracteres",
new[] { nameof(Description) }));
}
if (!string.IsNullOrWhiteSpace(ProductTitle) && ProductTitle.Length > 100)
{
results.Add(new ValidationResult(
"Título do produto deve ter no máximo 100 caracteres",
new[] { nameof(ProductTitle) }));
}
if (!string.IsNullOrWhiteSpace(ProductPrice) && ProductPrice.Length > 50)
{
results.Add(new ValidationResult(
"Preço deve ter no máximo 50 caracteres",
new[] { nameof(ProductPrice) }));
}
if (!string.IsNullOrWhiteSpace(ProductDescription) && ProductDescription.Length > 200)
{
results.Add(new ValidationResult(
"Descrição do produto deve ter no máximo 200 caracteres",
new[] { nameof(ProductDescription) }));
}
}
return results;
}
// Propriedade helper para verificar se o link está vazio
public bool IsEmpty => string.IsNullOrWhiteSpace(Title) && string.IsNullOrWhiteSpace(Url);
}
public class DashboardViewModel
{
public User CurrentUser { get; set; } = new();

View File

@ -618,403 +618,624 @@
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script>
let linkCount = @Model.Links.Count;
let currentStep = 1;
let linkCount = @Model.Links.Count;
let currentStep = 1;
$(document).ready(function() {
// Generate slug when name or category changes
$('#DisplayName, #Category').on('input change', function() {
generateSlug();
updateProgress();
$(document).ready(function() {
// Inicializar contador baseado nos links existentes não vazios
linkCount = $('.link-input-group').length;
// Generate slug when name or category changes
$('#DisplayName, #Category').on('input change', function() {
generateSlug();
updateProgress();
});
// Theme selection
$('.theme-card').on('click', function() {
$('.theme-card').removeClass('selected');
$(this).addClass('selected');
const themeName = $(this).data('theme');
$('#SelectedTheme').val(themeName);
markStepComplete(2);
});
// Add link functionality via modal
$('#addLinkBtn').on('click', function() {
if (linkCount >= @Model.MaxLinksAllowed) {
alert('Você atingiu o limite de links para seu plano atual.');
return false;
}
resetModal();
});
// Toggle between link types
$('input[name="linkType"]').on('change', function() {
const linkType = $(this).val();
if (linkType === 'Product') {
$('#normalLinkSection').hide();
$('#productLinkSection').show();
} else {
$('#normalLinkSection').show();
$('#productLinkSection').hide();
}
});
// Extract product data
$('#extractProductBtn').on('click', function() {
const url = $('#productUrl').val().trim();
if (!url) {
alert('Por favor, insira a URL do produto.');
return;
}
if (!isValidUrl(url)) {
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
return;
}
extractProductData(url);
});
// Save link from modal
$('#saveLinkBtn').on('click', function() {
const linkType = $('input[name="linkType"]:checked').val();
if (linkType === 'Product') {
saveProductLink();
} else {
saveNormalLink();
}
});
// Remove link functionality
$(document).on('click', '.remove-link-btn', function() {
if (confirm('Tem certeza que deseja remover este link?')) {
$(this).closest('.link-input-group').remove();
reindexLinks();
updateAddLinkButton();
}
});
// Form validation and submission
$('#managePageForm').on('submit', function(e) {
console.log('Form sendo enviado...');
// Remover containers de links completamente vazios antes do envio
removeEmptyLinkContainers();
// Reindexar todos os links restantes
reindexLinks();
// Validar formulário
if (!validateForm()) {
e.preventDefault();
alert('Por favor, corrija os campos destacados em vermelho.');
return false;
}
// Desabilitar botão de submit para evitar duplo clique
const $submitBtn = $(this).find('button[type="submit"]');
$submitBtn.prop('disabled', true);
if (@Model.IsNewPage) {
$submitBtn.html('<i class="fas fa-spinner fa-spin me-2"></i>Criando...');
} else {
$submitBtn.html('<i class="fas fa-spinner fa-spin me-2"></i>Salvando...');
}
});
// Validação em tempo real para links
$(document).on('input', '.link-title, .link-url', function() {
validateLinkInputs($(this).closest('.link-input-group'));
});
// Inicializar estado dos botões e validações
updateAddLinkButton();
// Marcar passos já completos se editando
@if (!Model.IsNewPage)
{
<text>
markStepComplete(1);
markStepComplete(2);
if ($('.link-input-group').length > 0) {
markStepComplete(3);
}
</text>
}
});
// Theme selection
$('.theme-card').on('click', function() {
$('.theme-card').removeClass('selected');
$(this).addClass('selected');
const themeName = $(this).data('theme');
$('#SelectedTheme').val(themeName);
markStepComplete(2);
});
// Add link functionality via modal
$('#addLinkBtn').on('click', function() {
if (linkCount >= @Model.MaxLinksAllowed) {
alert('Você atingiu o limite de links para seu plano atual.');
// Função para validar URL
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
});
}
// Toggle between link types
$('input[name="linkType"]').on('change', function() {
const linkType = $(this).val();
if (linkType === 'Product') {
$('#normalLinkSection').hide();
$('#productLinkSection').show();
// Função para remover containers de links vazios
function removeEmptyLinkContainers() {
$('.link-input-group').each(function() {
const $container = $(this);
const title = $container.find('input[name$=".Title"]').val()?.trim() || '';
const url = $container.find('input[name$=".Url"]').val()?.trim() || '';
// Se tanto título quanto URL estão vazios, remover o container
if (!title && !url) {
$container.remove();
}
});
}
// Função para reindexar links
function reindexLinks() {
$('.link-input-group').each(function(newIndex) {
const $container = $(this);
// Atualizar data-link
$container.attr('data-link', newIndex);
// Atualizar título visual
const $header = $container.find('h6').first();
const currentText = $header.html();
if (currentText.includes('Produto')) {
$header.html(currentText.replace(/Produto \d+/, `Produto ${newIndex + 1}`));
} else {
$header.html(currentText.replace(/Link \d+/, `Link ${newIndex + 1}`));
}
// Atualizar nomes dos inputs
$container.find('input, select, textarea').each(function() {
const $input = $(this);
const name = $input.attr('name');
if (name && name.includes('Links[')) {
const newName = name.replace(/Links\[\d+\]/, `Links[${newIndex}]`);
$input.attr('name', newName);
}
});
// Atualizar Order value
$container.find('input[name$=".Order"]').val(newIndex);
});
// Atualizar contador global
linkCount = $('.link-input-group').length;
}
// Função para atualizar estado do botão de adicionar link
function updateAddLinkButton() {
const canAddMore = linkCount < @Model.MaxLinksAllowed;
$('#addLinkBtn').prop('disabled', !canAddMore);
if (canAddMore) {
$('#addLinkBtn').html('<i class="fas fa-plus"></i> Adicionar Link');
} else {
$('#normalLinkSection').show();
$('#productLinkSection').hide();
$('#addLinkBtn').html(`Limite de ${@Model.MaxLinksAllowed} links atingido`);
}
});
}
// Extract product data
$('#extractProductBtn').on('click', function() {
// Função para validar inputs de um link específico
function validateLinkInputs($container) {
const $titleInput = $container.find('input[name$=".Title"]');
const $urlInput = $container.find('input[name$=".Url"]');
const title = $titleInput.val()?.trim() || '';
const url = $urlInput.val()?.trim() || '';
// Limpar classes de erro
$titleInput.removeClass('is-invalid');
$urlInput.removeClass('is-invalid');
// Se um está preenchido, o outro deve estar também
if (title && !url) {
$urlInput.addClass('is-invalid');
} else if (url && !title) {
$titleInput.addClass('is-invalid');
} else if (url && !isValidUrl(url)) {
$urlInput.addClass('is-invalid');
}
}
// Função melhorada para adicionar links normais
function addLinkInput(title = '', url = '', description = '', icon = '', linkType = 'Normal', linkId = 'new') {
if (linkCount >= @Model.MaxLinksAllowed) {
alert('Você atingiu o limite de links para seu plano atual.');
return;
}
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
const linkHtml = `
<div class="link-input-group" data-link="${linkCount}">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">
${iconHtml}Link ${linkCount + 1}: ${title || 'Novo Link'}
</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-2">
<label class="form-label">Título</label>
<input type="text" name="Links[${linkCount}].Title" class="form-control link-title" value="${title}" placeholder="Ex: Meu Site" maxlength="50">
</div>
</div>
<div class="col-md-6">
<div class="mb-2">
<label class="form-label">URL</label>
<input type="url" name="Links[${linkCount}].Url" class="form-control link-url" value="${url}" placeholder="https://exemplo.com">
</div>
</div>
</div>
<div class="mb-2">
<label class="form-label">Descrição (opcional)</label>
<input type="text" name="Links[${linkCount}].Description" class="form-control link-description" value="${description}" placeholder="Breve descrição do link" maxlength="100">
</div>
<!-- Hidden fields -->
<input type="hidden" name="Links[${linkCount}].Id" value="${linkId}">
<input type="hidden" name="Links[${linkCount}].Type" value="${linkType}">
<input type="hidden" name="Links[${linkCount}].Icon" value="${icon}">
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
</div>
`;
$('#linksContainer').append(linkHtml);
linkCount++;
markStepComplete(3);
updateAddLinkButton();
}
// Função melhorada para adicionar links de produtos
function addProductLinkInput(title, url, description, price, image, linkId = 'new') {
if (linkCount >= @Model.MaxLinksAllowed) {
alert('Você atingiu o limite de links para seu plano atual.');
return;
}
const linkHtml = `
<div class="link-input-group product-link-preview" data-link="${linkCount}">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">
<i class="fas fa-shopping-bag me-2 text-success"></i>Link de Produto ${linkCount + 1}
</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="card border-success">
<div class="row g-0">
<div class="col-md-3">
<div class="p-3 text-center">
${image ? `<img src="${image}" class="img-fluid rounded" style="max-height: 80px; max-width: 100%;" onerror="this.style.display='none'; this.parentNode.innerHTML='<i class=\\"fas fa-image text-muted\\"></i><br><small class=\\"text-muted\\">Sem imagem</small>';">` : '<i class="fas fa-image text-muted fa-2x"></i><br><small class="text-muted">Sem imagem</small>'}
</div>
</div>
<div class="col-md-9">
<div class="card-body">
<h6 class="card-title text-success">${title}</h6>
${price ? `<p class="card-text"><strong class="text-success">${price}</strong></p>` : ''}
${description ? `<p class="card-text small text-muted">${description}</p>` : ''}
<small class="text-muted d-block">
<i class="fas fa-external-link-alt me-1"></i>
${url.length > 50 ? url.substring(0, 50) + '...' : url}
</small>
</div>
</div>
</div>
</div>
<!-- Hidden fields para form submission -->
<input type="hidden" name="Links[${linkCount}].Id" value="${linkId}">
<input type="hidden" name="Links[${linkCount}].Title" value="${title}">
<input type="hidden" name="Links[${linkCount}].Url" value="${url}">
<input type="hidden" name="Links[${linkCount}].Description" value="${description || ''}">
<input type="hidden" name="Links[${linkCount}].Type" value="Product">
<input type="hidden" name="Links[${linkCount}].ProductTitle" value="${title}">
<input type="hidden" name="Links[${linkCount}].ProductDescription" value="${description || ''}">
<input type="hidden" name="Links[${linkCount}].ProductPrice" value="${price || ''}">
<input type="hidden" name="Links[${linkCount}].ProductImage" value="${image || ''}">
<input type="hidden" name="Links[${linkCount}].Icon" value="fas fa-shopping-bag">
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
</div>
`;
$('#linksContainer').append(linkHtml);
linkCount++;
markStepComplete(3);
updateAddLinkButton();
}
// Função para salvar link normal do modal
function saveNormalLink() {
const title = $('#linkTitle').val().trim();
const url = $('#linkUrl').val().trim();
const description = $('#linkDescription').val().trim();
const icon = $('#linkIcon').val();
if (!title || !url) {
alert('Por favor, preencha pelo menos o título e a URL do link.');
return;
}
if (!isValidUrl(url)) {
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
return;
}
addLinkInput(title, url, description, icon, 'Normal');
closeModalAndReset();
}
// Função para salvar link de produto do modal
function saveProductLink() {
const url = $('#productUrl').val().trim();
const title = $('#productTitle').val().trim();
const description = $('#productDescription').val().trim();
const price = $('#productPrice').val().trim();
const image = $('#productImage').val();
if (!url) {
alert('Por favor, insira a URL do produto.');
return;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (!title) {
alert('Por favor, preencha o título do produto.');
return;
}
if (!isValidUrl(url)) {
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
return;
}
extractProductData(url);
});
addProductLinkInput(title, url, description, price, image);
closeModalAndReset();
}
// Save link from modal
$(document).on('click', '#saveLinkBtn', function() {
const linkType = $('input[name="linkType"]:checked').val();
// Função para extrair dados do produto
function extractProductData(url) {
$('#extractProductBtn').prop('disabled', true);
$('#extractLoading').show();
if (linkType === 'Product') {
saveProductLink();
} else {
saveNormalLink();
$.ajax({
url: '/api/Product/extract',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ url: url }),
success: function(response) {
if (response.success) {
$('#productTitle').val(response.title || '');
$('#productDescription').val(response.description || '');
$('#productPrice').val(response.price || '');
if (response.image) {
$('#productImage').val(response.image);
$('#productImagePreview').attr('src', response.image).show();
$('#productImagePlaceholder').hide();
}
showToast('Dados extraídos com sucesso!', 'success');
} else {
alert('Erro: ' + (response.message || 'Não foi possível extrair os dados do produto.'));
}
},
error: function(xhr) {
let errorMessage = 'Erro ao extrair dados do produto.';
if (xhr.responseJSON && xhr.responseJSON.message) {
errorMessage = xhr.responseJSON.message;
} else if (xhr.status === 429) {
errorMessage = 'Aguarde 1 minuto antes de extrair dados de outro produto.';
} else if (xhr.status === 401) {
errorMessage = 'Você precisa estar logado para usar esta funcionalidade.';
}
alert(errorMessage);
},
complete: function() {
$('#extractProductBtn').prop('disabled', false);
$('#extractLoading').hide();
}
});
}
// Função para fechar modal e resetar
function closeModalAndReset() {
var modal = bootstrap.Modal.getInstance(document.getElementById('addLinkModal'));
if (modal) {
modal.hide();
}
});
resetModal();
}
// Remove link functionality
$(document).on('click', '.remove-link-btn', function() {
$(this).closest('.link-input-group').remove();
linkCount--;
updateLinkNumbers();
});
// Função para resetar modal
function resetModal() {
$('#addLinkForm')[0].reset();
$('#productImagePreview').hide();
$('#productImagePlaceholder').show();
$('#productImage').val('');
$('#normalLinkSection').show();
$('#productLinkSection').hide();
$('#linkTypeNormal').prop('checked', true);
$('#extractProductBtn').prop('disabled', false);
}
// Form validation
$('#managePageForm').on('submit', function(e) {
console.log('Form submitted');
// Allow submission but add loading state
$(this).find('button[type="submit"]').prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-2"></i>Criando...');
});
});
// Funções de navegação do wizard
function nextStep(step) {
if (validateCurrentStep()) {
markStepComplete(currentStep);
currentStep = step;
updateProgress();
function nextStep(step) {
if (validateCurrentStep()) {
markStepComplete(currentStep);
// Close current accordion and open next
$('.accordion-collapse.show').collapse('hide');
setTimeout(() => {
$(`#collapse${getStepName(step)}`).collapse('show');
}, 300);
}
}
function previousStep(step) {
currentStep = step;
updateProgress();
// Close current accordion and open next
// Close current accordion and open previous
$('.accordion-collapse.show').collapse('hide');
setTimeout(() => {
$(`#collapse${getStepName(step)}`).collapse('show');
}, 300);
}
}
function previousStep(step) {
currentStep = step;
updateProgress();
function skipStep(step) {
markStepComplete(step);
updateProgress();
}
// Close current accordion and open previous
$('.accordion-collapse.show').collapse('hide');
setTimeout(() => {
$(`#collapse${getStepName(step)}`).collapse('show');
}, 300);
}
function getStepName(step) {
const names = ['', 'Basic', 'Theme', 'Links', 'Social'];
return names[step];
}
function skipStep(step) {
markStepComplete(step);
// Show create button or next step
updateProgress();
}
function getStepName(step) {
const names = ['', 'Basic', 'Theme', 'Links', 'Social'];
return names[step];
}
function validateCurrentStep() {
if (currentStep === 1) {
const name = $('#DisplayName').val().trim();
const category = $('#Category').val().trim();
if (!name || !category) {
alert('Por favor, preencha o nome da página e selecione uma categoria.');
return false;
function validateCurrentStep() {
if (currentStep === 1) {
const name = $('#DisplayName').val().trim();
const category = $('#Category').val().trim();
if (!name || !category) {
alert('Por favor, preencha o nome da página e selecione uma categoria.');
return false;
}
} else if (currentStep === 2) {
const theme = $('#SelectedTheme').val();
if (!theme) {
alert('Por favor, selecione um tema visual.');
return false;
}
}
} else if (currentStep === 2) {
const theme = $('#SelectedTheme').val();
if (!theme) {
alert('Por favor, selecione um tema visual.');
return false;
return true;
}
function markStepComplete(step) {
$(`#step${step}Status`).show();
}
function updateProgress() {
const progress = (currentStep / 4) * 100;
$('.progress-bar').css('width', `${progress}%`).attr('aria-valuenow', progress);
$('.progress').next().find('small').first().text(`Passo ${currentStep} de 4`);
}
function generateSlug() {
const name = $('#DisplayName').val();
const category = $('#Category').val();
if (name && category) {
$.post('@Url.Action("GenerateSlug", "Admin")', { category: category, name: name })
.done(function(data) {
$('#Slug').val(data.slug);
$('#slugPreview').val(data.slug);
$('#categorySlug').text(category);
});
}
}
return true;
}
function markStepComplete(step) {
$(`#step${step}Status`).show();
}
function validateForm() {
let isValid = true;
function updateProgress() {
const progress = (currentStep / 4) * 100;
$('.progress-bar').css('width', `${progress}%`).attr('aria-valuenow', progress);
$('.progress').next().find('small').first().text(`Passo ${currentStep} de 4`);
}
// Limpar classes de erro anteriores
$('.is-invalid').removeClass('is-invalid');
function generateSlug() {
const name = $('#DisplayName').val();
const category = $('#Category').val();
// Validar campos obrigatórios principais
const requiredFields = [
{ field: '#DisplayName', message: 'Nome é obrigatório' },
{ field: '#Category', message: 'Categoria é obrigatória' }
];
if (name && category) {
$.post('@Url.Action("GenerateSlug", "Admin")', { category: category, name: name })
.done(function(data) {
$('#Slug').val(data.slug);
$('#slugPreview').val(data.slug);
$('#categorySlug').text(category);
});
}
}
requiredFields.forEach(item => {
const $field = $(item.field);
if (!$field.val()?.trim()) {
$field.addClass('is-invalid');
isValid = false;
}
});
function addLinkInput(title = '', url = '', description = '', icon = '', linkType = 'Normal') {
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
// Validar links
$('.link-input-group').each(function() {
const $container = $(this);
const title = $container.find('input[name$=".Title"]').val()?.trim() || '';
const url = $container.find('input[name$=".Url"]').val()?.trim() || '';
const linkHtml = `
<div class="link-input-group" data-link="${linkCount}">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">
${iconHtml}Link ${linkCount + 1}: ${title || 'Novo Link'}
</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-2">
<input type="hidden" name="Links[${linkCount}].Id" value="new">
<label class="form-label">Título</label>
<input type="text" name="Links[${linkCount}].Title" class="form-control link-title" value="${title}" placeholder="Ex: Meu Site" readonly>
</div>
</div>
<div class="col-md-6">
<div class="mb-2">
<label class="form-label">URL</label>
<input type="url" name="Links[${linkCount}].Url" class="form-control link-url" value="${url}" placeholder="https://exemplo.com" readonly>
</div>
</div>
</div>
<div class="mb-2">
<label class="form-label">Descrição (opcional)</label>
<input type="text" name="Links[${linkCount}].Description" class="form-control link-description" value="${description}" placeholder="Breve descrição do link" readonly>
</div>
<input type="hidden" name="Links[${linkCount}].Id" value="">
<input type="hidden" name="Links[${linkCount}].Type" value="${linkType}">
<input type="hidden" name="Links[${linkCount}].Icon" value="${icon}">
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
</div>
`;
$('#linksContainer').append(linkHtml);
linkCount++;
}
function updateLinkNumbers() {
$('.link-input-group').each(function(index) {
$(this).find('h6').text('Link ' + (index + 1));
$(this).attr('data-link', index);
});
}
function saveNormalLink() {
const title = $('#linkTitle').val().trim();
const url = $('#linkUrl').val().trim();
const description = $('#linkDescription').val().trim();
const icon = $('#linkIcon').val();
if (!title || !url) {
alert('Por favor, preencha pelo menos o título e a URL do link.');
return;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
return;
}
addLinkInput(title, url, description, icon, 'Normal');
closeModalAndReset();
}
function saveProductLink() {
const url = $('#productUrl').val().trim();
const title = $('#productTitle').val().trim();
const description = $('#productDescription').val().trim();
const price = $('#productPrice').val().trim();
const image = $('#productImage').val();
if (!url) {
alert('Por favor, insira a URL do produto.');
return;
}
if (!title) {
alert('Por favor, preencha o título do produto.');
return;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
return;
}
addProductLinkInput(title, url, description, price, image);
closeModalAndReset();
}
function extractProductData(url) {
$('#extractProductBtn').prop('disabled', true);
$('#extractLoading').show();
$.ajax({
url: '/api/Product/extract',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ url: url }),
success: function(response) {
if (response.success) {
$('#productTitle').val(response.title || '');
$('#productDescription').val(response.description || '');
$('#productPrice').val(response.price || '');
if (response.image) {
$('#productImage').val(response.image);
$('#productImagePreview').attr('src', response.image).show();
$('#productImagePlaceholder').hide();
// Se tem título ou URL, ambos são obrigatórios
if (title || url) {
if (!title) {
$container.find('input[name$=".Title"]').addClass('is-invalid');
isValid = false;
}
if (!url) {
$container.find('input[name$=".Url"]').addClass('is-invalid');
isValid = false;
} else if (!isValidUrl(url)) {
$container.find('input[name$=".Url"]').addClass('is-invalid');
isValid = false;
}
showToast('Dados extraídos com sucesso!', 'success');
} else {
alert('Erro: ' + (response.message || 'Não foi possível extrair os dados do produto.'));
}
},
error: function(xhr) {
let errorMessage = 'Erro ao extrair dados do produto.';
});
if (xhr.responseJSON && xhr.responseJSON.message) {
errorMessage = xhr.responseJSON.message;
} else if (xhr.status === 429) {
errorMessage = 'Aguarde 1 minuto antes de extrair dados de outro produto.';
} else if (xhr.status === 401) {
errorMessage = 'Você precisa estar logado para usar esta funcionalidade.';
// Validar URLs de redes sociais se preenchidas
const socialFields = ['#FacebookUrl', '#TwitterUrl', '#InstagramUrl'];
socialFields.forEach(field => {
const $field = $(field);
const value = $field.val()?.trim();
if (value && !isValidUrl(value)) {
$field.addClass('is-invalid');
isValid = false;
}
});
alert(errorMessage);
},
complete: function() {
$('#extractProductBtn').prop('disabled', false);
$('#extractLoading').hide();
// Validar WhatsApp se preenchido
const whatsapp = $('#WhatsAppNumber').val()?.trim();
if (whatsapp) {
const cleanNumber = whatsapp.replace(/[+\s\-()]/g, '');
if (cleanNumber.length < 10 || !/^\d+$/.test(cleanNumber)) {
$('#WhatsAppNumber').addClass('is-invalid');
isValid = false;
}
}
});
}
function addProductLinkInput(title, url, description, price, image) {
const linkHtml = `
<div class="link-input-group product-link-preview" data-link="${linkCount}">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">
<i class="fas fa-shopping-bag me-2 text-success"></i>Link de Produto ${linkCount + 1}
</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="card border-success">
<div class="row g-0">
<div class="col-md-3">
<div class="p-3 text-center">
${image ? `<img src="${image}" class="img-fluid rounded" style="max-height: 80px; max-width: 100%;" onerror="this.style.display='none'; this.parentNode.innerHTML='<i class=\\"fas fa-image text-muted\\"></i><br><small class=\\"text-muted\\">Sem imagem</small>';">` : '<i class="fas fa-image text-muted fa-2x"></i><br><small class="text-muted">Sem imagem</small>'}
</div>
</div>
<div class="col-md-9">
<div class="card-body">
<h6 class="card-title text-success">${title}</h6>
${price ? `<p class="card-text"><strong class="text-success">${price}</strong></p>` : ''}
${description ? `<p class="card-text small text-muted">${description}</p>` : ''}
<small class="text-muted d-block">
<i class="fas fa-external-link-alt me-1"></i>
${url.length > 50 ? url.substring(0, 50) + '...' : url}
</small>
</div>
</div>
return isValid;
}
function showToast(message, type = 'info') {
const toastHtml = `
<div class="toast align-items-center text-white bg-${type === 'success' ? 'success' : 'primary'} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
<!-- Hidden fields for form submission -->
<input type="hidden" name="Links[${linkCount}].Id" value="">
<input type="hidden" name="Links[${linkCount}].Title" value="${title}">
<input type="hidden" name="Links[${linkCount}].Url" value="${url}">
<input type="hidden" name="Links[${linkCount}].Description" value="${description}">
<input type="hidden" name="Links[${linkCount}].Type" value="Product">
<input type="hidden" name="Links[${linkCount}].ProductTitle" value="${title}">
<input type="hidden" name="Links[${linkCount}].ProductDescription" value="${description}">
<input type="hidden" name="Links[${linkCount}].ProductPrice" value="${price}">
<input type="hidden" name="Links[${linkCount}].ProductImage" value="${image}">
<input type="hidden" name="Links[${linkCount}].Icon" value="fas fa-shopping-bag">
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
</div>
`;
if (!$('#toastContainer').length) {
$('body').append('<div id="toastContainer" class="toast-container position-fixed top-0 end-0 p-3"></div>');
}
$('#linksContainer').append(linkHtml);
linkCount++;
markStepComplete(3);
}
const $toast = $(toastHtml);
$('#toastContainer').append($toast);
function closeModalAndReset() {
// Clear modal form
$('#addLinkForm')[0].reset();
$('#productImagePreview').hide();
$('#productImagePlaceholder').show();
$('#productImage').val('');
$('#normalLinkSection').show();
$('#productLinkSection').hide();
$('#linkTypeNormal').prop('checked', true);
const toast = new bootstrap.Toast($toast[0]);
toast.show();
// Close modal
var modal = bootstrap.Modal.getInstance(document.getElementById('addLinkModal'));
if (modal) {
modal.hide();
setTimeout(() => {
$toast.remove();
}, 5000);
}
}
function showToast(message, type = 'info') {
// Simple toast notification
const toastHtml = `
<div class="toast align-items-center text-white bg-${type === 'success' ? 'success' : 'primary'} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
if (!$('#toastContainer').length) {
$('body').append('<div id="toastContainer" class="toast-container position-fixed top-0 end-0 p-3"></div>');
}
const $toast = $(toastHtml);
$('#toastContainer').append($toast);
const toast = new bootstrap.Toast($toast[0]);
toast.show();
setTimeout(() => {
$toast.remove();
}, 5000);
}
</script>
}