Compare commits
6 Commits
038422c255
...
d32cc18044
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d32cc18044 | ||
|
|
c824e9da1c | ||
|
|
9e7ea6ed9a | ||
|
|
5fc7eb5ad3 | ||
|
|
c6129a1c63 | ||
|
|
2449a617ca |
@ -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" />
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
202
src/BCards.Web/Controllers/ImageController.cs
Normal file
202
src/BCards.Web/Controllers/ImageController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
199
src/BCards.Web/Services/GridFSImageStorage.cs
Normal file
199
src/BCards.Web/Services/GridFSImageStorage.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
34
src/BCards.Web/Services/IImageStorageService.cs
Normal file
34
src/BCards.Web/Services/IImageStorageService.cs
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
11
src/BCards.Web/wwwroot/images/default-avatar.svg
Normal file
11
src/BCards.Web/wwwroot/images/default-avatar.svg
Normal 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 |
Loading…
Reference in New Issue
Block a user