Compare commits

...

6 Commits

Author SHA1 Message Date
Ricardo Carneiro
d32cc18044 Merge branch 'feat/live-preview' of http://git.carneiro.ddnsfree.com/ricardo/BCards into feat/live-preview 2025-08-17 15:46:09 -03:00
Ricardo Carneiro
c824e9da1c feat: imagens!!!! Agora tenho uma imagem no topo! 2025-08-17 15:45:59 -03:00
Ricardo Carneiro
9e7ea6ed9a feat: ajuste dos contadores 2025-08-17 00:29:17 -03:00
Ricardo Carneiro
5fc7eb5ad3 feat: ajustes de callback do stripe e atualização do stripe.net 2025-08-16 23:13:00 -03:00
Ricardo Carneiro
c6129a1c63 feat: links sociais opcionais 2025-08-16 18:55:17 -03:00
Ricardo Carneiro
2449a617ca feat: trial and pay 2025-08-16 17:10:45 -03:00
37 changed files with 1414 additions and 181 deletions

View File

@ -21,9 +21,9 @@
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
<PackageReference Include="Testcontainers.MongoDb" Version="3.6.0" />
<PackageReference Include="Stripe.net" Version="44.7.0" />
<PackageReference Include="Stripe.net" Version="48.4.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />

View File

@ -9,8 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
<PackageReference Include="Stripe.net" Version="44.7.0" />
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
<PackageReference Include="Stripe.net" Version="48.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Localization" Version="2.2.0" />

View File

@ -4,6 +4,7 @@ using BCards.Web.Utils;
using BCards.Web.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.Security.Claims;
namespace BCards.Web.Controllers;
@ -19,6 +20,7 @@ public class AdminController : Controller
private readonly IModerationService _moderationService;
private readonly IEmailService _emailService;
private readonly ILivePageService _livePageService;
private readonly IImageStorageService _imageStorage;
private readonly ILogger<AdminController> _logger;
public AdminController(
@ -29,6 +31,7 @@ public class AdminController : Controller
IModerationService moderationService,
IEmailService emailService,
ILivePageService livePageService,
IImageStorageService imageStorage,
ILogger<AdminController> logger)
{
_authService = authService;
@ -38,6 +41,7 @@ public class AdminController : Controller
_moderationService = moderationService;
_emailService = emailService;
_livePageService = livePageService;
_imageStorage = imageStorage;
_logger = logger;
}
@ -55,6 +59,25 @@ public class AdminController : Controller
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var userPages = await _userPageService.GetUserPagesAsync(user.Id);
var listCounts = new Dictionary<string, dynamic>();
// Atualizar status das baseado nas livepasges
foreach (var page in userPages)
{
if (page.Status == ViewModels.PageStatus.Active)
{
var livePage = await _livePageService.GetLivePageFromUserPageId(page.Id);
if (livePage != null)
{
listCounts.Add(page.Id, new { TotalViews = livePage.Analytics?.TotalViews ?? 0, TotalClicks = livePage.Analytics?.TotalClicks ?? 0 });
}
}
else
{
listCounts.Add(page.Id, new { TotalViews = (long)(page.Analytics?.TotalViews ?? 0), TotalClicks = (long)(page.Analytics?.TotalClicks ?? 0) });
}
}
var dashboardModel = new DashboardViewModel
{
CurrentUser = user,
@ -65,8 +88,8 @@ public class AdminController : Controller
Slug = p.Slug,
Category = p.Category,
Status = p.Status,
TotalClicks = p.Analytics?.TotalClicks ?? 0,
TotalViews = p.Analytics?.TotalViews ?? 0,
TotalClicks = listCounts[p.Id].TotalClicks ?? 0,
TotalViews = listCounts[p.Id].TotalViews ?? 0,
PreviewToken = p.PreviewToken,
CreatedAt = p.CreatedAt,
LastModerationStatus = p.ModerationHistory == null || p.ModerationHistory.Count == 0 || p.ModerationHistory.Last().Status == "rejected"
@ -159,8 +182,46 @@ public class AdminController : Controller
if (user == null)
return RedirectToAction("Login", "Auth");
// Limpar campos de redes sociais que são apenas espaços (tratados como vazios)
CleanSocialMediaFields(model);
_logger.LogInformation($"ManagePage POST: IsNewPage={model.IsNewPage}, DisplayName={model.DisplayName}, Category={model.Category}, Links={model.Links?.Count ?? 0}");
ModelState.Remove<ManagePageViewModel>(x => x.InstagramUrl);
ModelState.Remove<ManagePageViewModel>(x => x.FacebookUrl);
ModelState.Remove<ManagePageViewModel>(x => x.TwitterUrl);
ModelState.Remove<ManagePageViewModel>(x => x.WhatsAppNumber);
// Processar upload de imagem se fornecida
if (model.ProfileImageFile != null && model.ProfileImageFile.Length > 0)
{
try
{
using var memoryStream = new MemoryStream();
await model.ProfileImageFile.CopyToAsync(memoryStream);
var imageBytes = memoryStream.ToArray();
var imageId = await _imageStorage.SaveImageAsync(
imageBytes,
model.ProfileImageFile.FileName,
model.ProfileImageFile.ContentType
);
model.ProfileImageId = imageId;
_logger.LogInformation("Profile image uploaded successfully: {ImageId}", imageId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading profile image");
ModelState.AddModelError("ProfileImageFile", "Erro ao fazer upload da imagem. Tente novamente.");
// Repopulate dropdowns
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
return View(model);
}
}
if (!ModelState.IsValid)
{
_logger.LogWarning("ModelState is invalid:");
@ -170,6 +231,8 @@ public class AdminController : Controller
}
// Repopulate dropdowns
var slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName);
model.Slug = slug;
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
return View(model);
@ -248,6 +311,12 @@ public class AdminController : Controller
return RedirectToAction("Dashboard");
}
// IMPORTANTE: Preservar ProfileImageId da página existente se não houver novo upload
if (model.ProfileImageFile == null || model.ProfileImageFile.Length == 0)
{
model.ProfileImageId = existingPage.ProfileImageId;
}
UpdateUserPageFromModel(existingPage, model);
// Set status to PendingModeration for updates
@ -269,7 +338,7 @@ public class AdminController : Controller
null,
previewUrl);
TempData["Success"] = "Página atualizada e enviada para moderação!";
TempData["Success"] = "Página atualizada! Teste e envie para moderação.";
}
return RedirectToAction("Dashboard");
@ -553,6 +622,7 @@ public class AdminController : Controller
Bio = page.Bio,
Slug = page.Slug,
SelectedTheme = page.Theme?.Name ?? "minimalist",
ProfileImageId = page.ProfileImageId,
Links = page.Links?.Select((l, index) => new ManageLinkViewModel
{
Id = $"link_{index}",
@ -589,6 +659,7 @@ public class AdminController : Controller
Slug = SlugHelper.CreateSlug(model.Slug.ToLower()),
Theme = theme,
Status = ViewModels.PageStatus.Active,
ProfileImageId = model.ProfileImageId,
Links = new List<LinkItem>()
};
@ -676,6 +747,7 @@ public class AdminController : Controller
page.BusinessType = model.BusinessType;
page.Bio = model.Bio;
page.Slug = model.Slug;
page.ProfileImageId = model.ProfileImageId; // CRUCIAL: Atualizar ProfileImageId
page.UpdatedAt = DateTime.UtcNow;
// Update links
@ -958,4 +1030,20 @@ public class AdminController : Controller
});
}
}
private void CleanSocialMediaFields(ManagePageViewModel model)
{
// Tratar espaço em branco como campo vazio para redes sociais
if (string.IsNullOrWhiteSpace(model.WhatsAppNumber) || model.WhatsAppNumber.Trim().Length <= 1)
model.WhatsAppNumber = string.Empty;
if (string.IsNullOrWhiteSpace(model.FacebookUrl) || model.FacebookUrl.Trim().Length <= 1)
model.FacebookUrl = string.Empty;
if (string.IsNullOrWhiteSpace(model.InstagramUrl) || model.InstagramUrl.Trim().Length <= 1)
model.InstagramUrl = string.Empty;
if (string.IsNullOrWhiteSpace(model.TwitterUrl) || model.TwitterUrl.Trim().Length <= 1)
model.TwitterUrl = string.Empty;
}
}

View File

@ -0,0 +1,202 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Route("api/[controller]")]
[ApiController]
public class ImageController : ControllerBase
{
private readonly IImageStorageService _imageStorage;
private readonly ILogger<ImageController> _logger;
public ImageController(IImageStorageService imageStorage, ILogger<ImageController> logger)
{
_imageStorage = imageStorage;
_logger = logger;
}
[HttpGet("{imageId}")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "imageId" })]
public async Task<IActionResult> GetImage(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
{
_logger.LogWarning("Image request with empty ID");
return BadRequest("Image ID is required");
}
var imageBytes = await _imageStorage.GetImageAsync(imageId);
if (imageBytes == null || imageBytes.Length == 0)
{
_logger.LogWarning("Image not found: {ImageId}", imageId);
return NotFound("Image not found");
}
// Headers de cache mais agressivos para imagens
Response.Headers["Cache-Control"] = "public, max-age=31536000"; // 1 ano
Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R");
Response.Headers["ETag"] = $"\"{imageId}\"";
return File(imageBytes, "image/jpeg", enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving image: {ImageId}", imageId);
return NotFound("Image not found");
}
}
[HttpPost("upload")]
[RequestSizeLimit(5 * 1024 * 1024)] // 5MB máximo
[DisableRequestSizeLimit] // Para formulários grandes
public async Task<IActionResult> UploadImage(IFormFile file)
{
try
{
if (file == null || file.Length == 0)
{
_logger.LogWarning("Upload request with no file");
return BadRequest(new { error = "No file provided", code = "NO_FILE" });
}
// Validações de tipo
var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif" };
if (!allowedTypes.Contains(file.ContentType.ToLower()))
{
_logger.LogWarning("Invalid file type uploaded: {ContentType}", file.ContentType);
return BadRequest(new {
error = "Invalid file type. Only JPEG, PNG and GIF are allowed.",
code = "INVALID_TYPE"
});
}
// Validação de tamanho
if (file.Length > 5 * 1024 * 1024) // 5MB
{
_logger.LogWarning("File too large: {Size}MB", file.Length / (1024 * 1024));
return BadRequest(new {
error = "File too large. Maximum size is 5MB.",
code = "FILE_TOO_LARGE"
});
}
// Processar upload
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
var imageBytes = memoryStream.ToArray();
// Validação adicional: verificar se é realmente uma imagem
if (!IsValidImageBytes(imageBytes))
{
_logger.LogWarning("Invalid image data uploaded");
return BadRequest(new {
error = "Invalid image data.",
code = "INVALID_IMAGE"
});
}
var imageId = await _imageStorage.SaveImageAsync(imageBytes, file.FileName, file.ContentType);
_logger.LogInformation("Image uploaded successfully: {ImageId}, Original: {FileName}, Size: {Size}KB",
imageId, file.FileName, file.Length / 1024);
return Ok(new {
success = true,
imageId,
url = $"/api/image/{imageId}",
originalSize = file.Length,
fileName = file.FileName
});
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Invalid upload parameters");
return BadRequest(new {
error = ex.Message,
code = "VALIDATION_ERROR"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading image: {FileName}", file?.FileName);
return StatusCode(500, new {
error = "Error uploading image. Please try again.",
code = "UPLOAD_ERROR"
});
}
}
[HttpDelete("{imageId}")]
public async Task<IActionResult> DeleteImage(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return BadRequest(new { error = "Image ID is required" });
var deleted = await _imageStorage.DeleteImageAsync(imageId);
if (!deleted)
return NotFound(new { error = "Image not found" });
_logger.LogInformation("Image deleted: {ImageId}", imageId);
return Ok(new { success = true, message = "Image deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting image: {ImageId}", imageId);
return StatusCode(500, new { error = "Error deleting image" });
}
}
[HttpHead("{imageId}")]
public async Task<IActionResult> ImageExists(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return BadRequest();
var exists = await _imageStorage.ImageExistsAsync(imageId);
return exists ? Ok() : NotFound();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking image existence: {ImageId}", imageId);
return StatusCode(500);
}
}
private static bool IsValidImageBytes(byte[] bytes)
{
if (bytes == null || bytes.Length < 4)
return false;
// Verificar assinaturas de arquivos de imagem
var jpegSignature = new byte[] { 0xFF, 0xD8, 0xFF };
var pngSignature = new byte[] { 0x89, 0x50, 0x4E, 0x47 };
var gifSignature = new byte[] { 0x47, 0x49, 0x46 };
return StartsWithSignature(bytes, jpegSignature) ||
StartsWithSignature(bytes, pngSignature) ||
StartsWithSignature(bytes, gifSignature);
}
private static bool StartsWithSignature(byte[] bytes, byte[] signature)
{
if (bytes.Length < signature.Length)
return false;
for (int i = 0; i < signature.Length; i++)
{
if (bytes[i] != signature[i])
return false;
}
return true;
}
}

View File

@ -53,7 +53,7 @@ public class LivePageController : Controller
// Configurar ViewBag para indicar que é uma live page
ViewBag.IsLivePage = true;
ViewBag.PageUrl = $"https://vcart.me/page/{category}/{slug}";
ViewBag.PageUrl = $"https://bcards.site/page/{category}/{slug}";
ViewBag.Title = $"{livePage.DisplayName} - {livePage.Category} | BCards";
_logger.LogInformation("Serving LivePage {LivePageId} for {Category}/{Slug}", livePage.Id, category, slug);

View File

@ -54,21 +54,13 @@ public class PaymentController : Controller
}
public async Task<IActionResult> Success()
{
try
{
var user = await _authService.GetCurrentUserAsync(User);
var planType = TempData[$"PlanType|{user.Id}"].ToString();
try
{
if (!string.IsNullOrEmpty(planType) && Enum.TryParse<PlanType>(planType, out var plan))
{
user.CurrentPlan = plan.ToString();
user.SubscriptionStatus = "active";
await _userService.UpdateAsync(user); // ou o método equivalente
TempData["Success"] = $"Assinatura {planType} ativada com sucesso!";
}
return RedirectToAction("Dashboard", "Admin");
}
catch (Exception ex)
@ -240,7 +232,7 @@ public class PaymentController : Controller
MaxLinks = 15,
AllowAnalytics = true,
AllowCustomDomain = true,
Features = new List<string> { "15 links", "Todos os temas", "Domínio personalizado", "Análises avançadas" },
Features = new List<string> { "15 links", "Todos os temas", "Página rápida", "Análises avançadas" },
IsCurrentPlan = currentPlan == "professional"
},
new()
@ -253,7 +245,7 @@ public class PaymentController : Controller
AllowCustomThemes = true,
AllowAnalytics = true,
AllowCustomDomain = true,
Features = new List<string> { "Links ilimitados", "Temas personalizados", "Múltiplos domínios", "Suporte prioritário" },
Features = new List<string> { "Links ilimitados", "Temas personalizados", "Página rápida", "Suporte prioritário" },
IsCurrentPlan = currentPlan == "premium"
}
};

View File

@ -59,19 +59,19 @@ public class StripeWebhookController : ControllerBase
switch (stripeEvent.Type)
{
case Events.InvoicePaymentSucceeded:
case "invoice.payment_succeeded":
await HandlePaymentSucceeded(stripeEvent);
break;
case Events.InvoicePaymentFailed:
case "invoice.payment_failed":
await HandlePaymentFailed(stripeEvent);
break;
case Events.CustomerSubscriptionDeleted:
case "customer.subscription.deleted":
await HandleSubscriptionDeleted(stripeEvent);
break;
case Events.CustomerSubscriptionUpdated:
case "customer.subscription.updated":
await HandleSubscriptionUpdated(stripeEvent);
break;
@ -100,7 +100,8 @@ public class StripeWebhookController : ControllerBase
{
_logger.LogInformation($"Payment succeeded for customer: {invoice.CustomerId}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
var subscriptionId = GetSubscriptionId(stripeEvent);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (subscription != null)
{
subscription.Status = "active";
@ -127,7 +128,8 @@ public class StripeWebhookController : ControllerBase
{
_logger.LogInformation($"Payment failed for customer: {invoice.CustomerId}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
var subscriptionId = GetSubscriptionId(stripeEvent);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (subscription != null)
{
subscription.Status = "past_due";
@ -184,9 +186,12 @@ public class StripeWebhookController : ControllerBase
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
subscription.Status = stripeSubscription.Status;
subscription.CurrentPeriodStart = stripeSubscription.CurrentPeriodStart;
subscription.CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd;
subscription.CurrentPeriodStart = subItem.CurrentPeriodStart;
subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd;
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
subscription.UpdatedAt = DateTime.UtcNow;
@ -216,4 +221,33 @@ public class StripeWebhookController : ControllerBase
_ => "trial"
};
}
private string GetSubscriptionId(Event stripeEvent)
{
if (stripeEvent.Data.Object is Invoice invoice)
{
var subscriptionLineItem = invoice.Lines?.Data
.FirstOrDefault(line =>
!string.IsNullOrEmpty(line.SubscriptionId) ||
line.Subscription != null
);
string subscriptionId = null;
if (subscriptionLineItem != null)
{
// Tenta obter o ID da assinatura de duas formas diferentes
subscriptionId = subscriptionLineItem.SubscriptionId
?? subscriptionLineItem.Subscription?.Id;
}
return subscriptionId;
}
else if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
return stripeSubscription.Id;
}
return null;
}
}

View File

@ -87,12 +87,18 @@ public class UserPageController : Controller
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
// Record page view (async, don't wait) - only for non-preview requests
Console.WriteLine($"DEBUG VIEW COUNT - Page: {userPage.Slug}, Status: {userPage.Status}, IsPreview: {isPreview}, PreviewToken: {previewToken}");
if (!isPreview)
{
Console.WriteLine($"DEBUG: Recording view for page {userPage.Slug}");
var referrer = Request.Headers["Referer"].FirstOrDefault();
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
}
else
{
Console.WriteLine($"DEBUG: NOT recording view - isPreview = true");
}
ViewBag.SeoSettings = seoSettings;
ViewBag.Category = categoryObj;

View File

@ -12,7 +12,7 @@
string Slug { get; }
string DisplayName { get; }
string Bio { get; }
string ProfileImage { get; }
string? ProfileImageId { get; }
string BusinessType { get; }
PageTheme Theme { get; }
List<LinkItem> Links { get; }
@ -20,7 +20,8 @@
string Language { get; }
DateTime CreatedAt { get; }
// Propriedade calculada comum
// Propriedades calculadas comuns
string FullUrl { get; }
string ProfileImageUrl { get; }
}
}

View File

@ -29,8 +29,14 @@ public class LivePage : IPageDisplay
[BsonElement("bio")]
public string Bio { get; set; } = string.Empty;
[BsonElement("profileImageId")]
public string? ProfileImageId { get; set; }
// Campo antigo - ignorar durante deserialização para compatibilidade
[BsonElement("profileImage")]
public string ProfileImage { get; set; } = string.Empty;
[BsonIgnoreIfDefault]
[BsonIgnore]
public string? ProfileImage { get; set; }
[BsonElement("businessType")]
public string BusinessType { get; set; } = string.Empty;
@ -60,6 +66,14 @@ public class LivePage : IPageDisplay
public DateTime CreatedAt { get; set; }
public string FullUrl => $"page/{Category}/{Slug}";
/// <summary>
/// URL da imagem de perfil ou imagem padrão se não houver upload
/// </summary>
[BsonIgnore]
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
? $"/api/image/{ProfileImageId}"
: "/images/default-avatar.svg";
}
public class LivePageAnalytics

View File

@ -29,8 +29,14 @@ public class UserPage : IPageDisplay
[BsonElement("bio")]
public string Bio { get; set; } = string.Empty;
[BsonElement("profileImageId")]
public string? ProfileImageId { get; set; }
// Campo antigo - ignorar durante deserialização para compatibilidade
[BsonElement("profileImage")]
public string ProfileImage { get; set; } = string.Empty;
[BsonIgnoreIfDefault]
[BsonIgnore]
public string? ProfileImage { get; set; }
[BsonElement("theme")]
public PageTheme Theme { get; set; } = new();
@ -87,4 +93,12 @@ public class UserPage : IPageDisplay
public int PreviewViewCount { get; set; } = 0;
public string FullUrl => $"page/{Category}/{Slug}";
/// <summary>
/// URL da imagem de perfil ou imagem padrão se não houver upload
/// </summary>
[BsonIgnore]
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
? $"/api/image/{ProfileImageId}"
: "/images/default-avatar.svg";
}

View File

@ -12,6 +12,7 @@ using Stripe;
using Microsoft.AspNetCore.Authentication.OAuth;
using SendGrid;
using BCards.Web.Middleware;
using Microsoft.AspNetCore.Http.Features;
var builder = WebApplication.CreateBuilder(args);
@ -126,6 +127,18 @@ builder.Services.AddScoped<IOpenGraphService, OpenGraphService>();
builder.Services.AddScoped<IModerationService, ModerationService>();
builder.Services.AddScoped<IEmailService, EmailService>();
// Image Storage Service
builder.Services.AddScoped<IImageStorageService, GridFSImageStorage>();
// Configure upload limits for file uploads
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10MB for forms with files
options.ValueLengthLimit = int.MaxValue;
options.ValueCountLimit = int.MaxValue;
options.KeyLengthLimit = int.MaxValue;
});
// 🔥 NOVO: LivePage Services
builder.Services.AddScoped<ILivePageRepository, LivePageRepository>();
builder.Services.AddScoped<ILivePageService, LivePageService>();

View File

@ -6,6 +6,7 @@ public interface ILivePageRepository
{
Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId);
Task<LivePage?> GetByIdAsync(string pageId);
Task<List<LivePage>> GetAllActiveAsync();
Task<LivePage> CreateAsync(LivePage livePage);
Task<LivePage> UpdateAsync(LivePage livePage);

View File

@ -43,6 +43,11 @@ public class LivePageRepository : ILivePageRepository
}
}
public async Task<LivePage?> GetByIdAsync(string pageId)
{
return await _collection.Find(x => x.Id == pageId).FirstOrDefaultAsync();
}
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
{
return await _collection.Find(x => x.Category == category && x.Slug == slug).FirstOrDefaultAsync();

View File

@ -0,0 +1,199 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Formats.Jpeg;
namespace BCards.Web.Services;
public class GridFSImageStorage : IImageStorageService
{
private readonly IMongoDatabase _database;
private readonly GridFSBucket _gridFS;
private readonly ILogger<GridFSImageStorage> _logger;
private const int TARGET_SIZE = 400;
private const int MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
private static readonly string[] ALLOWED_TYPES = { "image/jpeg", "image/jpg", "image/png", "image/gif" };
public GridFSImageStorage(IMongoDatabase database, ILogger<GridFSImageStorage> logger)
{
_database = database;
_gridFS = new GridFSBucket(database, new GridFSBucketOptions
{
BucketName = "profile_images"
});
_logger = logger;
}
public async Task<string> SaveImageAsync(byte[] imageBytes, string fileName, string contentType)
{
try
{
// Validações
if (imageBytes == null || imageBytes.Length == 0)
throw new ArgumentException("Image bytes cannot be null or empty");
if (imageBytes.Length > MAX_FILE_SIZE)
throw new ArgumentException($"File size exceeds maximum allowed size of {MAX_FILE_SIZE / (1024 * 1024)}MB");
if (!ALLOWED_TYPES.Contains(contentType.ToLower()))
throw new ArgumentException($"Content type {contentType} is not allowed");
// Processar e redimensionar imagem
var processedImage = await ProcessImageAsync(imageBytes);
// Metadata
var options = new GridFSUploadOptions
{
Metadata = new BsonDocument
{
{ "originalFileName", fileName },
{ "contentType", "image/jpeg" }, // Sempre JPEG após processamento
{ "uploadDate", DateTime.UtcNow },
{ "originalSize", imageBytes.Length },
{ "processedSize", processedImage.Length },
{ "dimensions", $"{TARGET_SIZE}x{TARGET_SIZE}" },
{ "version", "1.0" }
}
};
// Nome único para o arquivo
var uniqueFileName = $"profile_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}.jpg";
// Upload para GridFS
var fileId = await _gridFS.UploadFromBytesAsync(uniqueFileName, processedImage, options);
_logger.LogInformation("Image uploaded successfully: {FileId}, Size: {Size}KB",
fileId, processedImage.Length / 1024);
return fileId.ToString();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading image: {FileName}", fileName);
throw;
}
}
public async Task<byte[]?> GetImageAsync(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return null;
if (!ObjectId.TryParse(imageId, out var objectId))
{
_logger.LogWarning("Invalid ObjectId format: {ImageId}", imageId);
return null;
}
var imageBytes = await _gridFS.DownloadAsBytesAsync(objectId);
return imageBytes;
}
catch (GridFSFileNotFoundException)
{
_logger.LogWarning("Image not found: {ImageId}", imageId);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving image: {ImageId}", imageId);
return null;
}
}
public async Task<bool> DeleteImageAsync(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return false;
if (!ObjectId.TryParse(imageId, out var objectId))
return false;
await _gridFS.DeleteAsync(objectId);
_logger.LogInformation("Image deleted successfully: {ImageId}", imageId);
return true;
}
catch (GridFSFileNotFoundException)
{
_logger.LogWarning("Attempted to delete non-existent image: {ImageId}", imageId);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting image: {ImageId}", imageId);
return false;
}
}
public async Task<bool> ImageExistsAsync(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return false;
if (!ObjectId.TryParse(imageId, out var objectId))
return false;
var filter = Builders<GridFSFileInfo>.Filter.Eq("_id", objectId);
var fileInfo = await _gridFS.Find(filter).FirstOrDefaultAsync();
return fileInfo != null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking image existence: {ImageId}", imageId);
return false;
}
}
private async Task<byte[]> ProcessImageAsync(byte[] originalBytes)
{
return await Task.Run(() =>
{
using var originalImage = Image.Load(originalBytes);
// Calcular dimensões mantendo aspect ratio
var (newWidth, newHeight) = CalculateResizeDimensions(
originalImage.Width, originalImage.Height, TARGET_SIZE);
// Criar imagem com fundo branco
using var processedImage = new Image<SixLabors.ImageSharp.PixelFormats.Rgb24>(TARGET_SIZE, TARGET_SIZE);
// Preencher com fundo branco
processedImage.Mutate(ctx => ctx.BackgroundColor(SixLabors.ImageSharp.Color.White));
// Redimensionar a imagem original mantendo aspect ratio
originalImage.Mutate(ctx => ctx.Resize(newWidth, newHeight));
// Calcular posição para centralizar a imagem
var x = (TARGET_SIZE - newWidth) / 2;
var y = (TARGET_SIZE - newHeight) / 2;
// Desenhar a imagem centralizada sobre o fundo branco
processedImage.Mutate(ctx => ctx.DrawImage(originalImage, new Point(x, y), 1f));
// Converter para JPEG com compressão otimizada
using var outputStream = new MemoryStream();
var encoder = new JpegEncoder()
{
Quality = 85 // 85% qualidade
};
processedImage.SaveAsJpeg(outputStream, encoder);
return outputStream.ToArray();
});
}
private static (int width, int height) CalculateResizeDimensions(int originalWidth, int originalHeight, int targetSize)
{
var ratio = Math.Min((double)targetSize / originalWidth, (double)targetSize / originalHeight);
return ((int)(originalWidth * ratio), (int)(originalHeight * ratio));
}
}

View File

@ -0,0 +1,34 @@
namespace BCards.Web.Services;
public interface IImageStorageService
{
/// <summary>
/// Salva uma imagem no storage, com redimensionamento automático para 400x400px
/// </summary>
/// <param name="imageBytes">Bytes da imagem original</param>
/// <param name="fileName">Nome original do arquivo</param>
/// <param name="contentType">Tipo de conteúdo da imagem</param>
/// <returns>ID único da imagem salva</returns>
Task<string> SaveImageAsync(byte[] imageBytes, string fileName, string contentType);
/// <summary>
/// Recupera os bytes de uma imagem pelo ID
/// </summary>
/// <param name="imageId">ID da imagem</param>
/// <returns>Bytes da imagem ou null se não encontrada</returns>
Task<byte[]?> GetImageAsync(string imageId);
/// <summary>
/// Remove uma imagem do storage
/// </summary>
/// <param name="imageId">ID da imagem</param>
/// <returns>True se removida com sucesso</returns>
Task<bool> DeleteImageAsync(string imageId);
/// <summary>
/// Verifica se uma imagem existe no storage
/// </summary>
/// <param name="imageId">ID da imagem</param>
/// <returns>True se a imagem existe</returns>
Task<bool> ImageExistsAsync(string imageId);
}

View File

@ -6,6 +6,7 @@ public interface ILivePageService
{
Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
Task<List<LivePage>> GetAllActiveAsync();
Task<LivePage?> GetLivePageFromUserPageId(string userPageId);
Task<LivePage> SyncFromUserPageAsync(string userPageId);
Task<bool> DeleteByOriginalPageIdAsync(string originalPageId);
Task IncrementViewAsync(string livePageId);

View File

@ -30,6 +30,11 @@ public class LivePageService : ILivePageService
return await _livePageRepository.GetAllActiveAsync();
}
public async Task<LivePage?> GetLivePageFromUserPageId(string userPageId)
{
return await _livePageRepository.GetByOriginalPageIdAsync(userPageId);
}
public async Task<LivePage> SyncFromUserPageAsync(string userPageId)
{
var userPage = await _userPageRepository.GetByIdAsync(userPageId);
@ -50,7 +55,7 @@ public class LivePageService : ILivePageService
Slug = userPage.Slug,
DisplayName = userPage.DisplayName,
Bio = userPage.Bio,
ProfileImage = userPage.ProfileImage,
ProfileImageId = userPage.ProfileImageId,
BusinessType = userPage.BusinessType,
Theme = userPage.Theme,
Links = userPage.Links,

View File

@ -5,6 +5,7 @@ using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
using Stripe.BillingPortal;
using System.Numerics;
namespace BCards.Web.Services;
@ -115,22 +116,22 @@ public class PaymentService : IPaymentService
{
try
{
var stripeEvent = EventUtility.ConstructEvent(requestBody, signature, _stripeSettings.WebhookSecret);
var stripeEvent = EventUtility.ConstructEvent(requestBody, signature, _stripeSettings.WebhookSecret, throwOnApiVersionMismatch: false);
switch (stripeEvent.Type)
{
case Events.CheckoutSessionCompleted:
case "checkout.session.completed":
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
await HandleCheckoutSessionCompletedAsync(session!);
break;
case Events.InvoicePaymentSucceeded:
case "invoice.finalized":
var invoice = stripeEvent.Data.Object as Invoice;
await HandleInvoicePaymentSucceededAsync(invoice!);
break;
case Events.CustomerSubscriptionUpdated:
case Events.CustomerSubscriptionDeleted:
case "customer.subscription.updated":
case "customer.subscription.deleted":
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionUpdatedAsync(subscription!);
break;
@ -255,6 +256,9 @@ public class PaymentService : IPaymentService
var subscriptionService = new SubscriptionService();
var stripeSubscription = await subscriptionService.GetAsync(session.SubscriptionId);
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
var limitations = await GetPlanLimitationsAsync(planType);
var subscription = new Models.Subscription
@ -263,8 +267,8 @@ public class PaymentService : IPaymentService
StripeSubscriptionId = session.SubscriptionId,
PlanType = planType,
Status = stripeSubscription.Status,
CurrentPeriodStart = stripeSubscription.CurrentPeriodStart,
CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd,
CurrentPeriodStart = subItem.CurrentPeriodStart,
CurrentPeriodEnd = subItem.CurrentPeriodEnd,
MaxLinks = limitations.MaxLinks,
AllowCustomThemes = limitations.AllowCustomThemes,
AllowAnalytics = limitations.AllowAnalytics,
@ -287,7 +291,8 @@ public class PaymentService : IPaymentService
private async Task HandleInvoicePaymentSucceededAsync(Invoice invoice)
{
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
var subscriptionId = GetSubscriptionId(invoice);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (subscription != null)
{
subscription.Status = "active";
@ -300,9 +305,12 @@ public class PaymentService : IPaymentService
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
subscription.Status = stripeSubscription.Status;
subscription.CurrentPeriodStart = stripeSubscription.CurrentPeriodStart;
subscription.CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd;
subscription.CurrentPeriodStart = subItem.CurrentPeriodStart;
subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd;
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
await _subscriptionRepository.UpdateAsync(subscription);
@ -386,4 +394,29 @@ public class PaymentService : IPaymentService
throw new InvalidOperationException($"Erro ao criar sessão do portal: {ex.Message}");
}
}
private string GetSubscriptionId(Invoice? invoice)
{
if (invoice!=null)
{
var subscriptionLineItem = invoice.Lines?.Data
.FirstOrDefault(line =>
!string.IsNullOrEmpty(line.SubscriptionId) ||
line.Subscription != null
);
string subscriptionId = null;
if (subscriptionLineItem != null)
{
// Tenta obter o ID da assinatura de duas formas diferentes
subscriptionId = subscriptionLineItem.SubscriptionId
?? subscriptionLineItem.Subscription?.Id;
}
return subscriptionId;
}
return null;
}
}

View File

@ -20,7 +20,7 @@ public class SeoService : ISeoService
Keywords = GenerateKeywords(userPage, category),
OgTitle = GeneratePageTitle(userPage, category),
OgDescription = GeneratePageDescription(userPage, category),
OgImage = !string.IsNullOrEmpty(userPage.ProfileImage) ? userPage.ProfileImage : $"{_baseUrl}/images/default-og.png",
OgImage = !string.IsNullOrEmpty(userPage.ProfileImageId) ? userPage.ProfileImageUrl : $"{_baseUrl}/images/default-og.png",
CanonicalUrl = GenerateCanonicalUrl(userPage),
TwitterCard = "summary_large_image"
};

View File

@ -157,7 +157,7 @@ public class TrialExpirationService : BackgroundService
Básico - R$ 9,90/mês - 5 links, analytics básicos
Profissional - R$ 24,90/mês - 15 links, todos os temas, analytics avançados
Premium - R$ 29,90/mês - Links ilimitados, temas customizáveis, analytics completos
Premium - R$ 29,90/mês - Links ilimitados, temas premium, analytics completos
Seus dados estão seguros e serão restaurados assim que você escolher um plano.

View File

@ -12,15 +12,18 @@ public class UserPageService : IUserPageService
private readonly IUserPageRepository _userPageRepository;
private readonly IUserRepository _userRepository;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly ILivePageRepository _livePageRepository;
public UserPageService(
IUserPageRepository userPageRepository,
IUserRepository userRepository,
ISubscriptionRepository subscriptionRepository)
ISubscriptionRepository subscriptionRepository,
ILivePageRepository livePageRepository)
{
_userPageRepository = userPageRepository;
_userRepository = userRepository;
_subscriptionRepository = subscriptionRepository;
_livePageRepository = livePageRepository;
}
public async Task<UserPage?> GetPageAsync(string category, string slug)
@ -148,14 +151,21 @@ public class UserPageService : IUserPageService
public async Task RecordLinkClickAsync(string pageId, int linkIndex)
{
var page = await _userPageRepository.GetByIdAsync(pageId);
if (page?.PlanLimitations.AllowAnalytics != true) return;
var livePageExists = await _livePageRepository.GetByIdAsync(pageId);
if (livePageExists == null) return;
if (linkIndex >= 0 && linkIndex < page.Links.Count)
var page = await _userPageRepository.GetByIdAsync(livePageExists.OriginalPageId);
var livepage = (LivePage)livePageExists;
if (linkIndex >= 0 && linkIndex < livepage.Links.Count)
{
livepage.Links[linkIndex].Clicks++;
page.Links[linkIndex].Clicks++;
}
var analyticsLive = livepage.Analytics;
analyticsLive.TotalClicks++;
var analytics = page.Analytics;
analytics.TotalClicks++;
@ -166,6 +176,7 @@ public class UserPageService : IUserPageService
else
analytics.MonthlyClicks[monthKey] = 1;
await _livePageRepository.UpdateAsync(livepage);
await _userPageRepository.UpdateAsync(page);
}

View File

@ -20,16 +20,12 @@ public class CreatePageViewModel
[Required(ErrorMessage = "Tema é obrigatório")]
public string SelectedTheme { get; set; } = "minimalist";
[Phone(ErrorMessage = "Número de WhatsApp inválido")]
public string WhatsAppNumber { get; set; } = string.Empty;
[Url(ErrorMessage = "URL do Facebook inválida")]
public string FacebookUrl { get; set; } = string.Empty;
[Url(ErrorMessage = "URL do X/Twitter inválida")]
public string TwitterUrl { get; set; } = string.Empty;
[Url(ErrorMessage = "URL do Instagram inválida")]
public string InstagramUrl { get; set; } = string.Empty;
public List<CreateLinkViewModel> Links { get; set; } = new();

View File

@ -36,6 +36,10 @@ public class ManagePageViewModel
public List<ManageLinkViewModel> Links { get; set; } = new();
// Profile image fields
public string? ProfileImageId { get; set; }
public IFormFile? ProfileImageFile { get; set; }
// Data for dropdowns and selections
public List<Category> AvailableCategories { get; set; } = new();
public List<PageTheme> AvailableThemes { get; set; } = new();
@ -43,6 +47,13 @@ public class ManagePageViewModel
// Plan limitations
public int MaxLinksAllowed { get; set; } = 3;
public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower());
/// <summary>
/// URL da imagem de perfil ou imagem padrão se não houver upload
/// </summary>
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
? $"/api/image/{ProfileImageId}"
: "/images/default-avatar.svg";
}
public class ManageLinkViewModel
@ -97,8 +108,8 @@ public class UserPageSummary
public string Slug { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public PageStatus Status { get; set; } = PageStatus.Active;
public int TotalClicks { get; set; } = 0;
public int TotalViews { get; set; } = 0;
public long TotalClicks { get; set; } = 0;
public long TotalViews { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string? PreviewToken { get; set; } = string.Empty;
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}?preview={PreviewToken}";
@ -126,5 +137,6 @@ public enum PageStatus
Inactive, // Pausada pelo usuário
PendingModeration = 4, // Aguardando moderação
Rejected = 5, // Rejeitada na moderação
Creating = 6 // Em desenvolvimento/criação
Creating = 6, // Em desenvolvimento/criação
Approved = 7 // Aprovada
}

View File

@ -19,7 +19,7 @@ public class ManageSubscriptionViewModel
public bool CanDowngrade => HasActiveSubscription && User.CurrentPlan != "basic";
public bool WillCancelAtPeriodEnd => StripeSubscription?.CancelAtPeriodEnd == true;
public DateTime? CurrentPeriodEnd => StripeSubscription?.CurrentPeriodEnd;
public DateTime? CurrentPeriodEnd => null;
public DateTime? NextBillingDate => !WillCancelAtPeriodEnd ? CurrentPeriodEnd : null;
public decimal? MonthlyAmount => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.UnitAmount / 100m;

View File

@ -548,18 +548,26 @@ function generateLinksData() {
}
});
// Create hidden inputs for links
$('#linksContainer').append('<div id="linksData"></div>');
$('#linksData').empty();
// Remove existing hidden link inputs
$('input[name^="Links["]').remove();
// Create hidden inputs for links directly in the form
links.forEach((link, index) => {
$('#linksData').append(`
$('#createPageForm').append(`
<input type="hidden" name="Links[${index}].Title" value="${link.Title}" />
<input type="hidden" name="Links[${index}].Url" value="${link.Url}" />
<input type="hidden" name="Links[${index}].Description" value="${link.Description}" />
<input type="hidden" name="Links[${index}].Icon" value="${link.Icon}" />
`);
});
// Debug: Log what we're sending
console.log('=== DEBUG GENERATELINKSDATA ===');
console.log('Links found:', links.length);
links.forEach((link, index) => {
console.log(`Link ${index}:`, link);
});
console.log('=== FIM DEBUG ===');
}
function generatePreview() {

View File

@ -111,7 +111,7 @@
onclick="openPreview('@pageItem.Id')"
data-page-category="@pageItem.Category"
data-page-slug="@pageItem.Slug">
<i class="fas fa-eye me-1"></i>Preview
<i class="fas fa-eye me-1"></i>Testar
</button>
}
@ -122,7 +122,7 @@
id="dropdownMenuButton@(pageItem.Id)"
data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fas fa-ellipsis-v"></i>
Ações
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton@(pageItem.Id)">
<!-- Editar - sempre presente -->
@ -212,7 +212,7 @@
<i class="fas fa-exclamation-triangle me-3"></i>
<div>
<strong>Página em criação!</strong>
Você pode editar e fazer preview quantas vezes quiser. <br />
Você pode editar e testar quantas vezes quiser. <br />
Ao terminar, clique em <i class="fas fa-ellipsis-v"></i> para enviar a página <b><span id="pageNameDisplay"></span></b> para moderação!
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
@ -268,7 +268,7 @@
{
<!-- Limite atingido -->
<div class="col-12">
<div class="alert alert-warning d-flex align-items-center">
<div class="alert alert-warning d-flex align-items-center alert-permanent">
<i class="fas fa-exclamation-triangle me-3"></i>
<div>
<strong>Limite atingido!</strong>
@ -331,7 +331,7 @@
</div>
<div class="small mb-3">
<i class="fas fa-palette me-2"></i>
Temas customizáveis: @(Model.CurrentPlan.AllowsCustomThemes ? "✅" : "❌")
Temas premium: @(Model.CurrentPlan.AllowsCustomThemes ? "✅" : "❌")
</div>
@if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial)

View File

@ -17,7 +17,7 @@
</div>
<div class="card-body">
<form asp-action="ManagePage" method="post" id="managePageForm" novalidate>
<form asp-action="ManagePage" method="post" id="managePageForm" enctype="multipart/form-data" novalidate>
<input asp-for="Id" type="hidden">
<input asp-for="IsNewPage" type="hidden">
@ -108,6 +108,38 @@
<div class="form-text">Máximo 200 caracteres</div>
</div>
<!-- Profile Image Upload -->
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label class="form-label">
<i class="fas fa-camera me-2"></i>
Foto de Perfil (Opcional)
</label>
<input type="file" class="form-control" id="profileImageInput" name="ProfileImageFile" accept="image/*">
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
Formatos aceitos: JPG, PNG, GIF. Máximo: 5MB. Será redimensionada para 400x400px.
</div>
<span class="text-danger" id="imageError"></span>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="profile-image-preview">
<img id="imagePreview" src="@Model.ProfileImageUrl" alt="Preview" class="img-thumbnail profile-preview-img">
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-danger" id="removeImageBtn" style="@(string.IsNullOrEmpty(Model.ProfileImageId) ? "display: none;" : "")">
<i class="fas fa-trash"></i> Remover
</button>
</div>
</div>
</div>
</div>
</div>
<input type="hidden" asp-for="ProfileImageId" id="profileImageId">
<div class="text-end">
<button type="button" class="btn btn-primary" onclick="nextStep(2)">
Próximo <i class="fas fa-arrow-right ms-1"></i>
@ -340,7 +372,7 @@
var instagram = Model.Links.Where(x => x.Icon.Contains("instagram")).FirstOrDefault();
var facebookUrl = facebook !=null ? facebook.Url : "";
var twitterUrl = twitter !=null ? twitter.Url : "";
var whatsappUrl = whatsapp !=null ? whatsapp.Url : "";
var whatsappUrl = whatsapp !=null ? whatsapp.Url.Replace("https://wa.me/","") : "";
var instagramUrl = instagram !=null ? instagram.Url : "";
}
<!-- Passo 4: Redes Sociais (Opcional) -->
@ -354,53 +386,96 @@
</h2>
<div id="collapseSocial" class="accordion-collapse collapse" aria-labelledby="headingSocial" data-bs-parent="#pageWizard">
<div class="accordion-body">
<p class="text-muted mb-4">Conecte suas redes sociais (todos os campos são opcionais):</p>
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle me-2"></i>
<strong>Redes Sociais Opcionais</strong>
<p class="mb-0 mt-1">Marque apenas as redes sociais que você quer conectar. Todas são opcionais e você pode pular esta etapa.</p>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label asp-for="WhatsAppNumber" class="form-label">
<i class="fab fa-whatsapp text-success"></i>
WhatsApp
<div class="col-lg-6">
<!-- WhatsApp -->
<div class="mb-4">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="enableWhatsApp">
<label class="form-check-label" for="enableWhatsApp">
<i class="fab fa-whatsapp text-success me-2"></i>
<strong>Conectar WhatsApp</strong>
</label>
<input asp-for="WhatsAppNumber" class="form-control" placeholder="+55 11 99999-9999" value="@whatsappUrl">
</div>
<div class="input-group social-input-group" id="whatsappGroup" style="display: none;">
<span class="input-group-text">
<i class="fab fa-whatsapp text-success me-2"></i>
https://wa.me/
</span>
<input type="text" class="form-control" id="whatsappNumber" placeholder="5511999999999">
</div>
<small class="form-text text-muted">Exemplo: 5511999999999 (código do país + DDD + número)</small>
<input asp-for="WhatsAppNumber" type="hidden" value="@(whatsappUrl ?? "")">
<span asp-validation-for="WhatsAppNumber" class="text-danger"></span>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label asp-for="FacebookUrl" class="form-label">
<i class="fab fa-facebook text-primary"></i>
Facebook
<!-- Facebook -->
<div class="mb-4">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="enableFacebook">
<label class="form-check-label" for="enableFacebook">
<i class="fab fa-facebook text-primary me-2"></i>
<strong>Conectar Facebook</strong>
</label>
<input asp-for="FacebookUrl" class="form-control" placeholder="https://facebook.com/seu-perfil" value="@facebookUrl">
</div>
<div class="input-group social-input-group" id="facebookGroup" style="display: none;">
<span class="input-group-text">
<i class="fab fa-facebook text-primary me-2"></i>
https://facebook.com/
</span>
<input type="text" class="form-control" id="facebookUser" placeholder="seu-usuario">
</div>
<input asp-for="FacebookUrl" type="hidden" value="@(facebookUrl ?? "")">
<span asp-validation-for="FacebookUrl" class="text-danger"></span>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label asp-for="TwitterUrl" class="form-label">
<i class="fab fa-x-twitter"></i>
X / Twitter
<div class="col-lg-6">
<!-- Instagram -->
<div class="mb-4">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="enableInstagram">
<label class="form-check-label" for="enableInstagram">
<i class="fab fa-instagram text-danger me-2"></i>
<strong>Conectar Instagram</strong>
</label>
<input asp-for="TwitterUrl" class="form-control" placeholder="https://x.com/seu-perfil" value="@twitterUrl">
<span asp-validation-for="TwitterUrl" class="text-danger"></span>
</div>
<div class="input-group social-input-group" id="instagramGroup" style="display: none;">
<span class="input-group-text">
<i class="fab fa-instagram text-danger me-2"></i>
https://instagram.com/
</span>
<input type="text" class="form-control" id="instagramUser" placeholder="seu-usuario">
</div>
<div class="col-md-6">
<div class="mb-3">
<label asp-for="InstagramUrl" class="form-label">
<i class="fab fa-instagram text-danger"></i>
Instagram
</label>
<input asp-for="InstagramUrl" class="form-control" placeholder="https://instagram.com/seu-perfil" value="@instagramUrl">
<input asp-for="InstagramUrl" type="hidden" value="@(instagramUrl ?? "")">
<span asp-validation-for="InstagramUrl" class="text-danger"></span>
</div>
<!-- X / Twitter -->
<div class="mb-4">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="enableTwitter">
<label class="form-check-label" for="enableTwitter">
<i class="fab fa-x-twitter me-2"></i>
<strong>Conectar X / Twitter</strong>
</label>
</div>
<div class="input-group social-input-group" id="twitterGroup" style="display: none;">
<span class="input-group-text">
<i class="fab fa-x-twitter me-2"></i>
https://x.com/
</span>
<input type="text" class="form-control" id="twitterUser" placeholder="seu-usuario">
</div>
<input asp-for="TwitterUrl" type="hidden" value="@(twitterUrl ?? "")">
<span asp-validation-for="TwitterUrl" class="text-danger"></span>
</div>
</div>
</div>
@ -408,10 +483,6 @@
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(3)">
<i class="fas fa-arrow-left me-1"></i> Anterior
</button>
<div>
<button type="button" class="btn btn-outline-success me-2" onclick="skipStep(4)">
Pular Etapa
</button>
<button type="submit" class="btn btn-success">
<i class="fas fa-@(Model.IsNewPage ? "rocket" : "save") me-2"></i>
@(Model.IsNewPage ? "Criar Página" : "Salvar Alterações")
@ -421,7 +492,6 @@
</div>
</div>
</div>
</div>
</form>
</div>
</div>
@ -674,6 +744,60 @@
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Social Media Input Groups */
.social-input-group .input-group-text {
min-width: 180px;
justify-content: flex-start;
background-color: #f8f9fa;
border-right: none;
font-size: 0.9rem;
font-weight: 500;
}
.social-input-group .form-control {
border-left: none;
}
.social-input-group .form-control:focus {
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
}
.form-check-label {
cursor: pointer;
font-weight: 500;
transition: color 0.2s ease;
}
.form-check-label:hover {
color: #0056b3;
}
.form-check-input:checked + .form-check-label {
color: #198754;
}
.social-input-group {
transition: all 0.3s ease;
}
/* Estados de validação */
.form-control.is-valid {
border-color: #198754;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='m2.3 6.73.99-.99 1.99-1.99L6.98 2.99l-.99-.99L4.49 3.5 3.5 2.51 2.51 3.5z'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right calc(0.375em + 0.1875rem) center;
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
}
.form-control.is-invalid {
border-color: #dc3545;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath d='m5.8 4.6 1.4 1.4m0 0 1.4 1.4m-1.4-1.4L5.8 8.4m1.4-1.4L8.6 5.6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right calc(0.375em + 0.1875rem) center;
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
}
/* Product Link Preview Styles */
.product-link-preview {
background: rgba(25, 135, 84, 0.05);
@ -699,6 +823,49 @@
border: 1px solid rgba(0, 0, 0, 0.125);
border-radius: 4px;
}
/* Profile Image Upload Styles */
.profile-image-preview {
border: 2px dashed #dee2e6;
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
background: rgba(13, 110, 253, 0.02);
}
.profile-image-preview:hover {
border-color: #007bff;
background: rgba(13, 110, 253, 0.05);
}
.profile-preview-img {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 50% !important;
border: 4px solid #fff !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transition: all 0.3s ease;
}
.profile-preview-img:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(0,0,0,0.2);
}
#profileImageInput {
transition: border-color 0.3s ease;
}
#profileImageInput:focus {
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
#imageError {
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>
@section Scripts {
@ -709,6 +876,30 @@
let currentStep = 1;
$(document).ready(function() {
// Initialize social media fields
initializeSocialMedia();
// Initialize image upload
initializeImageUpload();
// Check for validation errors and show toast + open accordion
checkValidationErrors();
// Garantir que campos não marcados sejam string vazia ao submeter
$('form').on('submit', function() {
ensureUncheckedFieldsAreEmpty();
// Debug: Verificar quais campos de links estão sendo enviados
console.log('=== DEBUG FORM SUBMISSION ===');
const formData = new FormData(this);
for (let [key, value] of formData.entries()) {
if (key.includes('Links[')) {
console.log(`${key}: ${value}`);
}
}
console.log('=== FIM DEBUG ===');
});
// Generate slug when name or category changes
$('#DisplayName, #Category').on('input change', function() {
generateSlug();
@ -867,13 +1058,27 @@
}
function addLinkInput(title = '', url = '', description = '', icon = '', linkType = 'Normal', id='new') {
// Encontrar o próximo índice disponível baseado em todos os campos Links[] existentes
const existingIndexes = [];
$('input[name^="Links["]').each(function() {
const name = $(this).attr('name');
const match = name.match(/Links\[(\d+)\]/);
if (match) {
existingIndexes.push(parseInt(match[1]));
}
});
// Encontrar o próximo índice disponível
const nextIndex = existingIndexes.length > 0 ? Math.max(...existingIndexes) + 1 : 0;
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
const displayCount = $('.link-input-group').length + 1;
const linkHtml = `
<div class="link-input-group" data-link="${linkCount}">
<div class="link-input-group" data-link="${nextIndex}">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">
${iconHtml}Link ${linkCount + 1}: ${title || 'Novo Link'}
${iconHtml}Link ${displayCount}: ${title || 'Novo Link'}
</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
<i class="fas fa-trash"></i>
@ -882,27 +1087,26 @@
<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>
<input type="text" name="Links[${nextIndex}].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>
<input type="url" name="Links[${nextIndex}].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>
<input type="text" name="Links[${nextIndex}].Description" class="form-control link-description" value="${description}" placeholder="Breve descrição do link" readonly>
</div>
<input type="hidden" name="Links[${linkCount}].Id" value="${id}">
<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">
<input type="hidden" name="Links[${nextIndex}].Id" value="${id}">
<input type="hidden" name="Links[${nextIndex}].Type" value="${linkType}">
<input type="hidden" name="Links[${nextIndex}].Icon" value="${icon}">
<input type="hidden" name="Links[${nextIndex}].Order" value="${nextIndex}">
<input type="hidden" name="Links[${nextIndex}].IsActive" value="true">
</div>
`;
@ -1010,11 +1214,25 @@
}
function addProductLinkInput(title, url, description, price, image, id='new') {
// Encontrar o próximo índice disponível baseado em todos os campos Links[] existentes
const existingIndexes = [];
$('input[name^="Links["]').each(function() {
const name = $(this).attr('name');
const match = name.match(/Links\[(\d+)\]/);
if (match) {
existingIndexes.push(parseInt(match[1]));
}
});
// Encontrar o próximo índice disponível
const nextIndex = existingIndexes.length > 0 ? Math.max(...existingIndexes) + 1 : 0;
const displayCount = $('.link-input-group').length + 1;
const linkHtml = `
<div class="link-input-group product-link-preview" data-link="${linkCount}">
<div class="link-input-group product-link-preview" data-link="${nextIndex}">
<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}
<i class="fas fa-shopping-bag me-2 text-success"></i>Link de Produto ${displayCount}
</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
<i class="fas fa-trash"></i>
@ -1042,18 +1260,18 @@
</div>
<!-- Hidden fields for form submission -->
<input type="hidden" name="Links[${linkCount}].Id" value="${id}">
<input type="hidden" name="Links[${linkCount}].Title" value="${title}">
<input type="hidden" name="Links[${linkCount}].Url" value="${url}">
<input type="hidden" name="Links[${linkCount}].Description" value="${description}">
<input type="hidden" name="Links[${linkCount}].Type" value="Product">
<input type="hidden" name="Links[${linkCount}].ProductTitle" value="${title}">
<input type="hidden" name="Links[${linkCount}].ProductDescription" value="${description}">
<input type="hidden" name="Links[${linkCount}].ProductPrice" value="${price}">
<input type="hidden" name="Links[${linkCount}].ProductImage" value="${image}">
<input type="hidden" name="Links[${linkCount}].Icon" value="fas fa-shopping-bag">
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
<input type="hidden" name="Links[${nextIndex}].Id" value="${id}">
<input type="hidden" name="Links[${nextIndex}].Title" value="${title}">
<input type="hidden" name="Links[${nextIndex}].Url" value="${url}">
<input type="hidden" name="Links[${nextIndex}].Description" value="${description}">
<input type="hidden" name="Links[${nextIndex}].Type" value="Product">
<input type="hidden" name="Links[${nextIndex}].ProductTitle" value="${title}">
<input type="hidden" name="Links[${nextIndex}].ProductDescription" value="${description}">
<input type="hidden" name="Links[${nextIndex}].ProductPrice" value="${price}">
<input type="hidden" name="Links[${nextIndex}].ProductImage" value="${image}">
<input type="hidden" name="Links[${nextIndex}].Icon" value="fas fa-shopping-bag">
<input type="hidden" name="Links[${nextIndex}].Order" value="${nextIndex}">
<input type="hidden" name="Links[${nextIndex}].IsActive" value="true">
</div>
`;
@ -1104,6 +1322,286 @@
$toast.remove();
}, 5000);
}
// Validation Error Handling
function checkValidationErrors() {
// Só verificar erros se estamos em um POST-back (ou seja, se ModelState foi validado)
// Verificamos se existe algum input com validation-error class ou summary de erros
const hasValidationSummary = $('.validation-summary-errors').length > 0;
const hasFieldErrors = $('.input-validation-error').length > 0;
if (!hasValidationSummary && !hasFieldErrors) {
return; // Não há erros reais de validação, sair
}
const errorElements = $('.text-danger:not(:empty)').filter(function() {
// Filtrar apenas spans que realmente têm mensagens de erro
const text = $(this).text().trim();
return text.length > 0;
});
if (errorElements.length > 0) {
// Find which accordion steps have errors
const stepsWithErrors = [];
errorElements.each(function() {
const $error = $(this);
const $accordion = $error.closest('.accordion-item');
if ($accordion.length > 0) {
const stepNumber = getStepNumber($accordion);
if (stepNumber && !stepsWithErrors.includes(stepNumber)) {
stepsWithErrors.push(stepNumber);
}
}
});
if (stepsWithErrors.length > 0) {
// Show validation error toast
showValidationErrorToast(stepsWithErrors);
// Open first accordion with error
const firstErrorStep = Math.min(...stepsWithErrors);
openAccordionStep(firstErrorStep);
}
}
}
function getStepNumber($accordion) {
const id = $accordion.find('.accordion-collapse').attr('id');
const stepMap = {
'collapseBasic': 1,
'collapseLinks': 3,
'collapseSocial': 4
};
return stepMap[id] || null;
}
function openAccordionStep(stepNumber) {
const stepMap = {
1: '#collapseBasic',
3: '#collapseLinks',
4: '#collapseSocial'
};
const targetId = stepMap[stepNumber];
if (targetId) {
$(targetId).collapse('show');
}
}
function showValidationErrorToast(stepsWithErrors) {
const stepNames = {
1: 'Informações Básicas',
3: 'Links',
4: 'Redes Sociais'
};
const errorStepNames = stepsWithErrors.map(step => stepNames[step]).join(', ');
const toastHtml = `
<div class="toast align-items-center text-bg-warning border-0" role="alert" style="position: fixed; top: 20px; right: 20px; z-index: 9999;">
<div class="d-flex">
<div class="toast-body">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Erro de Validação</strong><br>
Verifique os campos em: ${errorStepNames}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
const $toast = $(toastHtml);
$('body').append($toast);
const toast = new bootstrap.Toast($toast[0], { delay: 4000 });
toast.show();
// Remove toast after it's hidden
$toast.on('hidden.bs.toast', function() {
$(this).remove();
});
}
// Garantir que campos não selecionados sejam string vazia
function ensureUncheckedFieldsAreEmpty() {
const socialFields = [
{ checkbox: '#enableWhatsApp', hidden: 'input[name="WhatsAppNumber"]' },
{ checkbox: '#enableFacebook', hidden: 'input[name="FacebookUrl"]' },
{ checkbox: '#enableInstagram', hidden: 'input[name="InstagramUrl"]' },
{ checkbox: '#enableTwitter', hidden: 'input[name="TwitterUrl"]' }
];
socialFields.forEach(field => {
if (!$(field.checkbox).is(':checked')) {
$(field.hidden).val(' '); // Forçar espaço para campos não marcados
}
});
}
// Social Media Functions
function initializeSocialMedia() {
// WhatsApp
setupSocialField('WhatsApp', 'WhatsAppNumber', 'https://wa.me/', true);
// Facebook
setupSocialField('Facebook', 'FacebookUrl', 'https://facebook.com/', false);
// Instagram
setupSocialField('Instagram', 'InstagramUrl', 'https://instagram.com/', false);
// Twitter
setupSocialField('Twitter', 'TwitterUrl', 'https://x.com/', false);
}
function setupSocialField(name, hiddenFieldName, prefix, isWhatsApp) {
const checkbox = $(`#enable${name}`);
const groupName = name.toLowerCase();
const group = $(`#${groupName}Group`);
const userInput = isWhatsApp ? $(`#${groupName}Number`) : $(`#${groupName}User`);
const hiddenField = $(`input[name="${hiddenFieldName}"]`);
// SEMPRE garantir que hidden field tenha um valor (espaço se vazio)
if (!hiddenField.val() || hiddenField.val().trim() === '') {
hiddenField.val(' '); // Espaço em branco para evitar null
}
// Verificar se já tem valor e configurar estado inicial
const currentValue = hiddenField.val();
if (currentValue && currentValue.trim() !== '') {
checkbox.prop('checked', true);
group.show();
// Extrair username/número do valor atual
if (currentValue.startsWith(prefix)) {
const userPart = currentValue.replace(prefix, '');
userInput.val(userPart);
} else {
userInput.val(currentValue);
}
} else {
// Se não tem valor, garantir que o hidden field seja espaço
hiddenField.val(' ');
}
// Toggle visibility
checkbox.on('change', function() {
if ($(this).is(':checked')) {
group.slideDown(200);
userInput.focus();
} else {
group.slideUp(200);
userInput.val('');
hiddenField.val(' '); // Espaço em branco para evitar null - sobrescreve valor existente
}
});
// Atualizar campo hidden em tempo real
userInput.on('input', function() {
let value = $(this).val().trim();
// Se o usuário colou uma URL completa, extrair apenas a parte do usuário
if (value.startsWith(prefix)) {
value = value.replace(prefix, '');
$(this).val(value);
}
if (isWhatsApp) {
// WhatsApp: apenas números
value = value.replace(/\D/g, '');
$(this).val(value);
}
// Atualizar campo hidden - SEMPRE string, nunca null
if (value) {
hiddenField.val(prefix + value);
} else {
hiddenField.val(' '); // Espaço em branco para evitar null
}
// Feedback visual
updateSocialFieldFeedback(userInput, value, isWhatsApp);
});
}
function updateSocialFieldFeedback(input, value, isWhatsApp) {
// Remover classes anteriores
input.removeClass('is-valid is-invalid');
if (!value) return;
if (isWhatsApp) {
// Validar WhatsApp (mínimo 10 dígitos)
if (value.length >= 10) {
input.addClass('is-valid');
} else {
input.addClass('is-invalid');
}
} else {
// Validar username (mínimo 3 caracteres, sem espaços)
if (value.length >= 3 && !/\s/.test(value)) {
input.addClass('is-valid');
} else {
input.addClass('is-invalid');
}
}
}
// Image Upload Functions
function initializeImageUpload() {
const fileInput = $('#profileImageInput');
const preview = $('#imagePreview');
const removeBtn = $('#removeImageBtn');
const errorSpan = $('#imageError');
const hiddenField = $('#profileImageId');
// Preview da imagem selecionada
fileInput.on('change', function(e) {
const file = e.target.files[0];
errorSpan.text('');
if (!file) return;
// Validações client-side
if (!file.type.match(/^image\/(jpeg|jpg|png|gif)$/i)) {
errorSpan.text('Formato inválido. Use apenas JPG, PNG ou GIF.');
fileInput.val('');
return;
}
if (file.size > 5 * 1024 * 1024) { // 5MB
errorSpan.text('Arquivo muito grande. Máximo 5MB.');
fileInput.val('');
return;
}
// Preview
const reader = new FileReader();
reader.onload = function(e) {
preview.attr('src', e.target.result);
removeBtn.show();
markStepComplete(1); // Marcar step 1 como completo
};
reader.readAsDataURL(file);
});
// Remover imagem
removeBtn.on('click', function() {
if (confirm('Tem certeza que deseja remover a imagem de perfil?')) {
fileInput.val('');
hiddenField.val('');
preview.attr('src', '/images/default-avatar.svg');
removeBtn.hide();
errorSpan.text('');
}
});
// Mostrar botão remover se já tem imagem
if (hiddenField.val()) {
removeBtn.show();
}
}
</script>
}

View File

@ -1,6 +1,6 @@
@{
//var isPreview = ViewBag.IsPreview as bool? ?? false;
ViewData["Title"] = "BCards - Crie seu LinkTree Profissional";
ViewData["Title"] = "BCards - Crie sua bio / links Profissional";
var categories = ViewBag.Categories as List<BCards.Web.Models.Category> ?? new List<BCards.Web.Models.Category>();
var recentPages = ViewBag.RecentPages as List<BCards.Web.Models.UserPage> ?? new List<BCards.Web.Models.UserPage>();
//Layout = isPreview ? "_Layout" : "_UserPageLayout";
@ -15,7 +15,8 @@
Crie sua página profissional em minutos
</h1>
<p class="lead mb-4">
A melhor alternativa ao LinkTree para profissionais e empresas no Brasil.
A melhor alternativa ao para ter uma página de links simples.
Criada para profissionais e empresas no Brasil.
Organize todos os seus links em uma página única e profissional.
</p>
<div class="d-flex gap-3 flex-wrap">
@ -113,16 +114,16 @@
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
@if (!string.IsNullOrEmpty(page.ProfileImage))
@if (!string.IsNullOrEmpty(page.ProfileImageId))
{
<img src="@(page.ProfileImage)" alt="@(page.DisplayName)"
<img src="@(page.ProfileImageUrl)" alt="@(page.DisplayName)"
class="rounded-circle mb-3" style="width: 60px; height: 60px; object-fit: cover;">
}
else
{
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3"
style="width: 60px; height: 60px;">
<i class="fs-4 text-primary">👤</i>
<i class="fas fa-id-card text-primary"></i>
</div>
}
<h6 class="card-title">@(page.DisplayName)</h6>

View File

@ -42,7 +42,7 @@
</li>
<li class="mb-3">
<i class="text-muted me-2">✗</i>
<span class="text-muted">Domínio personalizado</span>
<span class="text-muted">Página rápida</span>
</li>
</ul>
</div>
@ -89,11 +89,11 @@
</li>
<li class="mb-3">
<i class="text-muted me-2">✗</i>
<span class="text-muted">Domínio personalizado</span>
<span class="text-muted">Página rápida</span>
</li>
<li class="mb-3">
<i class="text-muted me-2">✗</i>
<span class="text-muted">Temas customizáveis</span>
<span class="text-muted">Temas premium</span>
</li>
</ul>
</div>
@ -189,7 +189,7 @@
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Temas customizáveis
Temas premium
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(ViewData["Title"] ?? "BCards - Crie seu LinkTree Profissional")</title>
<title>@(ViewData["Title"] ?? "BCards - Crie sua bios / links Profissional")</title>
@if (ViewBag.SeoSettings != null)
{
@ -27,8 +27,8 @@
}
else
{
<meta name="description" content="Crie sua página profissional com links organizados. A melhor alternativa ao LinkTree para profissionais e empresas no Brasil." />
<meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital" />
<meta name="description" content="Crie sua página profissional com links organizados. A melhor alternativa para ter sua bio / links. Criada para profissionais e empresas no Brasil." />
<meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital, bio, links, página simples" />
}
@await RenderSectionAsync("Head", required: false)

View File

@ -39,16 +39,16 @@
margin: 0 auto;
}
.profile-image {
.profile-image-large {
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid var(--primary-color);
object-fit: cover;
display: block;
margin: 0 auto;
}
.profile-image-placeholder {
.profile-icon-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
@ -404,8 +404,8 @@
border-radius: 15px;
}
.profile-image,
.profile-image-placeholder {
.profile-image-large,
.profile-icon-placeholder {
width: 100px;
height: 100px;
}
@ -449,8 +449,8 @@
margin: 0 0.5rem;
}
.profile-image,
.profile-image-placeholder {
.profile-image-large,
.profile-icon-placeholder {
width: 80px;
height: 80px;
}

View File

@ -46,21 +46,23 @@
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="profile-card text-center mx-auto">
<!-- Profile Image -->
@if (!string.IsNullOrEmpty(Model.ProfileImage))
<div class="profile-card mx-auto">
<!-- Profile Image & Info -->
<div class="profile-header text-center mb-4">
@if (!string.IsNullOrEmpty(Model.ProfileImageId))
{
<img src="@Model.ProfileImage" alt="@Model.DisplayName" class="profile-image mb-3">
<img src="@Model.ProfileImageUrl" alt="@Model.DisplayName" class="profile-image-large rounded-circle mb-3">
}
else
{
<div class="profile-image-placeholder mb-3 mx-auto">
👤
<div class="profile-icon-placeholder mb-3">
<i class="fas fa-id-card"></i>
</div>
}
<h1 class="profile-name mb-0">@Model.DisplayName</h1>
</div>
<!-- Profile Info -->
<h1 class="profile-name">@Model.DisplayName</h1>
<div class="text-center">
@if (!string.IsNullOrEmpty(Model.Bio))
{
@ -228,7 +230,9 @@
Criado com <a href="@Url.Action("Index", "Home")">BCards</a>
</div>
</div>
</div>
</div> <!-- /text-center -->
</div> <!-- /profile-card -->
</div>
</div>
</div>

View File

@ -6,6 +6,31 @@
}
},
"DetailedErrors": true,
"Stripe": {
"PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS",
"SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO",
"WebhookSecret": "whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543"
},
"Plans": {
"Basic": {
"PriceId": "price_1RjUskBMIadsOxJVgLwlVo1y",
"Price": 9.90,
"MaxLinks": 5,
"Features": [ "basic_themes", "simple_analytics" ]
},
"Professional": {
"PriceId": "price_1RjUv9BMIadsOxJVORqlM4E9",
"Price": 24.90,
"MaxLinks": 15,
"Features": [ "all_themes", "advanced_analytics", "custom_domain" ]
},
"Premium": {
"PriceId": "price_1RjUw0BMIadsOxJVmdouNV1g",
"Price": 29.90,
"MaxLinks": -1,
"Features": [ "custom_themes", "full_analytics", "multiple_domains", "priority_support" ]
}
},
"MongoDb": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "BCardsDB_Dev"

View File

@ -27,6 +27,22 @@
margin: 0 auto;
}
.profile-image-small {
width: 60px;
height: 60px;
border-radius: 50%;
border: 3px solid #007bff;
object-fit: cover;
flex-shrink: 0;
}
.profile-header {
padding: 1rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 15px;
backdrop-filter: blur(10px);
}
.profile-image-placeholder {
width: 120px;
height: 120px;
@ -152,6 +168,15 @@
height: 100px;
}
.profile-image-small {
width: 50px;
height: 50px;
}
.profile-header {
padding: 0.75rem;
}
.profile-name {
font-size: 1.75rem;
}

View File

@ -0,0 +1,11 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="200" cy="200" r="200" fill="#f8f9fa"/>
<!-- User icon -->
<circle cx="200" cy="160" r="50" fill="#6c757d"/>
<path d="M200 220c-40 0-75 20-90 50h180c-15-30-50-50-90-50z" fill="#6c757d"/>
<!-- Border -->
<circle cx="200" cy="200" r="199" stroke="#dee2e6" stroke-width="2" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 449 B