feat: fale conosco
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 4s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 11m18s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 17s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s

This commit is contained in:
Ricardo Carneiro 2025-10-28 19:58:43 -03:00
parent 94c77fc867
commit b382688a8f
24 changed files with 1733 additions and 5 deletions

View File

@ -0,0 +1,86 @@
using BCards.Web.Areas.Support.Models;
using BCards.Web.Areas.Support.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Areas.Support.Controllers;
[Area("Support")]
[Route("api/ratings")]
[ApiController]
public class RatingsController : ControllerBase
{
private readonly IRatingService _ratingService;
private readonly ILogger<RatingsController> _logger;
public RatingsController(IRatingService ratingService, ILogger<RatingsController> logger)
{
_ratingService = ratingService;
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> SubmitRating([FromBody] RatingSubmissionDto dto)
{
if (!ModelState.IsValid)
{
_logger.LogWarning("Rating inválido submetido: {Errors}", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
return BadRequest(ModelState);
}
try
{
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var success = await _ratingService.SubmitRatingAsync(dto, userId, HttpContext);
if (success)
{
_logger.LogInformation("Rating de {Stars} estrelas submetido com sucesso", dto.RatingValue);
return Ok(new { message = "Avaliação enviada com sucesso! Obrigado pelo feedback." });
}
_logger.LogError("Falha ao submeter rating");
return StatusCode(503, new { message = "Erro ao processar sua avaliação. Tente novamente mais tarde." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao processar rating");
return StatusCode(500, new { message = "Erro interno ao processar sua avaliação." });
}
}
[HttpGet("average")]
public async Task<IActionResult> GetAverageRating()
{
try
{
var average = await _ratingService.GetAverageRatingAsync();
var total = await _ratingService.GetTotalCountAsync();
return Ok(new { average = Math.Round(average, 2), total });
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar média de ratings");
return StatusCode(500, new { message = "Erro ao buscar avaliações" });
}
}
[HttpGet("recent")]
public async Task<IActionResult> GetRecentRatings([FromQuery] int limit = 10)
{
try
{
if (limit < 1 || limit > 50)
limit = 10;
var ratings = await _ratingService.GetRecentRatingsAsync(limit);
return Ok(ratings);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar ratings recentes");
return StatusCode(500, new { message = "Erro ao buscar avaliações recentes" });
}
}
}

View File

@ -0,0 +1,46 @@
using BCards.Web.Areas.Support.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Areas.Support.Controllers;
[Area("Support")]
[Authorize]
public class SupportController : Controller
{
private readonly ISupportService _supportService;
private readonly ILogger<SupportController> _logger;
public SupportController(ISupportService supportService, ILogger<SupportController> logger)
{
_supportService = supportService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> ContactForm()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var options = await _supportService.GetAvailableOptionsAsync(userId);
if (!options.CanUseContactForm)
{
_logger.LogWarning("Usuário {UserId} tentou acessar formulário sem permissão", userId);
TempData["Error"] = "Seu plano atual não tem acesso ao formulário de contato. Faça upgrade para o plano Básico ou superior.";
return RedirectToAction("Index", "Home", new { area = "" });
}
return View(options);
}
[HttpGet]
[Route("Support/Index")]
public async Task<IActionResult> Index()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var options = await _supportService.GetAvailableOptionsAsync(userId);
return View(options);
}
}

View File

@ -0,0 +1,24 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Areas.Support.Models;
public class Rating
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
public int RatingValue { get; set; } // 1-5 stars
public string? Name { get; set; }
public string? Email { get; set; }
public string? Comment { get; set; }
public string? UserId { get; set; } // null para anônimos
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? Culture { get; set; }
public string? Url { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace BCards.Web.Areas.Support.Models;
public class RatingSubmissionDto
{
[Required]
[Range(1, 5, ErrorMessage = "A avaliação deve ser entre 1 e 5 estrelas")]
public int RatingValue { get; set; }
[StringLength(100, ErrorMessage = "O nome deve ter no máximo 100 caracteres")]
public string? Name { get; set; }
[EmailAddress(ErrorMessage = "Email inválido")]
public string? Email { get; set; }
[StringLength(500, ErrorMessage = "O comentário deve ter no máximo 500 caracteres")]
public string? Comment { get; set; }
}

View File

@ -0,0 +1,11 @@
namespace BCards.Web.Areas.Support.Models;
public class SupportOptions
{
public bool CanRate { get; set; }
public bool CanUseContactForm { get; set; }
public bool CanAccessTelegram { get; set; }
public string? TelegramUrl { get; set; }
public string? FormspreeUrl { get; set; }
public string UserPlan { get; set; } = "Trial";
}

View File

@ -0,0 +1,12 @@
using BCards.Web.Areas.Support.Models;
namespace BCards.Web.Areas.Support.Repositories;
public interface IRatingRepository
{
Task<Rating> CreateAsync(Rating rating);
Task<List<Rating>> GetRecentAsync(int limit = 10);
Task<double> GetAverageRatingAsync();
Task<int> GetTotalCountAsync();
Task<List<Rating>> GetByUserIdAsync(string userId);
}

View File

@ -0,0 +1,123 @@
using BCards.Web.Areas.Support.Models;
using MongoDB.Driver;
namespace BCards.Web.Areas.Support.Repositories;
public class RatingRepository : IRatingRepository
{
private readonly IMongoCollection<Rating> _ratings;
private readonly ILogger<RatingRepository> _logger;
public RatingRepository(IMongoDatabase database, ILogger<RatingRepository> logger)
{
_ratings = database.GetCollection<Rating>("ratings");
_logger = logger;
// Criar índices
CreateIndexes();
}
private void CreateIndexes()
{
try
{
var indexKeysDefinition = Builders<Rating>.IndexKeys.Descending(r => r.CreatedAt);
var indexModel = new CreateIndexModel<Rating>(indexKeysDefinition);
_ratings.Indexes.CreateOne(indexModel);
var userIdIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.UserId);
var userIdIndexModel = new CreateIndexModel<Rating>(userIdIndexKeys);
_ratings.Indexes.CreateOne(userIdIndexModel);
var ratingValueIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.RatingValue);
var ratingValueIndexModel = new CreateIndexModel<Rating>(ratingValueIndexKeys);
_ratings.Indexes.CreateOne(ratingValueIndexModel);
var cultureIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.Culture);
var cultureIndexModel = new CreateIndexModel<Rating>(cultureIndexKeys);
_ratings.Indexes.CreateOne(cultureIndexModel);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Não foi possível criar índices para a collection ratings");
}
}
public async Task<Rating> CreateAsync(Rating rating)
{
try
{
await _ratings.InsertOneAsync(rating);
_logger.LogInformation("Rating criado com sucesso: {RatingId}", rating.Id);
return rating;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao criar rating");
throw;
}
}
public async Task<List<Rating>> GetRecentAsync(int limit = 10)
{
try
{
return await _ratings
.Find(_ => true)
.SortByDescending(r => r.CreatedAt)
.Limit(limit)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar ratings recentes");
return new List<Rating>();
}
}
public async Task<double> GetAverageRatingAsync()
{
try
{
var ratings = await _ratings.Find(_ => true).ToListAsync();
if (ratings.Count == 0)
return 0;
return ratings.Average(r => r.RatingValue);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao calcular média de ratings");
return 0;
}
}
public async Task<int> GetTotalCountAsync()
{
try
{
return (int)await _ratings.CountDocumentsAsync(_ => true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao contar ratings");
return 0;
}
}
public async Task<List<Rating>> GetByUserIdAsync(string userId)
{
try
{
return await _ratings
.Find(r => r.UserId == userId)
.SortByDescending(r => r.CreatedAt)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar ratings do usuário {UserId}", userId);
return new List<Rating>();
}
}
}

View File

@ -0,0 +1,11 @@
using BCards.Web.Areas.Support.Models;
namespace BCards.Web.Areas.Support.Services;
public interface IRatingService
{
Task<bool> SubmitRatingAsync(RatingSubmissionDto dto, string? userId, HttpContext httpContext);
Task<double> GetAverageRatingAsync();
Task<int> GetTotalCountAsync();
Task<List<Rating>> GetRecentRatingsAsync(int limit = 10);
}

View File

@ -0,0 +1,8 @@
using BCards.Web.Areas.Support.Models;
namespace BCards.Web.Areas.Support.Services;
public interface ISupportService
{
Task<SupportOptions> GetAvailableOptionsAsync(string? userId);
}

View File

@ -0,0 +1,61 @@
using BCards.Web.Areas.Support.Models;
using BCards.Web.Areas.Support.Repositories;
using System.Globalization;
namespace BCards.Web.Areas.Support.Services;
public class RatingService : IRatingService
{
private readonly IRatingRepository _repository;
private readonly ILogger<RatingService> _logger;
public RatingService(IRatingRepository repository, ILogger<RatingService> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> SubmitRatingAsync(RatingSubmissionDto dto, string? userId, HttpContext httpContext)
{
try
{
var rating = new Rating
{
RatingValue = dto.RatingValue,
Name = dto.Name,
Email = dto.Email,
Comment = dto.Comment,
UserId = userId,
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = httpContext.Request.Headers["User-Agent"].ToString(),
Culture = CultureInfo.CurrentCulture.Name,
Url = httpContext.Request.Headers["Referer"].ToString(),
CreatedAt = DateTime.UtcNow
};
await _repository.CreateAsync(rating);
_logger.LogInformation("Rating submetido com sucesso por usuário {UserId}", userId ?? "anônimo");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao submeter rating");
return false;
}
}
public async Task<double> GetAverageRatingAsync()
{
return await _repository.GetAverageRatingAsync();
}
public async Task<int> GetTotalCountAsync()
{
return await _repository.GetTotalCountAsync();
}
public async Task<List<Rating>> GetRecentRatingsAsync(int limit = 10)
{
return await _repository.GetRecentAsync(limit);
}
}

View File

@ -0,0 +1,101 @@
using BCards.Web.Areas.Support.Models;
using BCards.Web.Configuration;
using BCards.Web.Models;
using BCards.Web.Repositories;
using Microsoft.Extensions.Options;
namespace BCards.Web.Areas.Support.Services;
public class SupportService : ISupportService
{
private readonly IUserRepository _userRepository;
private readonly IOptions<SupportSettings> _settings;
private readonly ILogger<SupportService> _logger;
public SupportService(
IUserRepository userRepository,
IOptions<SupportSettings> settings,
ILogger<SupportService> logger)
{
_userRepository = userRepository;
_settings = settings;
_logger = logger;
}
public async Task<SupportOptions> GetAvailableOptionsAsync(string? userId)
{
var options = new SupportOptions
{
CanRate = _settings.Value.EnableRatingForAllUsers,
CanUseContactForm = false,
CanAccessTelegram = false,
TelegramUrl = _settings.Value.TelegramUrl,
FormspreeUrl = _settings.Value.FormspreeUrl,
UserPlan = "Trial"
};
// Usuário não autenticado ou trial
if (string.IsNullOrEmpty(userId))
{
_logger.LogDebug("Usuário não autenticado - apenas rating disponível");
return options;
}
try
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
_logger.LogWarning("Usuário {UserId} não encontrado", userId);
return options;
}
var planName = user.CurrentPlan?.ToLower() ?? "trial";
options.UserPlan = planName;
_logger.LogDebug("Verificando opções de suporte para usuário {UserId} com plano {Plan}", userId, planName);
// Trial: apenas rating
if (planName == "trial")
{
_logger.LogDebug("Plano Trial - apenas rating disponível");
return options;
}
// Básico: rating + formulário
if (planName == "basic" || planName == "básico")
{
options.CanUseContactForm = true;
options.UserPlan = "Básico";
_logger.LogDebug("Plano Básico - rating + formulário disponíveis");
return options;
}
// Profissional: rating + formulário (sem telegram)
if (planName == "professional" || planName == "profissional")
{
options.CanUseContactForm = true;
options.UserPlan = "Profissional";
_logger.LogDebug("Plano Profissional - rating + formulário disponíveis");
return options;
}
// Premium e PremiumAffiliate: tudo
if (planName == "premium" || planName == "premiumaffiliate" || planName == "premium+afiliados")
{
options.CanUseContactForm = true;
options.CanAccessTelegram = true;
options.UserPlan = planName.Contains("affiliate") || planName.Contains("afiliados") ? "Premium+Afiliados" : "Premium";
_logger.LogDebug("Plano {Plan} - todas as opções disponíveis", planName);
return options;
}
return options;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao verificar opções de suporte para usuário {UserId}", userId);
return options;
}
}
}

View File

@ -0,0 +1,36 @@
using BCards.Web.Areas.Support.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Areas.Support.ViewComponents;
public class SupportFabViewComponent : ViewComponent
{
private readonly ISupportService _supportService;
private readonly ILogger<SupportFabViewComponent> _logger;
public SupportFabViewComponent(ISupportService supportService, ILogger<SupportFabViewComponent> logger)
{
_supportService = supportService;
_logger = logger;
}
public async Task<IViewComponentResult> InvokeAsync()
{
try
{
var userId = UserClaimsPrincipal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var options = await _supportService.GetAvailableOptionsAsync(userId);
_logger.LogDebug("SupportFab invocado para usuário {UserId} - Opções: Rating={CanRate}, Form={CanUseContactForm}, Telegram={CanAccessTelegram}",
userId ?? "anônimo", options.CanRate, options.CanUseContactForm, options.CanAccessTelegram);
return View(options);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao carregar SupportFab ViewComponent");
return Content(string.Empty);
}
}
}

View File

@ -0,0 +1,134 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
<!-- Support FAB (Floating Action Button) -->
<div class="support-fab-container">
<button class="support-fab-trigger" id="supportFabTrigger" aria-label="Precisa de Ajuda?">
<i class="fas fa-question-circle"></i>
</button>
<div class="support-fab-menu" id="supportFabMenu" style="display: none;">
<div class="support-fab-header">
<h6>Precisa de Ajuda?</h6>
<p class="small text-muted mb-0">Plano: @Model.UserPlan</p>
</div>
@if (Model.CanAccessTelegram)
{
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="support-fab-option support-fab-telegram">
<div class="support-fab-option-icon">
<i class="fab fa-telegram"></i>
</div>
<div class="support-fab-option-content">
<strong>Falar no Telegram</strong>
<small>Suporte prioritário</small>
</div>
</a>
}
@if (Model.CanUseContactForm)
{
<a href="@Url.Action("ContactForm", "Support", new { area = "Support" })" class="support-fab-option support-fab-form">
<div class="support-fab-option-icon">
<i class="fas fa-envelope"></i>
</div>
<div class="support-fab-option-content">
<strong>Enviar Mensagem</strong>
<small>Formulário de contato</small>
</div>
</a>
}
@if (Model.CanRate)
{
<button type="button" class="support-fab-option support-fab-rating" data-bs-toggle="modal" data-bs-target="#ratingModal">
<div class="support-fab-option-icon">
<i class="fas fa-star"></i>
</div>
<div class="support-fab-option-content">
<strong>Avaliar Serviço</strong>
<small>Conte sua experiência</small>
</div>
</button>
}
@if (!Model.CanUseContactForm && !Model.CanAccessTelegram)
{
<div class="support-fab-upgrade">
<p class="small mb-2">
<i class="fas fa-lock text-warning"></i>
Faça upgrade para acessar mais opções de suporte!
</p>
<a href="@Url.Action("Index", "Payment", new { area = "" })" class="btn btn-sm btn-primary">Ver Planos</a>
</div>
}
</div>
</div>
<!-- Rating Modal -->
<div class="modal fade" id="ratingModal" tabindex="-1" aria-labelledby="ratingModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ratingModalLabel">Avalie nossa plataforma</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<form id="ratingForm">
<div class="mb-3">
<label class="form-label">Sua avaliação: <span class="text-danger">*</span></label>
<div class="star-rating" id="starRating">
<i class="far fa-star" data-rating="1"></i>
<i class="far fa-star" data-rating="2"></i>
<i class="far fa-star" data-rating="3"></i>
<i class="far fa-star" data-rating="4"></i>
<i class="far fa-star" data-rating="5"></i>
</div>
<input type="hidden" id="ratingValue" name="ratingValue" required>
<div class="invalid-feedback d-block" id="ratingError" style="display: none !important;">
Por favor, selecione uma avaliação
</div>
</div>
<div class="mb-3">
<label for="ratingName" class="form-label">Nome (opcional)</label>
<input type="text" class="form-control" id="ratingName" name="name" maxlength="100" placeholder="Seu nome">
</div>
<div class="mb-3">
<label for="ratingEmail" class="form-label">Email (opcional)</label>
<input type="email" class="form-control" id="ratingEmail" name="email" placeholder="seu@email.com">
</div>
<div class="mb-3">
<label for="ratingComment" class="form-label">Comentário (opcional)</label>
<textarea class="form-control" id="ratingComment" name="comment" rows="3" maxlength="500" placeholder="Conte-nos sobre sua experiência..."></textarea>
<small class="form-text text-muted">
<span id="commentCounter">0</span>/500 caracteres
</small>
</div>
<div class="alert alert-success d-none" id="ratingSuccessAlert" role="alert">
<i class="fas fa-check-circle"></i> Avaliação enviada com sucesso! Obrigado pelo feedback.
</div>
<div class="alert alert-danger d-none" id="ratingErrorAlert" role="alert">
<i class="fas fa-exclamation-triangle"></i> <span id="ratingErrorMessage">Erro ao enviar avaliação. Tente novamente.</span>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary" id="submitRatingBtn">
<i class="fas fa-paper-plane"></i> Enviar Avaliação
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<link rel="stylesheet" href="~/css/support-fab.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/rating.css" asp-append-version="true" />
<script src="~/js/support-fab.js" asp-append-version="true"></script>
<script src="~/js/rating.js" asp-append-version="true"></script>
}

View File

@ -0,0 +1,155 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
@{
ViewData["Title"] = "Formulário de Contato";
Layout = "_Layout";
}
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-body p-4">
<h2 class="card-title mb-4">
<i class="fas fa-envelope text-primary"></i> Fale Conosco
</h2>
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle"></i>
<strong>Tempo de resposta:</strong> Normalmente respondemos em até 24-48 horas.
</div>
<form action="@Model.FormspreeUrl" method="POST">
<div class="mb-3">
<label for="name" class="form-label">
Nome <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="name" name="name" required maxlength="100">
</div>
<div class="mb-3">
<label for="email" class="form-label">
Email <span class="text-danger">*</span>
</label>
<input type="email" class="form-control" id="email" name="email" required>
<small class="form-text text-muted">Usaremos este email para responder sua mensagem.</small>
</div>
<div class="mb-3">
<label for="subject" class="form-label">
Assunto <span class="text-danger">*</span>
</label>
<select class="form-select" id="subject" name="subject" required>
<option value="">Selecione um assunto</option>
<option value="Suporte Técnico">Suporte Técnico</option>
<option value="Dúvida sobre Planos">Dúvida sobre Planos</option>
<option value="Problema de Pagamento">Problema de Pagamento</option>
<option value="Sugestão de Melhoria">Sugestão de Melhoria</option>
<option value="Reportar Bug">Reportar Bug</option>
<option value="Outro">Outro</option>
</select>
</div>
@if (Model.CanAccessTelegram)
{
<div class="mb-3">
<label for="preferredContact" class="form-label">
Canal de contato preferido
</label>
<select class="form-select" id="preferredContact" name="preferredContact">
<option value="email" selected>Email</option>
<option value="telegram">Telegram</option>
</select>
</div>
}
else
{
<input type="hidden" name="preferredContact" value="email">
}
<div class="mb-3">
<label for="message" class="form-label">
Mensagem <span class="text-danger">*</span>
</label>
<textarea class="form-control" id="message" name="message" rows="6" required maxlength="2000" placeholder="Descreva sua dúvida ou problema em detalhes..."></textarea>
<small class="form-text text-muted">
<span id="messageCounter">0</span>/2000 caracteres
</small>
</div>
<input type="hidden" name="_language" value="pt-BR">
<input type="hidden" name="_subject" value="Novo contato BCards">
<input type="hidden" name="userPlan" value="@Model.UserPlan">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-paper-plane"></i> Enviar Mensagem
</button>
</div>
</form>
@if (Model.CanAccessTelegram)
{
<hr class="my-4">
<div class="text-center">
<p class="text-muted mb-3">Ou se preferir, entre em contato direto via Telegram:</p>
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="btn btn-outline-primary">
<i class="fab fa-telegram"></i> Abrir Telegram
</a>
</div>
}
</div>
</div>
<div class="mt-4">
<h4 class="mb-3">Perguntas Frequentes</h4>
<div class="accordion" id="faqAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq1">
Como faço upgrade do meu plano?
</button>
</h2>
<div id="faq1" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Você pode fazer upgrade a qualquer momento através da página de <a href="@Url.Action("Index", "Payment", new { area = "" })">Planos e Preços</a>.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq2">
Posso cancelar minha assinatura a qualquer momento?
</button>
</h2>
<div id="faq2" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Sim! Você pode cancelar sua assinatura a qualquer momento sem multas ou taxas adicionais.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq3">
Qual o tempo de resposta do suporte?
</button>
</h2>
<div id="faq3" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Respondemos em até 24-48h para planos Básico. Usuários Premium têm suporte prioritário com resposta em até 12h.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
// Character counter for message
document.getElementById('message').addEventListener('input', function() {
document.getElementById('messageCounter').textContent = this.value.length;
});
</script>
}

View File

@ -0,0 +1,90 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
@{
ViewData["Title"] = "Central de Suporte";
Layout = "_Layout";
}
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-lg-10">
<h1 class="mb-4">
<i class="fas fa-headset text-primary"></i> Central de Suporte
</h1>
<div class="alert alert-info">
<strong>Plano Atual:</strong> @Model.UserPlan
</div>
<div class="row g-4 mt-3">
@if (Model.CanAccessTelegram)
{
<div class="col-md-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="display-4 text-primary mb-3">
<i class="fab fa-telegram"></i>
</div>
<h5 class="card-title">Telegram</h5>
<p class="card-text">Fale conosco diretamente pelo Telegram para suporte prioritário.</p>
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
Abrir Telegram
</a>
</div>
</div>
</div>
}
@if (Model.CanUseContactForm)
{
<div class="col-md-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="display-4 text-success mb-3">
<i class="fas fa-envelope"></i>
</div>
<h5 class="card-title">Formulário de Contato</h5>
<p class="card-text">Envie sua dúvida ou problema através do nosso formulário.</p>
<a href="@Url.Action("ContactForm", "Support", new { area = "Support" })" class="btn btn-success">
Enviar Mensagem
</a>
</div>
</div>
</div>
}
@if (Model.CanRate)
{
<div class="col-md-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="display-4 text-warning mb-3">
<i class="fas fa-star"></i>
</div>
<h5 class="card-title">Avalie-nos</h5>
<p class="card-text">Conte-nos sobre sua experiência com a plataforma.</p>
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#ratingModal">
Avaliar Agora
</button>
</div>
</div>
</div>
}
</div>
@if (!Model.CanUseContactForm && !Model.CanAccessTelegram)
{
<div class="alert alert-warning mt-4">
<h5><i class="fas fa-lock"></i> Desbloqueie Mais Recursos de Suporte!</h5>
<p class="mb-2">Seu plano atual tem acesso limitado. Faça upgrade para:</p>
<ul>
<li><strong>Plano Básico:</strong> Formulário de contato + Avaliações</li>
<li><strong>Plano Premium:</strong> Telegram + Formulário + Suporte Prioritário</li>
</ul>
<a href="@Url.Action("Index", "Payment", new { area = "" })" class="btn btn-primary mt-2">
Ver Planos e Preços
</a>
</div>
}
</div>
</div>
</div>

View File

@ -0,0 +1,10 @@
namespace BCards.Web.Configuration;
public class SupportSettings
{
public string TelegramUrl { get; set; } = string.Empty;
public string FormspreeUrl { get; set; } = string.Empty;
public List<string> EnableTelegramForPlans { get; set; } = new();
public List<string> EnableFormForPlans { get; set; } = new();
public bool EnableRatingForAllUsers { get; set; } = true;
}

View File

@ -484,6 +484,14 @@ builder.Services.AddScoped<IDowngradeService, DowngradeService>();
builder.Services.AddScoped<IImageStorageService, GridFSImageStorage>();
// Support Area - Rating and Contact System
builder.Services.Configure<BCards.Web.Configuration.SupportSettings>(
builder.Configuration.GetSection("Support"));
builder.Services.AddScoped<BCards.Web.Areas.Support.Repositories.IRatingRepository, BCards.Web.Areas.Support.Repositories.RatingRepository>();
builder.Services.AddScoped<BCards.Web.Areas.Support.Services.IRatingService, BCards.Web.Areas.Support.Services.RatingService>();
builder.Services.AddScoped<BCards.Web.Areas.Support.Services.ISupportService, BCards.Web.Areas.Support.Services.SupportService>();
// Configure upload limits for file handling (images up to 5MB)
builder.Services.Configure<FormOptions>(options =>
{
@ -721,6 +729,12 @@ if (app.Environment.IsDevelopment())
app.UseResponseCaching();
// Support Area Routes
app.MapAreaControllerRoute(
name: "support-area",
areaName: "Support",
pattern: "Support/{controller=Support}/{action=Index}/{id?}");
app.MapControllerRoute(
name: "userpage-preview-path",
pattern: "page/preview/{category}/{slug}",

View File

@ -0,0 +1,129 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
<!-- Support FAB (Floating Action Button) -->
<div class="support-fab-container">
<button class="support-fab-trigger" id="supportFabTrigger" aria-label="Precisa de Ajuda?">
<i class="fas fa-question-circle"></i>
</button>
<div class="support-fab-menu" id="supportFabMenu" style="display: none;">
<div class="support-fab-header">
<h6>Precisa de Ajuda?</h6>
<p class="small text-muted mb-0">Plano: @Model.UserPlan</p>
</div>
@if (Model.CanAccessTelegram)
{
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="support-fab-option support-fab-telegram">
<div class="support-fab-option-icon">
<i class="fab fa-telegram"></i>
</div>
<div class="support-fab-option-content">
<strong>Falar no Telegram</strong>
<small>Suporte prioritário</small>
</div>
</a>
}
@if (Model.CanUseContactForm)
{
<a href="@Url.Action("ContactForm", "Support", new { area = "Support" })" class="support-fab-option support-fab-form">
<div class="support-fab-option-icon">
<i class="fas fa-envelope"></i>
</div>
<div class="support-fab-option-content">
<strong>Enviar Mensagem</strong>
<small>Formulário de contato</small>
</div>
</a>
}
@if (Model.CanRate)
{
<button type="button" class="support-fab-option support-fab-rating" data-bs-toggle="modal" data-bs-target="#ratingModal">
<div class="support-fab-option-icon">
<i class="fas fa-star"></i>
</div>
<div class="support-fab-option-content">
<strong>Avaliar Serviço</strong>
<small>Conte sua experiência</small>
</div>
</button>
}
@if (!Model.CanUseContactForm && !Model.CanAccessTelegram)
{
<div class="support-fab-upgrade">
<p class="small mb-2">
<i class="fas fa-lock text-warning"></i>
Faça upgrade para acessar mais opções de suporte!
</p>
<a href="@Url.Action("Index", "Payment", new { area = "" })" class="btn btn-sm btn-primary">Ver Planos</a>
</div>
}
</div>
</div>
<!-- Rating Modal -->
<div class="modal fade" id="ratingModal" tabindex="-1" aria-labelledby="ratingModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ratingModalLabel">Avalie nossa plataforma</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<form id="ratingForm">
<div class="mb-3">
<label class="form-label">Sua avaliação: <span class="text-danger">*</span></label>
<div class="star-rating" id="starRating">
<i class="far fa-star" data-rating="1"></i>
<i class="far fa-star" data-rating="2"></i>
<i class="far fa-star" data-rating="3"></i>
<i class="far fa-star" data-rating="4"></i>
<i class="far fa-star" data-rating="5"></i>
</div>
<input type="hidden" id="ratingValue" name="ratingValue" required>
<div class="invalid-feedback d-block" id="ratingError" style="display: none !important;">
Por favor, selecione uma avaliação
</div>
</div>
<div class="mb-3">
<label for="ratingName" class="form-label">Nome (opcional)</label>
<input type="text" class="form-control" id="ratingName" name="name" maxlength="100" placeholder="Seu nome">
</div>
<div class="mb-3">
<label for="ratingEmail" class="form-label">Email (opcional)</label>
<input type="email" class="form-control" id="ratingEmail" name="email" placeholder="seu@email.com">
</div>
<div class="mb-3">
<label for="ratingComment" class="form-label">Comentário (opcional)</label>
<textarea class="form-control" id="ratingComment" name="comment" rows="3" maxlength="500" placeholder="Conte-nos sobre sua experiência..."></textarea>
<small class="form-text text-muted">
<span id="commentCounter">0</span>/500 caracteres
</small>
</div>
<div class="alert alert-success d-none" id="ratingSuccessAlert" role="alert">
<i class="fas fa-check-circle"></i> Avaliação enviada com sucesso! Obrigado pelo feedback.
</div>
<div class="alert alert-danger d-none" id="ratingErrorAlert" role="alert">
<i class="fas fa-exclamation-triangle"></i> <span id="ratingErrorMessage">Erro ao enviar avaliação. Tente novamente.</span>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary" id="submitRatingBtn">
<i class="fas fa-paper-plane"></i> Enviar Avaliação
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- CSS e JS serão carregados via _Layout.cshtml -->

View File

@ -36,6 +36,8 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/support-fab.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/rating.css" asp-append-version="true" />
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
@await RenderSectionAsync("Styles", required: false)
@ -207,6 +209,9 @@
</div>
</div>
<!-- Support FAB (Floating Action Button) -->
@await Component.InvokeAsync("SupportFab")
</main>
</div>
@ -253,6 +258,8 @@
<script src="~/js/site.js" asp-append-version="true"></script>
<script src="~/js/cookie-consent.js" asp-append-version="true"></script>
<script src="~/js/email-handler.js" asp-append-version="true"></script>
<script src="~/js/support-fab.js" asp-append-version="true"></script>
<script src="~/js/rating.js" asp-append-version="true"></script>
<!-- Scripts para menu ativo e barra de carregamento -->
<script>

View File

@ -153,5 +153,12 @@
"FromEmail": "ricardo.carneiro@jobmaker.com.br",
"FromName": "Ricardo Carneiro"
},
"BaseUrl": "https://bcards.site"
"BaseUrl": "https://bcards.site",
"Support": {
"TelegramUrl": "https://t.me/jobmakerbr",
"FormspreeUrl": "https://formspree.io/f/xpwynqpj",
"EnableTelegramForPlans": [ "Premium", "PremiumAffiliate" ],
"EnableFormForPlans": [ "Basic", "Professional", "Premium", "PremiumAffiliate" ],
"EnableRatingForAllUsers": true
}
}

View File

@ -0,0 +1,190 @@
/* Rating System Styles for BCards */
/* Star Rating Component */
.star-rating {
display: inline-flex;
gap: 0.5rem;
font-size: 2.5rem;
user-select: none;
cursor: pointer;
}
.star-rating i {
cursor: pointer;
transition: all 0.2s ease;
color: #e0e0e0;
}
.star-rating i:hover {
transform: scale(1.2);
}
.star-rating i.fas {
color: #ffc107;
}
.star-rating i.text-warning {
color: #ffc107 !important;
}
/* Rating Modal Customizations */
#ratingModal .modal-content {
border-radius: 1rem;
border: none;
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.2);
}
#ratingModal .modal-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 1rem 1rem 0 0;
padding: 1.5rem;
border-bottom: none;
}
#ratingModal .modal-title {
font-weight: 600;
font-size: 1.25rem;
}
#ratingModal .btn-close {
filter: brightness(0) invert(1);
opacity: 0.8;
}
#ratingModal .btn-close:hover {
opacity: 1;
}
#ratingModal .modal-body {
padding: 2rem;
}
/* Form Styling */
#ratingForm .form-label {
font-weight: 500;
color: #495057;
margin-bottom: 0.5rem;
}
#ratingForm .form-control {
border-radius: 0.5rem;
border: 1px solid #dee2e6;
padding: 0.75rem 1rem;
transition: all 0.2s ease;
}
#ratingForm .form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
outline: none;
}
#ratingForm textarea.form-control {
resize: vertical;
min-height: 100px;
}
/* Submit Button */
#submitRatingBtn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 0.5rem;
padding: 0.875rem 1.5rem;
font-weight: 600;
transition: all 0.3s ease;
color: white;
}
#submitRatingBtn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(102, 126, 234, 0.3);
background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
}
#submitRatingBtn:active:not(:disabled) {
transform: translateY(0);
}
#submitRatingBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Alert Messages */
#ratingSuccessAlert,
#ratingErrorAlert {
border-radius: 0.75rem;
padding: 1rem 1.25rem;
animation: slideIn 0.3s ease;
}
#ratingSuccessAlert {
background-color: #d4edda;
border-color: #c3e6cb;
color: #155724;
}
#ratingErrorAlert {
background-color: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Character Counter */
.form-text {
font-size: 0.875rem;
}
#commentCounter {
font-weight: 600;
color: #667eea;
}
/* Invalid Feedback */
.invalid-feedback {
display: none;
margin-top: 0.25rem;
font-size: 0.875rem;
color: #dc3545;
}
.invalid-feedback.d-block {
display: block !important;
}
/* Responsive Adjustments */
@media (max-width: 576px) {
.star-rating {
font-size: 2rem;
gap: 0.25rem;
}
#ratingModal .modal-body {
padding: 1.5rem;
}
#ratingModal .modal-header {
padding: 1rem;
}
#ratingModal .modal-title {
font-size: 1.1rem;
}
#submitRatingBtn {
padding: 0.75rem 1.25rem;
font-size: 0.9rem;
}
}

View File

@ -0,0 +1,186 @@
/* Support FAB (Floating Action Button) Styles for BCards */
.support-fab-container {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 1050;
}
.support-fab-trigger {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
font-size: 1.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.support-fab-trigger:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.support-fab-trigger:active {
transform: scale(0.95);
}
.support-fab-menu {
position: absolute;
bottom: 70px;
right: 0;
min-width: 280px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
padding: 1rem;
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.support-fab-header {
padding-bottom: 0.75rem;
border-bottom: 1px solid #e9ecef;
margin-bottom: 0.75rem;
}
.support-fab-header h6 {
margin: 0;
font-weight: 600;
color: #212529;
}
.support-fab-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
text-decoration: none;
color: #212529;
transition: all 0.2s ease;
border: none;
background: none;
width: 100%;
text-align: left;
cursor: pointer;
}
.support-fab-option:hover {
background: #f8f9fa;
transform: translateX(4px);
}
.support-fab-option-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
flex-shrink: 0;
}
.support-fab-telegram .support-fab-option-icon {
background: linear-gradient(135deg, #0088cc 0%, #005580 100%);
color: white;
}
.support-fab-form .support-fab-option-icon {
background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
color: white;
}
.support-fab-rating .support-fab-option-icon {
background: linear-gradient(135deg, #ffc107 0%, #ff9800 100%);
color: white;
}
.support-fab-option-content {
flex: 1;
}
.support-fab-option-content strong {
display: block;
font-weight: 600;
font-size: 0.9rem;
}
.support-fab-option-content small {
display: block;
color: #6c757d;
font-size: 0.75rem;
}
.support-fab-upgrade {
padding: 0.75rem;
background: #fff3cd;
border-radius: 8px;
text-align: center;
margin-top: 0.5rem;
}
.support-fab-upgrade p {
color: #856404;
font-size: 0.85rem;
}
.support-fab-upgrade .btn-sm {
font-size: 0.8rem;
padding: 0.375rem 0.75rem;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.support-fab-container {
bottom: 1.5rem;
right: 1.5rem;
}
.support-fab-trigger {
width: 56px;
height: 56px;
font-size: 1.3rem;
}
.support-fab-menu {
min-width: 260px;
right: 0;
}
}
@media (max-width: 576px) {
.support-fab-container {
bottom: 1rem;
right: 1rem;
}
.support-fab-trigger {
width: 52px;
height: 52px;
font-size: 1.2rem;
}
.support-fab-menu {
min-width: calc(100vw - 3rem);
max-width: 280px;
}
}

View File

@ -0,0 +1,212 @@
// Rating System for BCards
(function() {
'use strict';
let selectedRating = 0;
// Initialize
function init() {
setupEventListeners();
}
function setupEventListeners() {
// Star rating clicks
const stars = document.querySelectorAll('#starRating i');
stars.forEach(star => {
star.addEventListener('click', handleStarClick);
star.addEventListener('mouseover', handleStarHover);
});
// Star rating container mouse leave
const starContainer = document.getElementById('starRating');
if (starContainer) {
starContainer.addEventListener('mouseleave', resetStarHover);
}
// Form submission
const ratingForm = document.getElementById('ratingForm');
if (ratingForm) {
ratingForm.addEventListener('submit', handleFormSubmit);
}
// Modal reset on close
const ratingModal = document.getElementById('ratingModal');
if (ratingModal) {
ratingModal.addEventListener('hidden.bs.modal', resetForm);
}
// Character counter for comment
const commentField = document.getElementById('ratingComment');
if (commentField) {
commentField.addEventListener('input', updateCharacterCounter);
}
}
function handleStarClick(e) {
const starValue = parseInt(e.currentTarget.getAttribute('data-rating'));
selectedRating = starValue;
document.getElementById('ratingValue').value = starValue;
updateStars(starValue, true);
// Hide error if was showing
const errorDiv = document.getElementById('ratingError');
if (errorDiv) {
errorDiv.style.display = 'none';
}
}
function handleStarHover(e) {
const starValue = parseInt(e.currentTarget.getAttribute('data-rating'));
updateStars(starValue, false);
}
function resetStarHover() {
updateStars(selectedRating, true);
}
function updateStars(rating, permanent) {
const stars = document.querySelectorAll('#starRating i');
stars.forEach(star => {
const starValue = parseInt(star.getAttribute('data-rating'));
if (starValue <= rating) {
star.classList.remove('far');
star.classList.add('fas', 'text-warning');
} else {
star.classList.remove('fas', 'text-warning');
star.classList.add('far');
}
});
}
function updateCharacterCounter() {
const commentField = document.getElementById('ratingComment');
const counter = document.getElementById('commentCounter');
if (commentField && counter) {
counter.textContent = commentField.value.length;
}
}
async function handleFormSubmit(e) {
e.preventDefault();
// Validate rating
if (selectedRating === 0) {
const errorDiv = document.getElementById('ratingError');
if (errorDiv) {
errorDiv.style.display = 'block';
}
return;
}
// Get form data
const formData = {
ratingValue: selectedRating,
name: document.getElementById('ratingName').value.trim(),
email: document.getElementById('ratingEmail').value.trim(),
comment: document.getElementById('ratingComment').value.trim()
};
// Get submit button
const submitButton = document.getElementById('submitRatingBtn');
const originalButtonHtml = submitButton.innerHTML;
// Disable submit button
submitButton.disabled = true;
submitButton.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Enviando...';
// Hide previous alerts
document.getElementById('ratingSuccessAlert').classList.add('d-none');
document.getElementById('ratingErrorAlert').classList.add('d-none');
try {
// Send to backend
const response = await fetch('/api/ratings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (response.ok) {
// Show success message
document.getElementById('ratingSuccessAlert').classList.remove('d-none');
// Hide form temporarily
document.getElementById('ratingForm').querySelectorAll('.mb-3').forEach(el => {
el.style.display = 'none';
});
submitButton.style.display = 'none';
// Close modal after 3 seconds
setTimeout(() => {
const modal = bootstrap.Modal.getInstance(document.getElementById('ratingModal'));
if (modal) {
modal.hide();
}
}, 3000);
} else {
throw new Error(data.message || 'Failed to submit rating');
}
} catch (error) {
console.error('Error submitting rating:', error);
// Show error message
const errorAlert = document.getElementById('ratingErrorAlert');
const errorMessage = document.getElementById('ratingErrorMessage');
errorMessage.textContent = error.message || 'Erro ao enviar avaliação. Por favor, tente novamente.';
errorAlert.classList.remove('d-none');
// Re-enable submit button
submitButton.disabled = false;
submitButton.innerHTML = originalButtonHtml;
}
}
function resetForm() {
// Reset stars
selectedRating = 0;
document.getElementById('ratingValue').value = '';
updateStars(0, true);
// Reset form fields
const ratingForm = document.getElementById('ratingForm');
if (ratingForm) {
ratingForm.reset();
}
// Show all form elements
document.getElementById('ratingForm').querySelectorAll('.mb-3').forEach(el => {
el.style.display = 'block';
});
// Hide alerts
document.getElementById('ratingSuccessAlert').classList.add('d-none');
document.getElementById('ratingErrorAlert').classList.add('d-none');
const errorDiv = document.getElementById('ratingError');
if (errorDiv) {
errorDiv.style.display = 'none';
}
// Re-enable submit button
const submitButton = document.getElementById('submitRatingBtn');
submitButton.disabled = false;
submitButton.innerHTML = '<i class="fas fa-paper-plane"></i> Enviar Avaliação';
submitButton.style.display = 'block';
// Reset character counter
const counter = document.getElementById('commentCounter');
if (counter) {
counter.textContent = '0';
}
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -0,0 +1,56 @@
// Support FAB (Floating Action Button) for BCards
(function () {
'use strict';
function initSupportFab() {
const fabTrigger = document.getElementById('supportFabTrigger');
const fabMenu = document.getElementById('supportFabMenu');
if (!fabTrigger || !fabMenu) {
return;
}
// Toggle menu on trigger click
fabTrigger.addEventListener('click', function () {
if (fabMenu.style.display === 'none' || fabMenu.style.display === '') {
fabMenu.style.display = 'block';
fabTrigger.setAttribute('aria-expanded', 'true');
} else {
fabMenu.style.display = 'none';
fabTrigger.setAttribute('aria-expanded', 'false');
}
});
// Close menu when clicking outside
document.addEventListener('click', function (event) {
if (!fabTrigger.contains(event.target) && !fabMenu.contains(event.target)) {
fabMenu.style.display = 'none';
fabTrigger.setAttribute('aria-expanded', 'false');
}
});
// Close menu on ESC key
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && fabMenu.style.display === 'block') {
fabMenu.style.display = 'none';
fabTrigger.setAttribute('aria-expanded', 'false');
}
});
// Close menu when clicking menu options
const menuOptions = fabMenu.querySelectorAll('.support-fab-option');
menuOptions.forEach(function (option) {
option.addEventListener('click', function () {
fabMenu.style.display = 'none';
fabTrigger.setAttribute('aria-expanded', 'false');
});
});
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSupportFab);
} else {
initSupportFab();
}
})();