feat/live-preview #1

Merged
ricardo merged 16 commits from feat/live-preview into Release/V0.0.3 2025-07-23 02:24:34 +00:00
11 changed files with 699 additions and 53 deletions
Showing only changes of commit 06d2c110d0 - Show all commits

228
CLAUDE.md Normal file
View File

@ -0,0 +1,228 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
BCards is a professional LinkTree clone built in ASP.NET Core MVC, targeting Brazilian and Spanish markets. It provides hierarchical URLs organized by business categories (/page/{category}/{slug}), integrated Stripe payments, and a sophisticated moderation system with preview tokens.
## Development Commands
### Build & Run
```bash
# Restore dependencies
dotnet restore
# Build solution
dotnet build
# Run development server
cd src/BCards.Web
dotnet run
# Access: https://localhost:49178
# Run with Docker
docker-compose up -d
# Access: http://localhost:8080
```
### Testing
```bash
# Run all tests
dotnet test
# Run tests with coverage
dotnet test --collect:"XPlat Code Coverage"
# Run specific test
dotnet test --filter "TestClassName"
```
## Architecture Overview
### Technology Stack
- **Framework**: ASP.NET Core MVC (.NET 8)
- **Database**: MongoDB 7.0 with MongoDB.Driver 2.25.0
- **Authentication**: OAuth 2.0 (Google + Microsoft)
- **Payments**: Stripe.NET 44.7.0
- **Frontend**: Bootstrap 5.3.2, jQuery 3.7.1, vanilla JavaScript
- **Email**: SendGrid 9.29.3
- **Containerization**: Docker + Docker Compose
### Core Architecture Patterns
- **MVC Pattern**: Controllers handle HTTP requests, Views render UI, Models represent data
- **Repository Pattern**: Data access abstraction via IUserPageRepository, ICategoryRepository, etc.
- **Service Layer**: Business logic encapsulation (IUserPageService, IModerationService, etc.)
- **Dependency Injection**: Built-in ASP.NET Core DI container
- **Domain-Driven Design**: Rich domain models with business logic
### Key Business Logic
#### Page Status System
Pages follow this lifecycle with explicit numeric enum values:
- `Creating = 6`: Development phase, requires preview tokens
- `PendingModeration = 4`: Submitted for approval, requires preview tokens
- `Rejected = 5`: Failed moderation, requires preview tokens
- `Active = 0`: Live and publicly accessible
- `Inactive = 3`: Paused by user
- `Expired = 1`: Trial expired, redirects to pricing
- `PendingPayment = 2`: Payment overdue, shows warning
#### Moderation System
- Content approval workflow with attempts tracking
- Preview tokens with 4-hour expiration for non-Active pages
- Email notifications for status changes
- Automatic status transitions based on user actions
#### Pricing Strategy
Three-tier system with psychological pricing (decoy effect):
- Basic (R$ 9.90/mês): 5 links, basic themes
- Professional (R$ 24.90/mês): 15 links, all themes - *DECOY*
- Premium (R$ 29.90/mês): Unlimited links, custom themes
### Project Structure
```
src/BCards.Web/
├── Controllers/ # MVC Controllers (9 total)
│ ├── AdminController # User dashboard and page management
│ ├── AuthController # OAuth authentication
│ ├── HomeController # Public pages and landing
│ ├── PaymentController # Stripe integration
│ ├── ModerationController # Content approval system
│ └── UserPageController # Public page display
├── Models/ # Domain entities (12 total)
│ ├── User # Authentication and subscriptions
│ ├── UserPage # Main business card entity
│ ├── LinkItem # Individual links with analytics
│ └── Category # Business categories
├── Services/ # Business logic (20 services)
│ ├── IUserPageService # Core page operations
│ ├── IModerationService # Content approval
│ ├── IAuthService # Authentication
│ └── IPaymentService # Stripe integration
├── Repositories/ # Data access (8 repositories)
├── ViewModels/ # View-specific models
├── Middleware/ # Custom middleware (4 pieces)
│ ├── PageStatusMiddleware # Handles page access by status
│ ├── ModerationAuthMiddleware # Admin access control
│ └── PreviewTokenMiddleware # Preview token validation
└── Views/ # Razor templates with Bootstrap 5
```
### Database Design (MongoDB)
#### Core Collections
- `users`: Authentication, subscription status, Stripe customer data
- `userpages`: Business cards with status, links, themes, moderation history
- `categories`: Business categories with SEO metadata
- `subscriptions`: Stripe subscription tracking
#### Important Indexes
- Compound: `{category: 1, slug: 1}` for page lookups
- User pages: `{userId: 1, status: 1}` for dashboard filtering
- Active pages: `{status: 1, category: 1}` for public listings
### Key Features Implementation
#### Preview Token System
Non-Active pages require preview tokens for access:
```csharp
// Generate fresh token (4-hour expiry)
POST /Admin/GeneratePreviewToken/{id}
// Access page with token
GET /page/{category}/{slug}?preview={token}
```
#### OAuth Integration
Supports Google and Microsoft OAuth with automatic user creation and session management.
#### Stripe Payment Flow
Complete subscription lifecycle:
1. Checkout session creation
2. Webhook handling for events
3. Subscription status updates
4. Plan limitation enforcement
#### Dynamic Theming
CSS generation system with customizable colors, backgrounds, and layouts based on user's plan limitations.
## Configuration
### Required Environment Variables
```json
{
"MongoDb": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "BCardsDB"
},
"Stripe": {
"PublishableKey": "pk_test_...",
"SecretKey": "sk_test_...",
"WebhookSecret": "whsec_..."
},
"Authentication": {
"Google": { "ClientId": "...", "ClientSecret": "..." },
"Microsoft": { "ClientId": "...", "ClientSecret": "..." }
}
}
```
### Development Setup
1. Install .NET 8 SDK, MongoDB 4.4+
2. Configure OAuth credentials (Google Cloud Console, Azure Portal)
3. Set up Stripe account with test keys
4. Configure webhook endpoints for `/webhook/stripe`
## Important Implementation Notes
### Page Status Middleware
`PageStatusMiddleware` intercepts all `/page/{category}/{slug}` requests and enforces access rules based on page status. Non-Active pages require valid preview tokens.
### Moderation Workflow
1. Pages start as `Creating` status
2. Users click "Submit for Moderation" → `PendingModeration`
3. Moderators approve/reject → `Active` or `Rejected`
4. Rejected pages can be edited and resubmitted
### Preview Token Security
- Tokens expire after 4 hours
- Generated on-demand via AJAX calls
- Required for Creating, PendingModeration, and Rejected pages
- Validated by middleware before page access
### Plan Limitations
Enforced throughout the application:
- Link count limits per plan
- Theme availability restrictions
- Analytics access control
- Page creation limits
## Common Development Patterns
### Repository Usage
```csharp
var page = await _userPageService.GetPageByIdAsync(id);
await _userPageService.UpdatePageAsync(page);
```
### Service Layer Pattern
Business logic resides in services, not controllers:
```csharp
public class UserPageService : IUserPageService
{
private readonly IUserPageRepository _repository;
// Implementation with business rules
}
```
### Status-Based Logic
Always check page status before operations:
```csharp
if (page.Status == PageStatus.Creating || page.Status == PageStatus.Rejected)
{
// Allow editing
}
```
This architecture supports a production-ready SaaS application with complex business rules, payment integration, and content moderation workflows.

View File

@ -43,6 +43,8 @@ public class AdminController : Controller
[Route("Dashboard")]
public async Task<IActionResult> Dashboard()
{
ViewBag.IsHomePage = false; // Menu normal do dashboard
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
@ -100,6 +102,8 @@ public class AdminController : Controller
[Route("ManagePage")]
public async Task<IActionResult> ManagePage(string id = null)
{
ViewBag.IsHomePage = false;
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
@ -146,6 +150,8 @@ public class AdminController : Controller
[Route("ManagePage")]
public async Task<IActionResult> ManagePage(ManagePageViewModel model)
{
ViewBag.IsHomePage = false;
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
@ -199,29 +205,20 @@ public class AdminController : Controller
var userPage = await MapToUserPage(model, user.Id);
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
// Set status to PendingModeration for new pages
userPage.Status = ViewModels.PageStatus.PendingModeration;
// Set status to Creating for new pages
userPage.Status = ViewModels.PageStatus.Creating;
await _userPageService.CreatePageAsync(userPage);
_logger.LogInformation("Page created successfully!");
// Generate preview token and send for moderation
// Generate preview token for development
var previewToken = await _moderationService.GeneratePreviewTokenAsync(userPage.Id);
var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{userPage.Category}/{userPage.Slug}?preview={previewToken}";
userPage.PreviewToken = previewToken;
userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4);
await _userPageService.UpdatePageAsync(userPage);
// Send email to user
await _emailService.SendModerationStatusAsync(
user.Email,
user.Name,
userPage.DisplayName,
"pending",
null,
previewUrl);
TempData["Success"] = "Página enviada para moderação! Você receberá um email quando for aprovada.";
TempData["Success"] = "Página criada com sucesso! Use o botão 'Enviar para Moderação' quando estiver pronta.";
}
catch (Exception ex)
{
@ -506,6 +503,8 @@ public class AdminController : Controller
[Route("Analytics")]
public async Task<IActionResult> Analytics()
{
ViewBag.IsHomePage = false;
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
@ -753,4 +752,136 @@ public class AdminController : Controller
page.Links.AddRange(socialLinks);
}
[HttpPost]
[Route("SubmitForModeration/{id}")]
public async Task<IActionResult> SubmitForModeration(string id)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return Json(new { success = false, message = "Usuário não autenticado" });
var pageItem = await _userPageService.GetPageByIdAsync(id);
if (pageItem == null || pageItem.UserId != user.Id)
return Json(new { success = false, message = "Página não encontrada" });
// Validar status atual
if (pageItem.Status != ViewModels.PageStatus.Creating && pageItem.Status != ViewModels.PageStatus.Rejected)
return Json(new { success = false, message = "Página não pode ser enviada para moderação neste momento" });
// Validar se tem pelo menos 1 link ativo
var activeLinksCount = pageItem.Links?.Count(l => l.IsActive) ?? 0;
if (activeLinksCount < 1)
return Json(new { success = false, message = "Página deve ter pelo menos 1 link ativo para ser enviada" });
try
{
// Mudar status para PendingModeration
pageItem.Status = ViewModels.PageStatus.PendingModeration;
pageItem.ModerationAttempts++;
pageItem.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(pageItem);
// Enviar email de notificação ao usuário
await _emailService.SendModerationStatusAsync(
user.Email,
user.Name,
pageItem.DisplayName,
"pending",
null,
$"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={pageItem.PreviewToken}");
_logger.LogInformation($"Page {pageItem.Id} submitted for moderation by user {user.Id}");
return Json(new {
success = true,
message = "Página enviada para moderação com sucesso! Você receberá um email quando for processada.",
newStatus = "PendingModeration"
});
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error submitting page {id} for moderation");
return Json(new { success = false, message = "Erro interno. Tente novamente." });
}
}
[HttpPost]
[Route("RefreshPreviewToken/{id}")]
public async Task<IActionResult> RefreshPreviewToken(string id)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return Json(new { success = false, message = "Não autorizado" });
var pageItem = await _userPageService.GetPageByIdAsync(id);
if (pageItem == null || pageItem.UserId != user.Id)
return Json(new { success = false, message = "Página não encontrada" });
// Só renovar token para páginas "Creating"
if (pageItem.Status != ViewModels.PageStatus.Creating)
return Json(new { success = false, message = "Token só pode ser renovado para páginas em desenvolvimento" });
try
{
// Gerar novo token com 4 horas de validade
var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id);
var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={newToken}";
return Json(new {
success = true,
previewToken = newToken,
previewUrl = previewUrl,
expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss")
});
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error refreshing preview token for page {id}");
return Json(new { success = false, message = "Erro ao renovar token" });
}
}
[HttpPost]
[Route("GeneratePreviewToken/{id}")]
public async Task<IActionResult> GeneratePreviewToken(string id)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return Json(new { success = false, message = "Usuário não autenticado" });
var pageItem = await _userPageService.GetPageByIdAsync(id);
if (pageItem == null || pageItem.UserId != user.Id)
return Json(new { success = false, message = "Página não encontrada" });
// Verificar se página pode ter preview
if (pageItem.Status != ViewModels.PageStatus.Creating &&
pageItem.Status != ViewModels.PageStatus.PendingModeration &&
pageItem.Status != ViewModels.PageStatus.Rejected)
{
return Json(new { success = false, message = "Preview não disponível para este status" });
}
try
{
// Gerar novo token com 4 horas de validade
var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id);
_logger.LogInformation($"Preview token generated for page {pageItem.Id} by user {user.Id}");
return Json(new {
success = true,
previewToken = newToken,
message = "Preview gerado com sucesso!",
expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss")
});
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error generating preview token for page {id}");
return Json(new { success = false, message = "Erro interno. Tente novamente." });
}
}
}

View File

@ -16,6 +16,7 @@ public class HomeController : Controller
public async Task<IActionResult> Index()
{
ViewBag.IsHomePage = true; // Flag para identificar home
ViewBag.Categories = await _categoryService.GetAllCategoriesAsync();
ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6);
return View();
@ -24,18 +25,21 @@ public class HomeController : Controller
[Route("Privacy")]
public IActionResult Privacy()
{
ViewBag.IsHomePage = true;
return View();
}
[Route("Pricing")]
public IActionResult Pricing()
{
ViewBag.IsHomePage = true;
return View();
}
[Route("categoria/{categorySlug}")]
public async Task<IActionResult> Category(string categorySlug)
{
ViewBag.IsHomePage = true;
var category = await _categoryService.GetCategoryBySlugAsync(categorySlug);
if (category == null)
return NotFound();

View File

@ -51,9 +51,33 @@ public class PageStatusMiddleware
await context.Response.WriteAsync("Página temporariamente indisponível.");
return;
case PageStatus.Creating:
case PageStatus.PendingModeration:
case PageStatus.Rejected:
// Páginas em desenvolvimento/moderação requerem preview token
var previewToken = context.Request.Query["preview"].FirstOrDefault();
if (string.IsNullOrEmpty(previewToken) ||
string.IsNullOrEmpty(page.PreviewToken) ||
previewToken != page.PreviewToken ||
page.PreviewTokenExpiry < DateTime.UtcNow)
{
_logger.LogInformation($"Page {category}/{slug} requires valid preview token");
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito.");
return;
}
break;
case PageStatus.Active:
// Continuar processamento normal
break;
default:
// Status desconhecido - tratar como inativo
_logger.LogWarning($"Unknown page status: {page.Status} for page {category}/{slug}");
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Página temporariamente indisponível.");
return;
}
}
}

View File

@ -125,5 +125,6 @@ public enum PageStatus
PendingPayment, // Pagamento atrasado -> aviso na página
Inactive, // Pausada pelo usuário
PendingModeration = 4, // Aguardando moderação
Rejected = 5 // Rejeitada na moderação
Rejected = 5, // Rejeitada na moderação
Creating = 6 // Em desenvolvimento/criação
}

View File

@ -23,14 +23,14 @@
<!-- Lista de Páginas -->
<div class="row">
@foreach (var page in Model.UserPages)
@foreach (var pageItem in Model.UserPages)
{
<div class="col-md-6 col-lg-4 mb-3">
<div class="card h-100 @(page.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
<div class="card h-100 @(pageItem.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
<div class="card-body">
<h6 class="card-title">
@(page.DisplayName)
<form method="post" action="/Admin/DeletePage/@(page.Id)" style="display: inline;" onsubmit="return confirm('Tem certeza que deseja excluir esta página?')">
@(pageItem.DisplayName)
<form method="post" action="/Admin/DeletePage/@(pageItem.Id)" style="display: inline;" onsubmit="return confirm('Tem certeza que deseja excluir esta página?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-link text-danger p-0" title="Excluir página"
style="font-size: 12px; text-decoration: none;">
@ -38,16 +38,16 @@
</button>
</form>
</h6>
<p class="text-muted small mb-2">@(page.Category)/@(page.Slug)</p>
<p class="text-muted small mb-2">@(pageItem.Category)/@(pageItem.Slug)</p>
<div class="mb-2">
@{
var pageStatus = page.Status;
if (page.Status == BCards.Web.ViewModels.PageStatus.Inactive)
var pageStatus = pageItem.Status;
if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Inactive)
{
if (page.LastModerationStatus.HasValue)
if (pageItem.LastModerationStatus.HasValue)
{
pageStatus = page.LastModerationStatus.Value;
pageStatus = pageItem.LastModerationStatus.Value;
}
}
}
@ -71,52 +71,102 @@
case BCards.Web.ViewModels.PageStatus.Rejected:
<span class="badge bg-danger">Rejeitada</span>
break;
case BCards.Web.ViewModels.PageStatus.Creating:
<span class="badge bg-info">
<i class="fas fa-edit me-1"></i>Criando
</span>
break;
}
</div>
@if (Model.CurrentPlan.AllowsAnalytics)
{
<div class="row text-center small mb-3">
<div class="col-6">
<div class="text-primary fw-bold">@(page.TotalViews)</div>
<div class="text-primary fw-bold">@(pageItem.TotalViews)</div>
<div class="text-muted">Visualizações</div>
</div>
<div class="col-6">
<div class="text-success fw-bold">@(page.TotalClicks)</div>
<div class="text-success fw-bold">@(pageItem.TotalClicks)</div>
<div class="text-muted">Cliques</div>
</div>
</div>
}
<div class="d-flex gap-1 flex-wrap">
<a href="@Url.Action("ManagePage", new { id = page.Id })"
class="btn btn-sm btn-outline-primary flex-fill">Editar</a>
<a href="@(page.PublicUrl)" target="_blank"
class="btn btn-sm btn-outline-success flex-fill">Ver</a>
<div class="d-flex gap-1 flex-wrap" data-page-id="@pageItem.Id" data-status="@pageItem.Status">
<!-- Botão Editar - sempre presente -->
<a href="@Url.Action("ManagePage", new { id = pageItem.Id })"
class="btn btn-sm btn-outline-primary">
<i class="fas fa-edit me-1"></i>Editar
</a>
<!-- Botões condicionais por status -->
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating ||
pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected)
{
<!-- Preview para páginas em desenvolvimento -->
<button type="button"
class="btn btn-sm btn-outline-info"
onclick="openPreview('@pageItem.Id')"
data-page-category="@pageItem.Category"
data-page-slug="@pageItem.Slug">
<i class="fas fa-eye me-1"></i>Preview
</button>
<!-- Botão Enviar para Moderação -->
<button type="button"
class="btn btn-sm btn-success"
onclick="submitForModeration('@pageItem.Id')"
data-page-name="@pageItem.DisplayName">
<i class="fas fa-paper-plane me-1"></i>Enviar
</button>
}
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
{
<!-- Preview para páginas em moderação -->
<button type="button"
class="btn btn-sm btn-outline-warning"
onclick="openPreview('@pageItem.Id')"
data-page-category="@pageItem.Category"
data-page-slug="@pageItem.Slug">
<i class="fas fa-clock me-1"></i>Preview
</button>
<span class="btn btn-sm btn-outline-secondary disabled">
<i class="fas fa-hourglass-half me-1"></i>Aguardando
</span>
}
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Active)
{
<!-- Ver página ativa -->
<a href="/page/@pageItem.Category/@pageItem.Slug" target="_blank"
class="btn btn-sm btn-outline-success">
<i class="fas fa-external-link-alt me-1"></i>Ver
</a>
}
</div>
</div>
<div class="card-footer bg-transparent">
<small class="text-muted">Criada em @(page.CreatedAt.ToString("dd/MM/yyyy"))</small>
@if ((page.LastModerationStatus ?? page.Status) == BCards.Web.ViewModels.PageStatus.Rejected && !string.IsNullOrEmpty(page.Motive))
<small class="text-muted">Criada em @(pageItem.CreatedAt.ToString("dd/MM/yyyy"))</small>
@if ((pageItem.LastModerationStatus ?? pageItem.Status) == BCards.Web.ViewModels.PageStatus.Rejected && !string.IsNullOrEmpty(pageItem.Motive))
{
<div class="alert alert-danger alert-dismissible fade show mt-2" role="alert">
<div class="d-flex align-items-start">
<i class="fas fa-exclamation-triangle me-2 mt-1"></i>
<div class="flex-grow-1">
<strong>Motivo da rejeição:</strong><br>
<small>@(page.Motive)</small>
<small>@(pageItem.Motive)</small>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
else if (page.LastModerationStatus == BCards.Web.ViewModels.PageStatus.Active && !string.IsNullOrEmpty(page.Motive))
else if (pageItem.LastModerationStatus == BCards.Web.ViewModels.PageStatus.Active && !string.IsNullOrEmpty(pageItem.Motive))
{
<div class="alert alert-info alert-dismissible fade show mt-2" role="alert">
<div class="d-flex align-items-start">
<i class="fas fa-exclamation-triangle me-2 mt-1"></i>
<div class="flex-grow-1">
<strong>Motivo:</strong><br>
<small>@(page.Motive)</small>
<small>@(pageItem.Motive)</small>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
@ -322,6 +372,140 @@
</div>
</div>
@section Scripts {
<script>
// Função para abrir preview com token fresh
async function openPreview(pageId) {
const button = event.target.closest('button');
const category = button.dataset.pageCategory;
const slug = button.dataset.pageSlug;
// Desabilitar botão temporariamente
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Carregando...';
try {
// Gerar novo token
const response = await fetch(`/Admin/GeneratePreviewToken/${pageId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
const result = await response.json();
if (result.success) {
// Abrir preview em nova aba com token novo
const previewUrl = `${window.location.origin}/page/${category}/${slug}?preview=${result.previewToken}`;
window.open(previewUrl, '_blank');
} else {
showToast(result.message || 'Erro ao gerar preview', 'error');
}
} catch (error) {
console.error('Erro ao gerar preview:', error);
showToast('Erro ao gerar preview. Tente novamente.', 'error');
} finally {
// Reabilitar botão
button.disabled = false;
button.innerHTML = originalText;
}
}
async function submitForModeration(pageId) {
const pageName = event.target.dataset.pageName || 'esta página';
if (!confirm(`Enviar "${pageName}" para moderação?\n\nApós enviar, você não poderá mais editá-la até receber o resultado da análise.`)) {
return;
}
// Desabilitar botão durante envio
const button = event.target;
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando...';
try {
const response = await fetch(`/Admin/SubmitForModeration/${pageId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
const result = await response.json();
if (result.success) {
// Mostrar toast de sucesso
showToast(result.message, 'success');
// Recarregar página após 2 segundos
setTimeout(() => {
location.reload();
}, 2000);
} else {
showToast(result.message || 'Erro ao enviar página', 'error');
// Reabilitar botão
button.disabled = false;
button.innerHTML = originalText;
}
} catch (error) {
console.error('Erro:', error);
showToast('Erro ao enviar página para moderação', 'error');
// Reabilitar botão
button.disabled = false;
button.innerHTML = originalText;
}
}
function showToast(message, type) {
const toastContainer = getOrCreateToastContainer();
const bgClass = type === 'success' ? 'bg-success' : type === 'error' ? 'bg-danger' : 'bg-info';
const icon = type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-triangle' : 'fa-info-circle';
const toastHtml = `
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="5000">
<div class="toast-header ${bgClass} text-white">
<i class="fas ${icon} me-2"></i>
<strong class="me-auto">${type === 'success' ? 'Sucesso' : type === 'error' ? 'Erro' : 'Informação'}</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">${message}</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const newToast = toastContainer.lastElementChild;
const toast = new bootstrap.Toast(newToast);
toast.show();
// Remover toast após ser fechado
newToast.addEventListener('hidden.bs.toast', function() {
newToast.remove();
});
}
function getOrCreateToastContainer() {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container position-fixed top-0 end-0 p-3';
container.style.zIndex = '1055';
document.body.appendChild(container);
}
return container;
}
</script>
}
@if (TempData["Success"] != null)
{
<div class="toast-container position-fixed top-0 end-0 p-3">

View File

@ -1,9 +1,10 @@
@{
var isPreview = ViewBag.IsPreview as bool? ?? false;
//var isPreview = ViewBag.IsPreview as bool? ?? false;
ViewData["Title"] = "BCards - Crie seu LinkTree 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";
//Layout = isPreview ? "_Layout" : "_UserPageLayout";
Layout = "_Layout";
}
<div class="hero-section bg-primary bg-gradient text-white py-5 mb-5">
@ -26,9 +27,6 @@
}
else
{
<a asp-controller="Auth" asp-action="Login" class="btn btn-outline-light btn-lg px-4">
Entrar
</a>
<a asp-controller="Auth" asp-action="Login" class="btn btn-light btn-lg px-4">
Começar Grátis
</a>

View File

@ -1,7 +1,8 @@
@{
ViewData["Title"] = "Planos e Preços - BCards";
var isPreview = ViewBag.IsPreview as bool? ?? false;
Layout = isPreview ? "_Layout" : "_UserPageLayout";
//var isPreview = ViewBag.IsPreview as bool? ?? false;
//Layout = isPreview ? "_Layout" : "_UserPageLayout";
Layout = "_Layout";
}
<div class="container py-5">

View File

@ -3,7 +3,10 @@
@if (Model)
{
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle text-warning fw-bold" href="#" id="moderationDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<a class="nav-link dropdown-toggle @(ViewBag.IsHomePage == true ? "text-warning" : "text-warning") fw-bold"
href="#" id="moderationDropdown" role="button"
data-bs-toggle="dropdown" aria-expanded="false"
style="@(ViewBag.IsHomePage == true ? "color: #fbbf24 !important;" : "")">
<i class="fas fa-shield-alt me-1"></i>Moderação
</a>
<ul class="dropdown-menu" aria-labelledby="moderationDropdown">

View File

@ -40,37 +40,54 @@
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light fixed-top @(ViewBag.IsHomePage == true ? "bg-home-blue" : "bg-dashboard")" id="mainNavbar">
<div class="container-fluid">
<a class="navbar-brand fw-bold text-primary" asp-area="" asp-controller="Home" asp-action="Index">
<a class="navbar-brand fw-bold @(ViewBag.IsHomePage == true ? "text-white" : "text-primary")"
asp-area="" asp-controller="Home" asp-action="Index">
BCards
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<button class="navbar-toggler @(ViewBag.IsHomePage == true ? "navbar-dark" : "")"
type="button"
data-bs-toggle="collapse"
data-bs-target=".navbar-collapse"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Início</a>
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Home" asp-action="Index">
Início
</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Pricing">Planos</a>
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Home" asp-action="Pricing">
Planos
</a>
</li>
@* Menu de Moderação via ViewComponent *@
@await Component.InvokeAsync("ModerationMenu")
</ul>
<ul class="navbar-nav">
@if (User.Identity?.IsAuthenticated == true)
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Dashboard">
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Admin" asp-action="Dashboard">
<i class="fas fa-user me-1"></i>Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Logout">
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Auth" asp-action="Logout">
<i class="fas fa-sign-out-alt me-1"></i>Sair
</a>
</li>
@ -78,7 +95,8 @@
else
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Login">
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Auth" asp-action="Login">
<i class="fas fa-sign-in-alt me-1"></i>Entrar
</a>
</li>

View File

@ -19,6 +19,7 @@ html {
body {
margin-bottom: 60px;
padding-top: 70px; /* Altura da navbar fixa */
}
.footer {
@ -217,3 +218,56 @@ body {
border: 2px solid;
border-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%) 1;
}
/* Menu Home (mesmo gradiente do fundo principal) */
.bg-home-blue {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
/* Removida linha divisória na home */
}
/* Menu Dashboard (mais neutro) */
.bg-dashboard {
background-color: #ffffff !important;
border-bottom: 1px solid #dee2e6;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Responsive adjustments */
@media (max-width: 576px) {
body {
padding-top: 60px;
}
.bg-home-blue .navbar-nav {
background: rgba(102, 126, 234, 0.95);
border-radius: 8px;
margin-top: 10px;
padding: 10px;
}
}
/* Hamburger menu para home */
.navbar-dark .navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.85%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
}
/* Hover effects para home menu */
.bg-home-blue .nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
transition: all 0.3s ease;
}
/* Hover effects para dashboard menu */
.bg-dashboard .nav-link:hover {
background-color: rgba(37, 99, 235, 0.1);
border-radius: 4px;
transition: all 0.3s ease;
}
/* Garantir contraste adequado no menu home */
.bg-home-blue .navbar-brand,
.bg-home-blue .nav-link {
color: #ffffff !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}