feat: primeira versão

This commit is contained in:
Ricardo Carneiro 2025-06-24 23:25:02 -03:00
parent b2d54a1cc0
commit 6ba824c155
82 changed files with 37871 additions and 0 deletions

View File

@ -0,0 +1,22 @@
{
"permissions": {
"allow": [
"Bash(dotnet --version)",
"Bash(dotnet restore:*)",
"Bash(dotnet build)",
"Bash(dotnet test)",
"Bash(dotnet tool install:*)",
"Bash(~/.dotnet/tools/libman restore)",
"Bash(timeout:*)",
"Bash(ls:*)",
"Bash(mkdir:*)",
"Bash(dotnet clean:*)",
"Bash(find:*)",
"Bash(rg:*)",
"Bash(pkill:*)",
"Bash(sudo rm:*)",
"Bash(rm:*)"
]
},
"enableAllProjectMcpServers": false
}

382
.gitignore vendored Normal file
View File

@ -0,0 +1,382 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/*.HxS
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these files may be disclosed. Comment the next line if you want to checkin
# your web deploy settings, but sensitive information contained in these files may
# be disclosed. Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.azurePubxml
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment the next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
CConvertLog*.txt
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# Custom
appsettings.Local.json
appsettings.Production.json
*.Development.json
logs/
uploads/
temp/
*.env
.env.local
.env.production
# macOS
.DS_Store
# IDE
.vscode/
.idea/
*.swp
*.swo
# Database
*.db
*.sqlite
*.sqlite3
# Certificates
*.pfx
*.crt
*.key

27
BCards.sln Normal file
View File

@ -0,0 +1,27 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Tests", "tests\BCards.Tests\BCards.Tests.csproj", "{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

55
docker-compose.yml Normal file
View File

@ -0,0 +1,55 @@
version: '3.8'
services:
mongodb:
image: mongo:7.0
container_name: bcards-mongodb
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: password123
MONGO_INITDB_DATABASE: BCardsDB
volumes:
- mongodb_data:/data/db
- ./scripts/init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro
networks:
- bcards-network
bcards-web:
build:
context: .
dockerfile: src/BCards.Web/Dockerfile
container_name: bcards-web
ports:
- "8080:80"
- "8443:443"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- MongoDb__ConnectionString=mongodb://admin:password123@mongodb:27017/BCardsDB?authSource=admin
- MongoDb__DatabaseName=BCardsDB
depends_on:
- mongodb
networks:
- bcards-network
volumes:
- ./uploads:/app/uploads
redis:
image: redis:7-alpine
container_name: bcards-redis
ports:
- "6379:6379"
networks:
- bcards-network
command: redis-server --appendonly yes
volumes:
- redis_data:/data
volumes:
mongodb_data:
redis_data:
networks:
bcards-network:
driver: bridge

194
scripts/init-mongo.js Normal file
View File

@ -0,0 +1,194 @@
// MongoDB initialization script
db = db.getSiblingDB('BCardsDB');
// Create collections
db.createCollection('users');
db.createCollection('userpages');
db.createCollection('categories');
db.createCollection('subscriptions');
db.createCollection('themes');
// Create indexes for users
db.users.createIndex({ "email": 1 }, { unique: true });
db.users.createIndex({ "stripeCustomerId": 1 });
// Create indexes for userpages
db.userpages.createIndex({ "userId": 1 });
db.userpages.createIndex({ "category": 1, "slug": 1 }, { unique: true });
db.userpages.createIndex({ "category": 1 });
db.userpages.createIndex({ "isActive": 1 });
db.userpages.createIndex({ "publishedAt": -1 });
// Create indexes for categories
db.categories.createIndex({ "slug": 1 }, { unique: true });
db.categories.createIndex({ "isActive": 1 });
// Create indexes for subscriptions
db.subscriptions.createIndex({ "userId": 1 });
db.subscriptions.createIndex({ "stripeSubscriptionId": 1 });
db.subscriptions.createIndex({ "status": 1 });
// Create indexes for themes
db.themes.createIndex({ "isActive": 1 });
db.themes.createIndex({ "isPremium": 1 });
// Insert default categories
db.categories.insertMany([
{
name: "Corretor de Imóveis",
slug: "corretor",
icon: "🏠",
description: "Profissionais especializados em compra, venda e locação de imóveis",
seoKeywords: ["corretor", "imóveis", "casa", "apartamento", "venda", "locação"],
isActive: true,
createdAt: new Date()
},
{
name: "Tecnologia",
slug: "tecnologia",
icon: "💻",
description: "Empresas e profissionais de tecnologia, desenvolvimento e TI",
seoKeywords: ["desenvolvimento", "software", "programação", "tecnologia", "TI"],
isActive: true,
createdAt: new Date()
},
{
name: "Saúde",
slug: "saude",
icon: "🏥",
description: "Profissionais da saúde, clínicas e consultórios médicos",
seoKeywords: ["médico", "saúde", "clínica", "consulta", "tratamento"],
isActive: true,
createdAt: new Date()
},
{
name: "Educação",
slug: "educacao",
icon: "📚",
description: "Professores, escolas, cursos e instituições de ensino",
seoKeywords: ["educação", "ensino", "professor", "curso", "escola"],
isActive: true,
createdAt: new Date()
},
{
name: "Comércio",
slug: "comercio",
icon: "🛍️",
description: "Lojas, e-commerce e estabelecimentos comerciais",
seoKeywords: ["loja", "comércio", "venda", "produtos", "e-commerce"],
isActive: true,
createdAt: new Date()
},
{
name: "Serviços",
slug: "servicos",
icon: "🔧",
description: "Prestadores de serviços gerais e especializados",
seoKeywords: ["serviços", "prestador", "profissional", "especializado"],
isActive: true,
createdAt: new Date()
},
{
name: "Alimentação",
slug: "alimentacao",
icon: "🍽️",
description: "Restaurantes, delivery, food trucks e estabelecimentos alimentícios",
seoKeywords: ["restaurante", "comida", "delivery", "alimentação", "gastronomia"],
isActive: true,
createdAt: new Date()
},
{
name: "Beleza",
slug: "beleza",
icon: "💄",
description: "Salões de beleza, barbearias, estética e cuidados pessoais",
seoKeywords: ["beleza", "salão", "estética", "cabeleireiro", "manicure"],
isActive: true,
createdAt: new Date()
},
{
name: "Advocacia",
slug: "advocacia",
icon: "⚖️",
description: "Advogados, escritórios jurídicos e consultoria legal",
seoKeywords: ["advogado", "jurídico", "direito", "advocacia", "legal"],
isActive: true,
createdAt: new Date()
},
{
name: "Arquitetura",
slug: "arquitetura",
icon: "🏗️",
description: "Arquitetos, engenheiros e profissionais da construção",
seoKeywords: ["arquiteto", "engenheiro", "construção", "projeto", "reforma"],
isActive: true,
createdAt: new Date()
}
]);
// Insert default themes
db.themes.insertMany([
{
name: "Minimalista",
primaryColor: "#2563eb",
secondaryColor: "#1d4ed8",
backgroundColor: "#ffffff",
textColor: "#1f2937",
backgroundImage: "",
isPremium: false,
cssTemplate: "minimal",
isActive: true,
createdAt: new Date()
},
{
name: "Dark Mode",
primaryColor: "#10b981",
secondaryColor: "#059669",
backgroundColor: "#111827",
textColor: "#f9fafb",
backgroundImage: "",
isPremium: false,
cssTemplate: "dark",
isActive: true,
createdAt: new Date()
},
{
name: "Natureza",
primaryColor: "#16a34a",
secondaryColor: "#15803d",
backgroundColor: "#f0fdf4",
textColor: "#166534",
backgroundImage: "/images/themes/nature-bg.jpg",
isPremium: false,
cssTemplate: "nature",
isActive: true,
createdAt: new Date()
},
{
name: "Corporativo",
primaryColor: "#1e40af",
secondaryColor: "#1e3a8a",
backgroundColor: "#f8fafc",
textColor: "#0f172a",
backgroundImage: "",
isPremium: false,
cssTemplate: "corporate",
isActive: true,
createdAt: new Date()
},
{
name: "Vibrante",
primaryColor: "#dc2626",
secondaryColor: "#b91c1c",
backgroundColor: "#fef2f2",
textColor: "#7f1d1d",
backgroundImage: "",
isPremium: true,
cssTemplate: "vibrant",
isActive: true,
createdAt: new Date()
}
]);
print("Database initialized successfully!");
print("Collections created with indexes and default data inserted.");

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
<PackageReference Include="Stripe.net" Version="44.7.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" />
<PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\**\*.resx" />
</ItemGroup>
<ItemGroup>
<Folder Include="Views\Payment\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,13 @@
namespace BCards.Web.Configuration;
public class GoogleAuthSettings
{
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
}
public class MicrosoftAuthSettings
{
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
}

View File

@ -0,0 +1,7 @@
namespace BCards.Web.Configuration;
public class MongoDbSettings
{
public string ConnectionString { get; set; } = string.Empty;
public string DatabaseName { get; set; } = string.Empty;
}

View File

@ -0,0 +1,8 @@
namespace BCards.Web.Configuration;
public class StripeSettings
{
public string PublishableKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public string WebhookSecret { get; set; } = string.Empty;
}

View File

@ -0,0 +1,673 @@
using BCards.Web.Models;
using BCards.Web.Services;
using BCards.Web.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Controllers;
[Authorize]
[Route("Admin")]
public class AdminController : Controller
{
private readonly IAuthService _authService;
private readonly IUserPageService _userPageService;
private readonly ICategoryService _categoryService;
private readonly IThemeService _themeService;
private readonly ILogger<AdminController> _logger;
public AdminController(
IAuthService authService,
IUserPageService userPageService,
ICategoryService categoryService,
IThemeService themeService,
ILogger<AdminController> logger)
{
_authService = authService;
_userPageService = userPageService;
_categoryService = categoryService;
_themeService = themeService;
_logger = logger;
}
[HttpGet]
[Route("Dashboard")]
public async Task<IActionResult> Dashboard()
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var userPages = await _userPageService.GetUserPagesAsync(user.Id);
var dashboardModel = new DashboardViewModel
{
CurrentUser = user,
UserPages = userPages.Select(p => new UserPageSummary
{
Id = p.Id,
DisplayName = p.DisplayName,
Slug = p.Slug,
Category = p.Category,
Status = p.Status,
TotalClicks = p.Analytics?.TotalClicks ?? 0,
TotalViews = p.Analytics?.TotalViews ?? 0,
CreatedAt = p.CreatedAt
}).ToList(),
CurrentPlan = new PlanInfo
{
Type = userPlanType,
Name = userPlanType.GetDisplayName(),
MaxPages = userPlanType.GetMaxPages(),
MaxLinksPerPage = userPlanType.GetMaxLinksPerPage(),
DurationDays = userPlanType.GetTrialDays(),
Price = userPlanType.GetPrice(),
AllowsAnalytics = userPlanType.AllowsAnalytics(),
AllowsCustomThemes = userPlanType.AllowsCustomThemes()
},
CanCreateNewPage = userPages.Count < userPlanType.GetMaxPages(),
DaysRemaining = userPlanType == PlanType.Trial ? CalculateTrialDaysRemaining(user) : 0
};
return View(dashboardModel);
}
private int CalculateTrialDaysRemaining(User user)
{
// This would be calculated based on subscription data
// For now, return a default value
return 7;
}
[HttpGet]
[Route("ManagePage")]
public async Task<IActionResult> ManagePage(string id = null)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var categories = await _categoryService.GetAllCategoriesAsync();
var themes = await _themeService.GetAvailableThemesAsync();
if (string.IsNullOrEmpty(id) || id == "new")
{
// Check if user can create new page
var existingPages = await _userPageService.GetUserPagesAsync(user.Id);
var maxPages = userPlanType.GetMaxPages();
if (existingPages.Count >= maxPages)
{
TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas.";
return RedirectToAction("Dashboard");
}
// CRIAR NOVA PÁGINA
var model = new ManagePageViewModel
{
IsNewPage = true,
AvailableCategories = categories,
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage()
};
return View(model);
}
else
{
// EDITAR PÁGINA EXISTENTE
var page = await _userPageService.GetPageByIdAsync(id);
if (page == null || page.UserId != user.Id)
return NotFound();
var model = MapToManageViewModel(page, categories, themes, userPlanType);
return View(model);
}
}
[HttpPost]
[Route("ManagePage")]
public async Task<IActionResult> ManagePage(ManagePageViewModel model)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
_logger.LogInformation($"ManagePage POST: IsNewPage={model.IsNewPage}, DisplayName={model.DisplayName}, Category={model.Category}, Links={model.Links?.Count ?? 0}");
if (!ModelState.IsValid)
{
_logger.LogWarning("ModelState is invalid:");
foreach (var error in ModelState)
{
_logger.LogWarning($"Key: {error.Key}, Errors: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}");
}
// Repopulate dropdowns
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
return View(model);
}
if (model.IsNewPage)
{
// Generate slug if not provided
if (string.IsNullOrEmpty(model.Slug))
{
model.Slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName);
}
// Check if slug is available
if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug))
{
ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
return View(model);
}
// Check if user can create the requested number of links
var activeLinksCount = model.Links?.Count ?? 0;
if (activeLinksCount > model.MaxLinksAllowed)
{
ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
return View(model);
}
try
{
// Create new page
var userPage = await MapToUserPage(model, user.Id);
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
await _userPageService.CreatePageAsync(userPage);
_logger.LogInformation("Page created successfully!");
TempData["Success"] = "Página criada com sucesso!";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating page");
ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
return View(model);
}
}
else
{
// Update existing page
var existingPage = await _userPageService.GetPageByIdAsync(model.Id);
if (existingPage == null || existingPage.UserId != user.Id)
return NotFound();
UpdateUserPageFromModel(existingPage, model);
await _userPageService.UpdatePageAsync(existingPage);
TempData["Success"] = "Página atualizada com sucesso!";
}
return RedirectToAction("Dashboard");
}
[HttpPost]
[Route("CreatePage")]
public async Task<IActionResult> CreatePage(CreatePageViewModel model)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
// Check if user already has a page
var existingPage = await _userPageService.GetUserPageAsync(user.Id);
if (existingPage != null)
return RedirectToAction("EditPage");
if (!ModelState.IsValid)
{
var categories = await _categoryService.GetAllCategoriesAsync();
var themes = await _themeService.GetAvailableThemesAsync();
ViewBag.Categories = categories;
ViewBag.Themes = themes;
return View(model);
}
// Generate slug if not provided
if (string.IsNullOrEmpty(model.Slug))
{
model.Slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName);
}
// Check if slug is available
if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug))
{
ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra.");
var categories = await _categoryService.GetAllCategoriesAsync();
var themes = await _themeService.GetAvailableThemesAsync();
ViewBag.Categories = categories;
ViewBag.Themes = themes;
return View(model);
}
// Check if user can create the requested number of links
var activeLinksCount = model.Links?.Count ?? 0;
if (!await _userPageService.CanCreateLinksAsync(user.Id, activeLinksCount))
{
ModelState.AddModelError("", "Você excedeu o limite de links do seu plano atual.");
var categories = await _categoryService.GetAllCategoriesAsync();
var themes = await _themeService.GetAvailableThemesAsync();
ViewBag.Categories = categories;
ViewBag.Themes = themes;
return View(model);
}
// Convert ViewModel to UserPage
var userPage = new UserPage
{
UserId = user.Id,
DisplayName = model.DisplayName,
Category = model.Category,
BusinessType = model.BusinessType,
Bio = model.Bio,
Slug = model.Slug,
Theme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme(),
Links = model.Links?.Select(l => new LinkItem
{
Title = l.Title,
Url = l.Url,
Description = l.Description,
Icon = l.Icon,
IsActive = true,
Order = model.Links.IndexOf(l)
}).ToList() ?? new List<LinkItem>()
};
// Add social media links
var socialLinks = new List<LinkItem>();
if (!string.IsNullOrEmpty(model.WhatsAppNumber))
{
socialLinks.Add(new LinkItem
{
Title = "WhatsApp",
Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}",
Icon = "fab fa-whatsapp",
IsActive = true,
Order = userPage.Links.Count + socialLinks.Count
});
}
if (!string.IsNullOrEmpty(model.FacebookUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Facebook",
Url = model.FacebookUrl,
Icon = "fab fa-facebook",
IsActive = true,
Order = userPage.Links.Count + socialLinks.Count
});
}
if (!string.IsNullOrEmpty(model.TwitterUrl))
{
socialLinks.Add(new LinkItem
{
Title = "X / Twitter",
Url = model.TwitterUrl,
Icon = "fab fa-x-twitter",
IsActive = true,
Order = userPage.Links.Count + socialLinks.Count
});
}
if (!string.IsNullOrEmpty(model.InstagramUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Instagram",
Url = model.InstagramUrl,
Icon = "fab fa-instagram",
IsActive = true,
Order = userPage.Links.Count + socialLinks.Count
});
}
userPage.Links.AddRange(socialLinks);
await _userPageService.CreatePageAsync(userPage);
TempData["Success"] = "Página criada com sucesso!";
return RedirectToAction("Dashboard");
}
[HttpGet]
[Route("EditPage")]
public async Task<IActionResult> EditPage()
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
var userPage = await _userPageService.GetUserPageAsync(user.Id);
var categories = await _categoryService.GetAllCategoriesAsync();
var themes = await _themeService.GetAvailableThemesAsync();
ViewBag.Categories = categories;
ViewBag.Themes = themes;
ViewBag.IsNew = userPage == null;
return View(userPage ?? new UserPage { UserId = user.Id });
}
[HttpPost]
[Route("EditPage")]
public async Task<IActionResult> EditPage(UserPage model)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
if (!ModelState.IsValid)
{
var categories = await _categoryService.GetAllCategoriesAsync();
var themes = await _themeService.GetAvailableThemesAsync();
ViewBag.Categories = categories;
ViewBag.Themes = themes;
return View(model);
}
// Check if user can create the requested number of links
var activeLinksCount = model.Links?.Count(l => l.IsActive) ?? 0;
if (!await _userPageService.CanCreateLinksAsync(user.Id, activeLinksCount))
{
ModelState.AddModelError("", "Você excedeu o limite de links do seu plano atual.");
var categories = await _categoryService.GetAllCategoriesAsync();
var themes = await _themeService.GetAvailableThemesAsync();
ViewBag.Categories = categories;
ViewBag.Themes = themes;
return View(model);
}
model.UserId = user.Id;
// Check if slug is available
if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug, model.Id))
{
ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra.");
var categories = await _categoryService.GetAllCategoriesAsync();
var themes = await _themeService.GetAvailableThemesAsync();
ViewBag.Categories = categories;
ViewBag.Themes = themes;
return View(model);
}
if (string.IsNullOrEmpty(model.Id))
{
await _userPageService.CreatePageAsync(model);
}
else
{
await _userPageService.UpdatePageAsync(model);
}
TempData["Success"] = "Página salva com sucesso!";
return RedirectToAction("Dashboard");
}
[HttpPost]
[Route("CheckSlugAvailability")]
public async Task<IActionResult> CheckSlugAvailability(string category, string slug, string? excludeId = null)
{
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(slug))
return Json(new { available = false, message = "Categoria e slug são obrigatórios." });
var isValid = await _userPageService.ValidateSlugAsync(category, slug, excludeId);
return Json(new { available = isValid, message = isValid ? "URL disponível!" : "Esta URL já está em uso." });
}
[HttpPost]
[Route("GenerateSlug")]
public async Task<IActionResult> GenerateSlug(string category, string name)
{
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name))
return Json(new { slug = "" });
var slug = await _userPageService.GenerateSlugAsync(category, name);
return Json(new { slug });
}
[HttpGet]
[Route("Analytics")]
public async Task<IActionResult> Analytics()
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
var userPage = await _userPageService.GetUserPageAsync(user.Id);
if (userPage == null || !userPage.PlanLimitations.AllowAnalytics)
return RedirectToAction("Dashboard");
return View(userPage);
}
[HttpPost]
public async Task<IActionResult> DeletePage()
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
var userPage = await _userPageService.GetUserPageAsync(user.Id);
if (userPage != null)
{
await _userPageService.DeletePageAsync(userPage.Id);
TempData["Success"] = "Página excluída com sucesso!";
}
return RedirectToAction("Dashboard");
}
private ManagePageViewModel MapToManageViewModel(UserPage page, List<Category> categories, List<PageTheme> themes, PlanType userPlanType)
{
return new ManagePageViewModel
{
Id = page.Id,
IsNewPage = false,
DisplayName = page.DisplayName,
Category = page.Category,
BusinessType = page.BusinessType,
Bio = page.Bio,
Slug = page.Slug,
SelectedTheme = page.Theme?.Name ?? "minimalist",
Links = page.Links?.Select((l, index) => new ManageLinkViewModel
{
Id = $"link_{index}",
Title = l.Title,
Url = l.Url,
Description = l.Description,
Icon = l.Icon,
Order = l.Order,
IsActive = l.IsActive
}).ToList() ?? new List<ManageLinkViewModel>(),
AvailableCategories = categories,
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage()
};
}
private async Task<UserPage> MapToUserPage(ManagePageViewModel model, string userId)
{
var theme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme();
var userPage = new UserPage
{
UserId = userId,
DisplayName = model.DisplayName,
Category = model.Category.ToLower(),
BusinessType = model.BusinessType,
Bio = model.Bio,
Slug = model.Slug.ToLower(),
Theme = theme,
Status = ViewModels.PageStatus.Active,
Links = new List<LinkItem>()
};
// Add regular links
if (model.Links?.Any() == true)
{
userPage.Links.AddRange(model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url))
.Select((l, index) => new LinkItem
{
Title = l.Title,
Url = l.Url.ToLower(),
Description = l.Description,
Icon = l.Icon,
IsActive = l.IsActive,
Order = index
}));
}
// Add social media links
var socialLinks = new List<LinkItem>();
var currentOrder = userPage.Links.Count;
if (!string.IsNullOrEmpty(model.WhatsAppNumber))
{
socialLinks.Add(new LinkItem
{
Title = "WhatsApp",
Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}",
Icon = "fab fa-whatsapp",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.FacebookUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Facebook",
Url = model.FacebookUrl,
Icon = "fab fa-facebook",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.TwitterUrl))
{
socialLinks.Add(new LinkItem
{
Title = "X / Twitter",
Url = model.TwitterUrl,
Icon = "fab fa-x-twitter",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.InstagramUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Instagram",
Url = model.InstagramUrl,
Icon = "fab fa-instagram",
IsActive = true,
Order = currentOrder++
});
}
userPage.Links.AddRange(socialLinks);
return userPage;
}
private void UpdateUserPageFromModel(UserPage page, ManagePageViewModel model)
{
page.DisplayName = model.DisplayName;
page.Category = model.Category;
page.BusinessType = model.BusinessType;
page.Bio = model.Bio;
page.Slug = model.Slug;
page.UpdatedAt = DateTime.UtcNow;
// Update links
page.Links = new List<LinkItem>();
// Add regular links
if (model.Links?.Any() == true)
{
page.Links.AddRange(model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url))
.Select((l, index) => new LinkItem
{
Title = l.Title,
Url = l.Url,
Description = l.Description,
Icon = l.Icon,
IsActive = l.IsActive,
Order = index
}));
}
// Add social media links (same logic as create)
var socialLinks = new List<LinkItem>();
var currentOrder = page.Links.Count;
if (!string.IsNullOrEmpty(model.WhatsAppNumber))
{
socialLinks.Add(new LinkItem
{
Title = "WhatsApp",
Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}",
Icon = "fab fa-whatsapp",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.FacebookUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Facebook",
Url = model.FacebookUrl,
Icon = "fab fa-facebook",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.TwitterUrl))
{
socialLinks.Add(new LinkItem
{
Title = "X / Twitter",
Url = model.TwitterUrl,
Icon = "fab fa-x-twitter",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.InstagramUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Instagram",
Url = model.InstagramUrl,
Icon = "fab fa-instagram",
IsActive = true,
Order = currentOrder++
});
}
page.Links.AddRange(socialLinks);
}
}

View File

@ -0,0 +1,125 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authentication.MicrosoftAccount;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Controllers;
[Route("Auth")]
public class AuthController : Controller
{
private readonly IAuthService _authService;
public AuthController(IAuthService authService)
{
_authService = authService;
}
[HttpGet]
[Route("Login")]
public IActionResult Login(string? returnUrl = null)
{
ViewBag.ReturnUrl = returnUrl;
return View();
}
[HttpPost]
[Route("LoginWithGoogle")]
public IActionResult LoginWithGoogle(string? returnUrl = null)
{
var redirectUrl = Url.Action("GoogleCallback", "Auth", new { returnUrl });
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
return Challenge(properties, GoogleDefaults.AuthenticationScheme);
}
[HttpPost]
[Route("LoginWithMicrosoft")]
public IActionResult LoginWithMicrosoft(string? returnUrl = null)
{
var redirectUrl = Url.Action("MicrosoftCallback", "Auth", new { returnUrl });
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
return Challenge(properties, MicrosoftAccountDefaults.AuthenticationScheme);
}
[HttpGet]
[Route("GoogleCallback")]
public async Task<IActionResult> GoogleCallback(string? returnUrl = null)
{
var result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
if (!result.Succeeded)
{
TempData["Error"] = "Falha na autenticação com Google";
return RedirectToAction("Login");
}
var user = await _authService.CreateOrUpdateUserFromClaimsAsync(result.Principal);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Name, user.Name),
new Claim(ClaimTypes.Email, user.Email),
new Claim("picture", user.ProfileImage ?? "")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
TempData["Success"] = $"Bem-vindo, {user.Name}!";
return RedirectToLocal(returnUrl);
}
[HttpGet]
[Route("MicrosoftCallback")]
public async Task<IActionResult> MicrosoftCallback(string? returnUrl = null)
{
var result = await HttpContext.AuthenticateAsync(MicrosoftAccountDefaults.AuthenticationScheme);
if (!result.Succeeded)
{
TempData["Error"] = "Falha na autenticação com Microsoft";
return RedirectToAction("Login");
}
var user = await _authService.CreateOrUpdateUserFromClaimsAsync(result.Principal);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Name, user.Name),
new Claim(ClaimTypes.Email, user.Email),
new Claim("picture", user.ProfileImage ?? "")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
TempData["Success"] = $"Bem-vindo, {user.Name}!";
return RedirectToLocal(returnUrl);
}
[HttpGet]
[Route("Logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
TempData["Success"] = "Logout realizado com sucesso";
return RedirectToAction("Index", "Home");
}
private IActionResult RedirectToLocal(string? returnUrl)
{
if (Url.IsLocalUrl(returnUrl))
return Redirect(returnUrl);
return RedirectToAction("Dashboard", "Admin");
}
}

View File

@ -0,0 +1,56 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
public class HomeController : Controller
{
private readonly ICategoryService _categoryService;
private readonly IUserPageService _userPageService;
public HomeController(ICategoryService categoryService, IUserPageService userPageService)
{
_categoryService = categoryService;
_userPageService = userPageService;
}
public async Task<IActionResult> Index()
{
ViewBag.Categories = await _categoryService.GetAllCategoriesAsync();
ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6);
return View();
}
[Route("Privacy")]
public IActionResult Privacy()
{
return View();
}
[Route("Pricing")]
public IActionResult Pricing()
{
return View();
}
[Route("categoria/{categorySlug}")]
public async Task<IActionResult> Category(string categorySlug)
{
var category = await _categoryService.GetCategoryBySlugAsync(categorySlug);
if (category == null)
return NotFound();
var pages = await _userPageService.GetPagesByCategoryAsync(categorySlug, 20);
ViewBag.Category = category;
ViewBag.Pages = pages;
return View();
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View();
}
}

View File

@ -0,0 +1,113 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Authorize]
public class PaymentController : Controller
{
private readonly IPaymentService _paymentService;
private readonly IAuthService _authService;
public PaymentController(IPaymentService paymentService, IAuthService authService)
{
_paymentService = paymentService;
_authService = authService;
}
public IActionResult Plans()
{
return View();
}
[HttpPost]
public async Task<IActionResult> CreateCheckoutSession(string planType)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
var successUrl = Url.Action("Success", "Payment", null, Request.Scheme);
var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme);
try
{
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
user.Id,
planType,
successUrl!,
cancelUrl!);
return Redirect(checkoutUrl);
}
catch (Exception ex)
{
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
return RedirectToAction("Plans");
}
}
public IActionResult Success()
{
TempData["Success"] = "Assinatura ativada com sucesso! Agora você pode aproveitar todos os recursos do seu plano.";
return RedirectToAction("Dashboard", "Admin");
}
public IActionResult Cancel()
{
TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser.";
return RedirectToAction("Plans");
}
[HttpPost]
[Route("webhook/stripe")]
[AllowAnonymous]
public async Task<IActionResult> StripeWebhook()
{
var signature = Request.Headers["Stripe-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(signature))
return BadRequest();
string requestBody;
using (var reader = new StreamReader(Request.Body))
{
requestBody = await reader.ReadToEndAsync();
}
try
{
await _paymentService.HandleWebhookAsync(requestBody, signature);
return Ok();
}
catch (Exception ex)
{
return BadRequest($"Webhook error: {ex.Message}");
}
}
public async Task<IActionResult> ManageSubscription()
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
return View(user);
}
[HttpPost]
public async Task<IActionResult> CancelSubscription(string subscriptionId)
{
try
{
await _paymentService.CancelSubscriptionAsync(subscriptionId);
TempData["Success"] = "Sua assinatura será cancelada no final do período atual.";
}
catch (Exception ex)
{
TempData["Error"] = $"Erro ao cancelar assinatura: {ex.Message}";
}
return RedirectToAction("ManageSubscription");
}
}

View File

@ -0,0 +1,86 @@
using Microsoft.AspNetCore.Mvc;
using System.Text;
using System.Xml.Linq;
using BCards.Web.Services;
namespace BCards.Web.Controllers;
public class SitemapController : Controller
{
private readonly IUserPageService _userPageService;
private readonly ILogger<SitemapController> _logger;
public SitemapController(IUserPageService userPageService, ILogger<SitemapController> logger)
{
_userPageService = userPageService;
_logger = logger;
}
[Route("sitemap.xml")]
[ResponseCache(Duration = 86400)] // Cache for 24 hours
public async Task<IActionResult> Index()
{
try
{
var activePages = await _userPageService.GetActivePagesAsync();
var sitemap = new XDocument(
new XDeclaration("1.0", "utf-8", "yes"),
new XElement("urlset",
new XAttribute("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9"),
// Add static pages
new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/"),
new XElement("lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
new XElement("changefreq", "daily"),
new XElement("priority", "1.0")
),
new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/Home/Pricing"),
new XElement("lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
new XElement("changefreq", "weekly"),
new XElement("priority", "0.9")
),
// Add user pages (only active ones)
activePages.Select(page =>
new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"),
new XElement("lastmod", page.UpdatedAt.ToString("yyyy-MM-dd")),
new XElement("changefreq", "weekly"),
new XElement("priority", "0.8")
)
)
)
);
_logger.LogInformation($"Generated sitemap with {activePages.Count} user pages");
return Content(sitemap.ToString(), "application/xml", Encoding.UTF8);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating sitemap");
return StatusCode(500, "Error generating sitemap");
}
}
[Route("robots.txt")]
[ResponseCache(Duration = 86400)] // Cache for 24 hours
public IActionResult RobotsTxt()
{
var robotsTxt = $@"User-agent: *
Allow: /
Allow: /page/
Disallow: /Admin/
Disallow: /Auth/
Disallow: /Payment/
Disallow: /api/
Sitemap: {Request.Scheme}://{Request.Host}/sitemap.xml";
return Content(robotsTxt, "text/plain", Encoding.UTF8);
}
}

View File

@ -0,0 +1,219 @@
using Microsoft.AspNetCore.Mvc;
using Stripe;
using BCards.Web.Services;
using BCards.Web.Repositories;
using BCards.Web.Configuration;
using Microsoft.Extensions.Options;
namespace BCards.Web.Controllers;
[ApiController]
[Route("api/stripe")]
public class StripeWebhookController : ControllerBase
{
private readonly ILogger<StripeWebhookController> _logger;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly IUserPageService _userPageService;
private readonly string _webhookSecret;
public StripeWebhookController(
ILogger<StripeWebhookController> logger,
ISubscriptionRepository subscriptionRepository,
IUserPageService userPageService,
IOptions<StripeSettings> stripeSettings)
{
_logger = logger;
_subscriptionRepository = subscriptionRepository;
_userPageService = userPageService;
_webhookSecret = stripeSettings.Value.WebhookSecret ?? "";
}
[HttpPost("webhook")]
public async Task<IActionResult> HandleWebhook()
{
try
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
if (string.IsNullOrEmpty(_webhookSecret))
{
_logger.LogWarning("Webhook secret not configured");
return BadRequest("Webhook secret not configured");
}
var stripeSignature = Request.Headers["Stripe-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(stripeSignature))
{
_logger.LogWarning("Missing Stripe signature");
return BadRequest("Missing Stripe signature");
}
var stripeEvent = EventUtility.ConstructEvent(
json,
stripeSignature,
_webhookSecret,
throwOnApiVersionMismatch: false
);
_logger.LogInformation($"Processing Stripe webhook: {stripeEvent.Type}");
switch (stripeEvent.Type)
{
case Events.InvoicePaymentSucceeded:
await HandlePaymentSucceeded(stripeEvent);
break;
case Events.InvoicePaymentFailed:
await HandlePaymentFailed(stripeEvent);
break;
case Events.CustomerSubscriptionDeleted:
await HandleSubscriptionDeleted(stripeEvent);
break;
case Events.CustomerSubscriptionUpdated:
await HandleSubscriptionUpdated(stripeEvent);
break;
default:
_logger.LogInformation($"Unhandled webhook event type: {stripeEvent.Type}");
break;
}
return Ok();
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe webhook error");
return BadRequest($"Stripe error: {ex.Message}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Webhook processing error");
return StatusCode(500, "Internal server error");
}
}
private async Task HandlePaymentSucceeded(Event stripeEvent)
{
if (stripeEvent.Data.Object is Invoice invoice)
{
_logger.LogInformation($"Payment succeeded for customer: {invoice.CustomerId}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
if (subscription != null)
{
subscription.Status = "active";
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(subscription);
// Reactivate user pages
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.PendingPayment))
{
page.Status = ViewModels.PageStatus.Active;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"Reactivated {userPages.Count} pages for user {subscription.UserId}");
}
}
}
private async Task HandlePaymentFailed(Event stripeEvent)
{
if (stripeEvent.Data.Object is Invoice invoice)
{
_logger.LogInformation($"Payment failed for customer: {invoice.CustomerId}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
if (subscription != null)
{
subscription.Status = "past_due";
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(subscription);
// Set pages to pending payment
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
{
page.Status = ViewModels.PageStatus.PendingPayment;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"Set {userPages.Count} pages to pending payment for user {subscription.UserId}");
}
}
}
private async Task HandleSubscriptionDeleted(Event stripeEvent)
{
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
_logger.LogInformation($"Subscription cancelled: {stripeSubscription.Id}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
subscription.Status = "cancelled";
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(subscription);
// Downgrade to trial or deactivate pages
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
{
page.Status = ViewModels.PageStatus.Expired;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"Deactivated {userPages.Count} pages for cancelled subscription {subscription.UserId}");
}
}
}
private async Task HandleSubscriptionUpdated(Event stripeEvent)
{
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
_logger.LogInformation($"Subscription updated: {stripeSubscription.Id}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
subscription.Status = stripeSubscription.Status;
subscription.CurrentPeriodStart = stripeSubscription.CurrentPeriodStart;
subscription.CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd;
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
subscription.UpdatedAt = DateTime.UtcNow;
// Update plan type based on Stripe price ID
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
if (!string.IsNullOrEmpty(priceId))
{
subscription.PlanType = MapPriceIdToPlanType(priceId);
}
await _subscriptionRepository.UpdateAsync(subscription);
_logger.LogInformation($"Updated subscription for user {subscription.UserId}");
}
}
}
private string MapPriceIdToPlanType(string priceId)
{
// Map Stripe price IDs to plan types
// This would be configured based on your actual Stripe price IDs
return priceId switch
{
var id when id.Contains("basic") => "basic",
var id when id.Contains("professional") => "professional",
var id when id.Contains("premium") => "premium",
_ => "trial"
};
}
}

View File

@ -0,0 +1,73 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
//[Route("[controller]")]
public class UserPageController : Controller
{
private readonly IUserPageService _userPageService;
private readonly ICategoryService _categoryService;
private readonly ISeoService _seoService;
public UserPageController(
IUserPageService userPageService,
ICategoryService categoryService,
ISeoService seoService)
{
_userPageService = userPageService;
_categoryService = categoryService;
_seoService = seoService;
}
//[Route("{category}/{slug}")]
[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })]
public async Task<IActionResult> Display(string category, string slug)
{
var userPage = await _userPageService.GetPageAsync(category, slug);
if (userPage == null || !userPage.IsActive)
return NotFound();
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
if (categoryObj == null)
return NotFound();
// Generate SEO settings
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
// Record page view (async, don't wait)
var referrer = Request.Headers["Referer"].FirstOrDefault();
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
ViewBag.SeoSettings = seoSettings;
ViewBag.Category = categoryObj;
return View(userPage);
}
[HttpPost]
[Route("click/{pageId}")]
public async Task<IActionResult> RecordClick(string pageId, int linkIndex)
{
await _userPageService.RecordLinkClickAsync(pageId, linkIndex);
return Ok();
}
[Route("preview/{category}/{slug}")]
public async Task<IActionResult> Preview(string category, string slug)
{
var userPage = await _userPageService.GetPageAsync(category, slug);
if (userPage == null)
return NotFound();
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
if (categoryObj == null)
return NotFound();
ViewBag.Category = categoryObj;
ViewBag.IsPreview = true;
return View("Display", userPage);
}
}

29
src/BCards.Web/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"]
RUN dotnet restore "src/BCards.Web/BCards.Web.csproj"
COPY . .
WORKDIR "/src/src/BCards.Web"
RUN dotnet build "BCards.Web.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "BCards.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
# Create uploads directory
RUN mkdir -p /app/uploads && chmod 755 /app/uploads
# Install dependencies for image processing
RUN apt-get update && apt-get install -y \
libgdiplus \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["dotnet", "BCards.Web.dll"]

View File

@ -0,0 +1,128 @@
using BCards.Web.Services;
using BCards.Web.ViewModels;
using System.Text.RegularExpressions;
namespace BCards.Web.Middleware;
public class PageStatusMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<PageStatusMiddleware> _logger;
public PageStatusMiddleware(RequestDelegate next, ILogger<PageStatusMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, IUserPageService userPageService)
{
// Check if this is a user page route (page/{category}/{slug})
if (IsUserPageRoute(context.Request.Path))
{
try
{
var (category, slug) = ExtractRouteParameters(context.Request.Path);
if (!string.IsNullOrEmpty(category) && !string.IsNullOrEmpty(slug))
{
var page = await userPageService.GetPageAsync(category, slug);
if (page != null)
{
switch (page.Status)
{
case PageStatus.Expired:
// 301 Redirect para não prejudicar SEO
_logger.LogInformation($"Redirecting expired page {category}/{slug} to upgrade page");
context.Response.Redirect($"/Home/Pricing?expired={slug}&category={category}", permanent: true);
return;
case PageStatus.PendingPayment:
// Mostrar página com aviso de pagamento
_logger.LogInformation($"Showing payment warning for page {category}/{slug}");
await ShowPaymentWarning(context, page);
return;
case PageStatus.Inactive:
// 404 temporário
_logger.LogInformation($"Page {category}/{slug} is inactive, returning 404");
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Página temporariamente indisponível.");
return;
case PageStatus.Active:
// Continuar processamento normal
break;
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in PageStatusMiddleware");
}
}
await _next(context);
}
private static bool IsUserPageRoute(PathString path)
{
// Check if path matches pattern: /page/{category}/{slug}
return Regex.IsMatch(path.Value ?? "", @"^/page/[a-z-]+/[a-z0-9-]+/?$", RegexOptions.IgnoreCase);
}
private static (string category, string slug) ExtractRouteParameters(PathString path)
{
var match = Regex.Match(path.Value ?? "", @"^/page/([a-z-]+)/([a-z0-9-]+)/?$", RegexOptions.IgnoreCase);
if (match.Success)
{
return (match.Groups[1].Value, match.Groups[2].Value);
}
return (string.Empty, string.Empty);
}
private async Task ShowPaymentWarning(HttpContext context, BCards.Web.Models.UserPage page)
{
// Generate a simple HTML page with payment warning
var html = $@"
<!DOCTYPE html>
<html>
<head>
<title>{page.DisplayName} - Pagamento Pendente</title>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<link href='https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css' rel='stylesheet'>
</head>
<body class='bg-light'>
<div class='container mt-5'>
<div class='row justify-content-center'>
<div class='col-md-6'>
<div class='card border-warning'>
<div class='card-header bg-warning text-dark'>
<h5 class='mb-0'>
<i class='fas fa-exclamation-triangle me-2'></i>
Pagamento Pendente
</h5>
</div>
<div class='card-body text-center'>
<h4>{page.DisplayName}</h4>
<p class='text-muted mb-4'>Esta página está temporariamente indisponível devido a um pagamento pendente.</p>
<p class='mb-4'>Para reativar esta página, o proprietário deve regularizar o pagamento.</p>
<a href='/Home/Pricing' class='btn btn-primary'>Ver Planos</a>
</div>
</div>
</div>
</div>
</div>
</body>
</html>";
context.Response.ContentType = "text/html";
context.Response.StatusCode = 200; // Keep as 200 for SEO, but show warning
await context.Response.WriteAsync(html);
}
}

View File

@ -0,0 +1,127 @@
using BCards.Web.Services;
using BCards.Web.Repositories;
using System.Security.Claims;
namespace BCards.Web.Middleware;
public class PlanLimitationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<PlanLimitationMiddleware> _logger;
public PlanLimitationMiddleware(RequestDelegate next, ILogger<PlanLimitationMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvider)
{
// Only check for authenticated users on specific endpoints
if (context.User.Identity?.IsAuthenticated == true && ShouldCheckLimitations(context))
{
using var scope = serviceProvider.CreateScope();
var authService = scope.ServiceProvider.GetRequiredService<IAuthService>();
var subscriptionRepository = scope.ServiceProvider.GetRequiredService<ISubscriptionRepository>();
var userPageRepository = scope.ServiceProvider.GetRequiredService<IUserPageRepository>();
try
{
var user = await authService.GetCurrentUserAsync(context.User);
if (user != null)
{
var subscription = await subscriptionRepository.GetByUserIdAsync(user.Id);
var limitations = GetPlanLimitations(subscription?.PlanType ?? "free");
// Check specific limitations based on the request
if (IsLinkCreationRequest(context))
{
var userPage = await userPageRepository.GetByUserIdAsync(user.Id);
var currentLinksCount = userPage?.Links?.Count(l => l.IsActive) ?? 0;
if (limitations.MaxLinks != -1 && currentLinksCount >= limitations.MaxLinks)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync("Limite de links atingido. Faça upgrade do seu plano.");
return;
}
}
// Add limitations to context for use in controllers
context.Items["PlanLimitations"] = limitations;
context.Items["CurrentSubscription"] = subscription;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking plan limitations for user");
// Continue without blocking the request
}
}
await _next(context);
}
private static bool ShouldCheckLimitations(HttpContext context)
{
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
return path.StartsWith("/admin/") ||
path.StartsWith("/api/") ||
(context.Request.Method == "POST" && path.Contains("/editpage"));
}
private static bool IsLinkCreationRequest(HttpContext context)
{
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
return context.Request.Method == "POST" &&
(path.Contains("/editpage") || path.Contains("/addlink"));
}
private static Models.PlanLimitations GetPlanLimitations(string planType)
{
return planType.ToLower() switch
{
"basic" => new Models.PlanLimitations
{
MaxLinks = 5,
AllowCustomThemes = false,
AllowAnalytics = true,
AllowCustomDomain = false,
AllowMultipleDomains = false,
PrioritySupport = false,
PlanType = "basic"
},
"professional" => new Models.PlanLimitations
{
MaxLinks = 15,
AllowCustomThemes = false,
AllowAnalytics = true,
AllowCustomDomain = true,
AllowMultipleDomains = false,
PrioritySupport = false,
PlanType = "professional"
},
"premium" => new Models.PlanLimitations
{
MaxLinks = -1, // Unlimited
AllowCustomThemes = true,
AllowAnalytics = true,
AllowCustomDomain = true,
AllowMultipleDomains = true,
PrioritySupport = true,
PlanType = "premium"
},
_ => new Models.PlanLimitations
{
MaxLinks = 5,
AllowCustomThemes = false,
AllowAnalytics = false,
AllowCustomDomain = false,
AllowMultipleDomains = false,
PrioritySupport = false,
PlanType = "free"
}
};
}
}

View File

@ -0,0 +1,32 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class Category
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("slug")]
public string Slug { get; set; } = string.Empty;
[BsonElement("icon")]
public string Icon { get; set; } = string.Empty;
[BsonElement("seoKeywords")]
public List<string> SeoKeywords { get; set; } = new();
[BsonElement("description")]
public string Description { get; set; } = string.Empty;
[BsonElement("isActive")]
public bool IsActive { get; set; } = true;
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,30 @@
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class LinkItem
{
[BsonElement("title")]
public string Title { get; set; } = string.Empty;
[BsonElement("url")]
public string Url { get; set; } = string.Empty;
[BsonElement("description")]
public string Description { get; set; } = string.Empty;
[BsonElement("icon")]
public string Icon { get; set; } = string.Empty;
[BsonElement("isActive")]
public bool IsActive { get; set; } = true;
[BsonElement("order")]
public int Order { get; set; }
[BsonElement("clicks")]
public int Clicks { get; set; } = 0;
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,30 @@
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class PageAnalytics
{
[BsonElement("totalViews")]
public int TotalViews { get; set; } = 0;
[BsonElement("totalClicks")]
public int TotalClicks { get; set; } = 0;
[BsonElement("lastViewedAt")]
public DateTime? LastViewedAt { get; set; }
[BsonElement("monthlyViews")]
public Dictionary<string, int> MonthlyViews { get; set; } = new(); // "2024-01" -> count
[BsonElement("monthlyClicks")]
public Dictionary<string, int> MonthlyClicks { get; set; } = new();
[BsonElement("topReferrers")]
public Dictionary<string, int> TopReferrers { get; set; } = new();
[BsonElement("deviceStats")]
public Dictionary<string, int> DeviceStats { get; set; } = new(); // mobile, desktop, tablet
[BsonElement("countryStats")]
public Dictionary<string, int> CountryStats { get; set; } = new();
}

View File

@ -0,0 +1,41 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class PageTheme
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("primaryColor")]
public string PrimaryColor { get; set; } = "#007bff";
[BsonElement("secondaryColor")]
public string SecondaryColor { get; set; } = "#6c757d";
[BsonElement("backgroundColor")]
public string BackgroundColor { get; set; } = "#ffffff";
[BsonElement("textColor")]
public string TextColor { get; set; } = "#212529";
[BsonElement("backgroundImage")]
public string BackgroundImage { get; set; } = string.Empty;
[BsonElement("isPremium")]
public bool IsPremium { get; set; } = false;
[BsonElement("cssTemplate")]
public string CssTemplate { get; set; } = string.Empty;
[BsonElement("isActive")]
public bool IsActive { get; set; } = true;
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,27 @@
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class PlanLimitations
{
[BsonElement("maxLinks")]
public int MaxLinks { get; set; } = 5;
[BsonElement("allowCustomThemes")]
public bool AllowCustomThemes { get; set; } = false;
[BsonElement("allowAnalytics")]
public bool AllowAnalytics { get; set; } = false;
[BsonElement("allowCustomDomain")]
public bool AllowCustomDomain { get; set; } = false;
[BsonElement("allowMultipleDomains")]
public bool AllowMultipleDomains { get; set; } = false;
[BsonElement("prioritySupport")]
public bool PrioritySupport { get; set; } = false;
[BsonElement("planType")]
public string PlanType { get; set; } = "free";
}

View File

@ -0,0 +1,94 @@
namespace BCards.Web.Models;
public enum PlanType
{
Trial = 0, // Gratuito por 7 dias
Basic = 1, // R$ 9,90
Professional = 2, // R$ 24,90 (Decoy)
Premium = 3 // R$ 29,90
}
public static class PlanTypeExtensions
{
public static string GetDisplayName(this PlanType planType)
{
return planType switch
{
PlanType.Trial => "Trial Gratuito",
PlanType.Basic => "Básico",
PlanType.Professional => "Profissional",
PlanType.Premium => "Premium",
_ => "Desconhecido"
};
}
public static decimal GetPrice(this PlanType planType)
{
return planType switch
{
PlanType.Trial => 0.00m,
PlanType.Basic => 9.90m,
PlanType.Professional => 24.90m,
PlanType.Premium => 29.90m,
_ => 0.00m
};
}
public static int GetMaxPages(this PlanType planType)
{
return planType switch
{
PlanType.Trial => 1,
PlanType.Basic => 3,
PlanType.Professional => 5, // DECOY - not attractive
PlanType.Premium => 15,
_ => 1
};
}
public static int GetMaxLinksPerPage(this PlanType planType)
{
return planType switch
{
PlanType.Trial => 3,
PlanType.Basic => 8,
PlanType.Professional => 20, // DECOY - too expensive for the benefit
PlanType.Premium => int.MaxValue, // Unlimited
_ => 3
};
}
public static int GetMaxLinks(this PlanType planType)
{
return GetMaxLinksPerPage(planType);
}
public static bool AllowsAnalytics(this PlanType planType)
{
return planType switch
{
PlanType.Trial => false,
PlanType.Basic => true,
PlanType.Professional => true,
PlanType.Premium => true,
_ => false
};
}
public static bool AllowsCustomThemes(this PlanType planType)
{
return planType switch
{
PlanType.Trial => false,
PlanType.Basic => false,
PlanType.Professional => true,
PlanType.Premium => true,
_ => false
};
}
public static int GetTrialDays(this PlanType planType)
{
return planType == PlanType.Trial ? 7 : 0;
}
}

View File

@ -0,0 +1,30 @@
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class SeoSettings
{
[BsonElement("title")]
public string Title { get; set; } = string.Empty;
[BsonElement("description")]
public string Description { get; set; } = string.Empty;
[BsonElement("keywords")]
public List<string> Keywords { get; set; } = new();
[BsonElement("ogImage")]
public string OgImage { get; set; } = string.Empty;
[BsonElement("ogTitle")]
public string OgTitle { get; set; } = string.Empty;
[BsonElement("ogDescription")]
public string OgDescription { get; set; } = string.Empty;
[BsonElement("twitterCard")]
public string TwitterCard { get; set; } = "summary_large_image";
[BsonElement("canonicalUrl")]
public string CanonicalUrl { get; set; } = string.Empty;
}

View File

@ -0,0 +1,57 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class Subscription
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
[BsonElement("userId")]
[BsonRepresentation(BsonType.ObjectId)]
public string UserId { get; set; } = string.Empty;
[BsonElement("stripeSubscriptionId")]
public string StripeSubscriptionId { get; set; } = string.Empty;
[BsonElement("planType")]
public string PlanType { get; set; } = "free";
[BsonElement("status")]
public string Status { get; set; } = "active";
[BsonElement("currentPeriodStart")]
public DateTime CurrentPeriodStart { get; set; }
[BsonElement("currentPeriodEnd")]
public DateTime CurrentPeriodEnd { get; set; }
[BsonElement("cancelAtPeriodEnd")]
public bool CancelAtPeriodEnd { get; set; } = false;
[BsonElement("maxLinks")]
public int MaxLinks { get; set; } = 5;
[BsonElement("allowCustomThemes")]
public bool AllowCustomThemes { get; set; } = false;
[BsonElement("allowAnalytics")]
public bool AllowAnalytics { get; set; } = false;
[BsonElement("allowCustomDomain")]
public bool AllowCustomDomain { get; set; } = false;
[BsonElement("allowMultipleDomains")]
public bool AllowMultipleDomains { get; set; } = false;
[BsonElement("prioritySupport")]
public bool PrioritySupport { get; set; } = false;
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[BsonElement("updatedAt")]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,44 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class User
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
[BsonElement("email")]
public string Email { get; set; } = string.Empty;
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("profileImage")]
public string ProfileImage { get; set; } = string.Empty;
[BsonElement("authProvider")]
public string AuthProvider { get; set; } = string.Empty;
[BsonElement("stripeCustomerId")]
public string StripeCustomerId { get; set; } = string.Empty;
[BsonElement("subscriptionStatus")]
public string SubscriptionStatus { get; set; } = "free";
[BsonElement("currentPlan")]
public string CurrentPlan { get; set; } = "free";
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[BsonElement("updatedAt")]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
[BsonElement("isActive")]
public bool IsActive { get; set; } = true;
[BsonElement("notifiedOfExpiration")]
public bool NotifiedOfExpiration { get; set; } = false;
}

View File

@ -0,0 +1,69 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using BCards.Web.ViewModels;
namespace BCards.Web.Models;
public class UserPage
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
[BsonElement("userId")]
[BsonRepresentation(BsonType.ObjectId)]
public string UserId { get; set; } = string.Empty;
[BsonElement("slug")]
public string Slug { get; set; } = string.Empty;
[BsonElement("category")]
public string Category { get; set; } = string.Empty;
[BsonElement("businessType")]
public string BusinessType { get; set; } = "individual"; // individual, company
[BsonElement("displayName")]
public string DisplayName { get; set; } = string.Empty;
[BsonElement("bio")]
public string Bio { get; set; } = string.Empty;
[BsonElement("profileImage")]
public string ProfileImage { get; set; } = string.Empty;
[BsonElement("theme")]
public PageTheme Theme { get; set; } = new();
[BsonElement("links")]
public List<LinkItem> Links { get; set; } = new();
[BsonElement("seoSettings")]
public SeoSettings SeoSettings { get; set; } = new();
[BsonElement("language")]
public string Language { get; set; } = "pt-BR";
[BsonElement("analytics")]
public PageAnalytics Analytics { get; set; } = new();
[BsonElement("isActive")]
public bool IsActive { get; set; } = true;
[BsonElement("planLimitations")]
public PlanLimitations PlanLimitations { get; set; } = new();
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[BsonElement("updatedAt")]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
[BsonElement("publishedAt")]
public DateTime? PublishedAt { get; set; }
[BsonElement("status")]
public PageStatus Status { get; set; } = PageStatus.Active;
public string FullUrl => $"page/{Category}/{Slug}";
}

202
src/BCards.Web/Program.cs Normal file
View File

@ -0,0 +1,202 @@
using BCards.Web.Configuration;
using BCards.Web.Services;
using BCards.Web.Repositories;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authentication.MicrosoftAccount;
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using System.Globalization;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews()
.AddRazorRuntimeCompilation()
.AddViewLocalization()
.AddDataAnnotationsLocalization();
// MongoDB Configuration
builder.Services.Configure<MongoDbSettings>(
builder.Configuration.GetSection("MongoDb"));
builder.Services.AddSingleton<IMongoClient>(serviceProvider =>
{
var settings = serviceProvider.GetRequiredService<IOptions<MongoDbSettings>>().Value;
return new MongoClient(settings.ConnectionString);
});
builder.Services.AddScoped(serviceProvider =>
{
var client = serviceProvider.GetRequiredService<IMongoClient>();
var settings = serviceProvider.GetRequiredService<IOptions<MongoDbSettings>>().Value;
return client.GetDatabase(settings.DatabaseName);
});
// Stripe Configuration
builder.Services.Configure<StripeSettings>(
builder.Configuration.GetSection("Stripe"));
// OAuth Configuration
builder.Services.Configure<GoogleAuthSettings>(
builder.Configuration.GetSection("Authentication:Google"));
builder.Services.Configure<MicrosoftAuthSettings>(
builder.Configuration.GetSection("Authentication:Microsoft"));
// Authentication
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/Auth/Login";
options.LogoutPath = "/Auth/Logout";
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
})
.AddGoogle(options =>
{
var googleAuth = builder.Configuration.GetSection("Authentication:Google");
options.ClientId = googleAuth["ClientId"] ?? "";
options.ClientSecret = googleAuth["ClientSecret"] ?? "";
})
.AddMicrosoftAccount(options =>
{
var msAuth = builder.Configuration.GetSection("Authentication:Microsoft");
options.ClientId = msAuth["ClientId"] ?? "";
options.ClientSecret = msAuth["ClientSecret"] ?? "";
});
// Localization
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
var supportedCultures = new[]
{
new CultureInfo("pt-BR"),
new CultureInfo("es-ES")
};
options.DefaultRequestCulture = new RequestCulture("pt-BR");
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
});
// Register Services
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IUserPageRepository, UserPageRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
builder.Services.AddScoped<IUserPageService, UserPageService>();
builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<ISeoService, SeoService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IPaymentService, PaymentService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
// Background Services
builder.Services.AddHostedService<TrialExpirationService>();
// Response Caching
builder.Services.AddResponseCaching();
builder.Services.AddMemoryCache();
builder.Services.AddRazorPages();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseRequestLocalization();
app.UseAuthentication();
app.UseAuthorization();
// Add custom middleware
app.UseMiddleware<BCards.Web.Middleware.PlanLimitationMiddleware>();
app.UseMiddleware<BCards.Web.Middleware.PageStatusMiddleware>();
app.UseResponseCaching();
// Rota padr<64>o primeiro (mais espec<65>fica)
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
//Rota customizada depois (mais gen<65>rica)
//app.MapControllerRoute(
// name: "userpage",
// pattern: "page/{category}/{slug}",
// defaults: new { controller = "UserPage", action = "Display" },
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
// Rota para preview
app.MapControllerRoute(
name: "userpage-preview",
pattern: "page/preview/{category}/{slug}",
defaults: new { controller = "UserPage", action = "Preview" },
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
// Rota para click
app.MapControllerRoute(
name: "userpage-click",
pattern: "page/click/{pageId}",
defaults: new { controller = "UserPage", action = "RecordClick" });
// Rota principal (deve vir por último)
app.MapControllerRoute(
name: "userpage",
pattern: "page/{category}/{slug}",
defaults: new { controller = "UserPage", action = "Display" },
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
// Rota padrão
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// Initialize default data
using (var scope = app.Services.CreateScope())
{
var themeService = scope.ServiceProvider.GetRequiredService<IThemeService>();
var categoryService = scope.ServiceProvider.GetRequiredService<ICategoryService>();
try
{
// Initialize themes
var existingThemes = await themeService.GetAvailableThemesAsync();
if (!existingThemes.Any())
{
await themeService.InitializeDefaultThemesAsync();
}
// Initialize categories
var existingCategories = await categoryService.GetAllCategoriesAsync();
if (!existingCategories.Any())
{
await categoryService.InitializeDefaultCategoriesAsync();
}
}
catch (Exception ex)
{
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "Error initializing default data");
}
}
app.Run();

View File

@ -0,0 +1,12 @@
{
"profiles": {
"BCards.Web": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:49178;http://localhost:49179"
}
}
}

View File

@ -0,0 +1,70 @@
using BCards.Web.Models;
using MongoDB.Driver;
namespace BCards.Web.Repositories;
public class CategoryRepository : ICategoryRepository
{
private readonly IMongoCollection<Category> _categories;
public CategoryRepository(IMongoDatabase database)
{
_categories = database.GetCollection<Category>("categories");
// Create indexes
var slugIndex = Builders<Category>.IndexKeys.Ascending(x => x.Slug);
_categories.Indexes.CreateOneAsync(new CreateIndexModel<Category>(slugIndex, new CreateIndexOptions { Unique = true }));
}
public async Task<List<Category>> GetAllActiveAsync()
{
return await _categories.Find(x => x.IsActive).SortBy(x => x.Name).ToListAsync();
}
public async Task<Category?> GetBySlugAsync(string slug)
{
return await _categories.Find(x => x.Slug == slug && x.IsActive).FirstOrDefaultAsync();
}
public async Task<Category?> GetByIdAsync(string id)
{
return await _categories.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync();
}
public async Task<Category> CreateAsync(Category category)
{
category.CreatedAt = DateTime.UtcNow;
await _categories.InsertOneAsync(category);
return category;
}
public async Task<Category> UpdateAsync(Category category)
{
await _categories.ReplaceOneAsync(x => x.Id == category.Id, category);
return category;
}
public async Task DeleteAsync(string id)
{
await _categories.UpdateOneAsync(
x => x.Id == id,
Builders<Category>.Update.Set(x => x.IsActive, false)
);
}
public async Task<bool> SlugExistsAsync(string slug, string? excludeId = null)
{
var filter = Builders<Category>.Filter.And(
Builders<Category>.Filter.Eq(x => x.Slug, slug),
Builders<Category>.Filter.Eq(x => x.IsActive, true)
);
if (!string.IsNullOrEmpty(excludeId))
{
filter = Builders<Category>.Filter.And(filter,
Builders<Category>.Filter.Ne(x => x.Id, excludeId));
}
return await _categories.Find(filter).AnyAsync();
}
}

View File

@ -0,0 +1,14 @@
using BCards.Web.Models;
namespace BCards.Web.Repositories;
public interface ICategoryRepository
{
Task<List<Category>> GetAllActiveAsync();
Task<Category?> GetBySlugAsync(string slug);
Task<Category?> GetByIdAsync(string id);
Task<Category> CreateAsync(Category category);
Task<Category> UpdateAsync(Category category);
Task DeleteAsync(string id);
Task<bool> SlugExistsAsync(string slug, string? excludeId = null);
}

View File

@ -0,0 +1,14 @@
using BCards.Web.Models;
namespace BCards.Web.Repositories;
public interface ISubscriptionRepository
{
Task<Subscription?> GetByUserIdAsync(string userId);
Task<Subscription?> GetByStripeSubscriptionIdAsync(string stripeSubscriptionId);
Task<Subscription> CreateAsync(Subscription subscription);
Task<Subscription> UpdateAsync(Subscription subscription);
Task DeleteAsync(string id);
Task<List<Subscription>> GetExpiringSoonAsync(int days = 7);
Task<List<Subscription>> GetTrialSubscriptionsAsync();
}

View File

@ -0,0 +1,19 @@
using BCards.Web.Models;
namespace BCards.Web.Repositories;
public interface IUserPageRepository
{
Task<UserPage?> GetByIdAsync(string id);
Task<UserPage?> GetBySlugAsync(string category, string slug);
Task<UserPage?> GetByUserIdAsync(string userId);
Task<List<UserPage>> GetByUserIdAllAsync(string userId);
Task<List<UserPage>> GetActivePagesAsync();
Task<UserPage> CreateAsync(UserPage userPage);
Task<UserPage> UpdateAsync(UserPage userPage);
Task DeleteAsync(string id);
Task<bool> SlugExistsAsync(string category, string slug, string? excludeId = null);
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
}

View File

@ -0,0 +1,13 @@
using BCards.Web.Models;
namespace BCards.Web.Repositories;
public interface IUserRepository
{
Task<User?> GetByIdAsync(string id);
Task<User?> GetByEmailAsync(string email);
Task<User> CreateAsync(User user);
Task<User> UpdateAsync(User user);
Task DeleteAsync(string id);
Task<bool> ExistsAsync(string email);
}

View File

@ -0,0 +1,64 @@
using BCards.Web.Models;
using MongoDB.Driver;
namespace BCards.Web.Repositories;
public class SubscriptionRepository : ISubscriptionRepository
{
private readonly IMongoCollection<Subscription> _subscriptions;
public SubscriptionRepository(IMongoDatabase database)
{
_subscriptions = database.GetCollection<Subscription>("subscriptions");
// Create indexes
var userIndex = Builders<Subscription>.IndexKeys.Ascending(x => x.UserId);
_subscriptions.Indexes.CreateOneAsync(new CreateIndexModel<Subscription>(userIndex));
var stripeIndex = Builders<Subscription>.IndexKeys.Ascending(x => x.StripeSubscriptionId);
_subscriptions.Indexes.CreateOneAsync(new CreateIndexModel<Subscription>(stripeIndex));
}
public async Task<Subscription?> GetByUserIdAsync(string userId)
{
return await _subscriptions.Find(x => x.UserId == userId).FirstOrDefaultAsync();
}
public async Task<Subscription?> GetByStripeSubscriptionIdAsync(string stripeSubscriptionId)
{
return await _subscriptions.Find(x => x.StripeSubscriptionId == stripeSubscriptionId).FirstOrDefaultAsync();
}
public async Task<Subscription> CreateAsync(Subscription subscription)
{
subscription.CreatedAt = DateTime.UtcNow;
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptions.InsertOneAsync(subscription);
return subscription;
}
public async Task<Subscription> UpdateAsync(Subscription subscription)
{
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptions.ReplaceOneAsync(x => x.Id == subscription.Id, subscription);
return subscription;
}
public async Task DeleteAsync(string id)
{
await _subscriptions.DeleteOneAsync(x => x.Id == id);
}
public async Task<List<Subscription>> GetExpiringSoonAsync(int days = 7)
{
var cutoffDate = DateTime.UtcNow.AddDays(days);
return await _subscriptions.Find(x => x.CurrentPeriodEnd <= cutoffDate && x.Status == "active")
.ToListAsync();
}
public async Task<List<Subscription>> GetTrialSubscriptionsAsync()
{
return await _subscriptions.Find(x => x.PlanType == "trial" && x.Status == "active")
.ToListAsync();
}
}

View File

@ -0,0 +1,119 @@
using BCards.Web.Models;
using MongoDB.Driver;
namespace BCards.Web.Repositories;
public class UserPageRepository : IUserPageRepository
{
private readonly IMongoCollection<UserPage> _pages;
public UserPageRepository(IMongoDatabase database)
{
_pages = database.GetCollection<UserPage>("userpages");
// Create indexes
var slugIndex = Builders<UserPage>.IndexKeys
.Ascending(x => x.Category)
.Ascending(x => x.Slug);
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(slugIndex, new CreateIndexOptions { Unique = true }));
var userIndex = Builders<UserPage>.IndexKeys.Ascending(x => x.UserId);
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(userIndex));
var categoryIndex = Builders<UserPage>.IndexKeys.Ascending(x => x.Category);
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(categoryIndex));
}
public async Task<UserPage?> GetByIdAsync(string id)
{
return await _pages.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync();
}
public async Task<UserPage?> GetBySlugAsync(string category, string slug)
{
return await _pages.Find(x => x.Category == category.ToLower() && x.Slug == slug && x.IsActive).FirstOrDefaultAsync();
}
public async Task<UserPage?> GetByUserIdAsync(string userId)
{
return await _pages.Find(x => x.UserId == userId && x.IsActive).FirstOrDefaultAsync();
}
public async Task<List<UserPage>> GetByUserIdAllAsync(string userId)
{
return await _pages.Find(x => x.UserId == userId && x.IsActive).ToListAsync();
}
public async Task<UserPage> CreateAsync(UserPage userPage)
{
userPage.CreatedAt = DateTime.UtcNow;
userPage.UpdatedAt = DateTime.UtcNow;
await _pages.InsertOneAsync(userPage);
return userPage;
}
public async Task<UserPage> UpdateAsync(UserPage userPage)
{
userPage.UpdatedAt = DateTime.UtcNow;
await _pages.ReplaceOneAsync(x => x.Id == userPage.Id, userPage);
return userPage;
}
public async Task DeleteAsync(string id)
{
await _pages.UpdateOneAsync(
x => x.Id == id,
Builders<UserPage>.Update.Set(x => x.IsActive, false).Set(x => x.UpdatedAt, DateTime.UtcNow)
);
}
public async Task<bool> SlugExistsAsync(string category, string slug, string? excludeId = null)
{
var filter = Builders<UserPage>.Filter.And(
Builders<UserPage>.Filter.Eq(x => x.Category, category),
Builders<UserPage>.Filter.Eq(x => x.Slug, slug),
Builders<UserPage>.Filter.Eq(x => x.IsActive, true)
);
if (!string.IsNullOrEmpty(excludeId))
{
filter = Builders<UserPage>.Filter.And(filter,
Builders<UserPage>.Filter.Ne(x => x.Id, excludeId));
}
return await _pages.Find(filter).AnyAsync();
}
public async Task<List<UserPage>> GetRecentPagesAsync(int limit = 10)
{
return await _pages.Find(x => x.IsActive && x.PublishedAt != null)
.SortByDescending(x => x.PublishedAt)
.Limit(limit)
.ToListAsync();
}
public async Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20)
{
return await _pages.Find(x => x.Category == category && x.IsActive && x.PublishedAt != null)
.SortByDescending(x => x.PublishedAt)
.Limit(limit)
.ToListAsync();
}
public async Task<List<UserPage>> GetActivePagesAsync()
{
return await _pages.Find(x => x.IsActive && x.Status == BCards.Web.ViewModels.PageStatus.Active)
.SortByDescending(x => x.UpdatedAt)
.ToListAsync();
}
public async Task UpdateAnalyticsAsync(string id, PageAnalytics analytics)
{
await _pages.UpdateOneAsync(
x => x.Id == id,
Builders<UserPage>.Update
.Set(x => x.Analytics, analytics)
.Set(x => x.UpdatedAt, DateTime.UtcNow)
);
}
}

View File

@ -0,0 +1,57 @@
using BCards.Web.Models;
using MongoDB.Driver;
namespace BCards.Web.Repositories;
public class UserRepository : IUserRepository
{
private readonly IMongoCollection<User> _users;
public UserRepository(IMongoDatabase database)
{
_users = database.GetCollection<User>("users");
// Create indexes
var indexKeys = Builders<User>.IndexKeys.Ascending(x => x.Email);
var indexOptions = new CreateIndexOptions { Unique = true };
_users.Indexes.CreateOneAsync(new CreateIndexModel<User>(indexKeys, indexOptions));
}
public async Task<User?> GetByIdAsync(string id)
{
return await _users.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync();
}
public async Task<User?> GetByEmailAsync(string email)
{
return await _users.Find(x => x.Email == email && x.IsActive).FirstOrDefaultAsync();
}
public async Task<User> CreateAsync(User user)
{
user.CreatedAt = DateTime.UtcNow;
user.UpdatedAt = DateTime.UtcNow;
await _users.InsertOneAsync(user);
return user;
}
public async Task<User> UpdateAsync(User user)
{
user.UpdatedAt = DateTime.UtcNow;
await _users.ReplaceOneAsync(x => x.Id == user.Id, user);
return user;
}
public async Task DeleteAsync(string id)
{
await _users.UpdateOneAsync(
x => x.Id == id,
Builders<User>.Update.Set(x => x.IsActive, false).Set(x => x.UpdatedAt, DateTime.UtcNow)
);
}
public async Task<bool> ExistsAsync(string email)
{
return await _users.Find(x => x.Email == email && x.IsActive).AnyAsync();
}
}

View File

@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Home" xml:space="preserve">
<value>Início</value>
</data>
<data name="Plans" xml:space="preserve">
<value>Planos</value>
</data>
<data name="Login" xml:space="preserve">
<value>Entrar</value>
</data>
<data name="Dashboard" xml:space="preserve">
<value>Dashboard</value>
</data>
<data name="Logout" xml:space="preserve">
<value>Sair</value>
</data>
<data name="CreatePage" xml:space="preserve">
<value>Criar Página</value>
</data>
<data name="EditPage" xml:space="preserve">
<value>Editar Página</value>
</data>
<data name="Save" xml:space="preserve">
<value>Salvar</value>
</data>
<data name="Cancel" xml:space="preserve">
<value>Cancelar</value>
</data>
<data name="Delete" xml:space="preserve">
<value>Excluir</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nome</value>
</data>
<data name="Email" xml:space="preserve">
<value>E-mail</value>
</data>
<data name="Bio" xml:space="preserve">
<value>Biografia</value>
</data>
<data name="Category" xml:space="preserve">
<value>Categoria</value>
</data>
<data name="Slug" xml:space="preserve">
<value>URL</value>
</data>
<data name="Links" xml:space="preserve">
<value>Links</value>
</data>
<data name="Title" xml:space="preserve">
<value>Título</value>
</data>
<data name="URL" xml:space="preserve">
<value>URL</value>
</data>
<data name="Description" xml:space="preserve">
<value>Descrição</value>
</data>
<data name="Theme" xml:space="preserve">
<value>Tema</value>
</data>
<data name="Analytics" xml:space="preserve">
<value>Analytics</value>
</data>
<data name="Views" xml:space="preserve">
<value>Visualizações</value>
</data>
<data name="Clicks" xml:space="preserve">
<value>Cliques</value>
</data>
</root>

View File

@ -0,0 +1,66 @@
using BCards.Web.Models;
using BCards.Web.Repositories;
using System.Security.Claims;
namespace BCards.Web.Services;
public class AuthService : IAuthService
{
private readonly IUserRepository _userRepository;
public AuthService(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<User> CreateOrUpdateUserFromClaimsAsync(ClaimsPrincipal claimsPrincipal)
{
var email = claimsPrincipal.FindFirst(ClaimTypes.Email)?.Value ?? "";
var name = claimsPrincipal.FindFirst(ClaimTypes.Name)?.Value ?? "";
var provider = claimsPrincipal.Identity?.AuthenticationType ?? "";
var profileImage = claimsPrincipal.FindFirst("picture")?.Value ?? "";
var existingUser = await _userRepository.GetByEmailAsync(email);
if (existingUser != null)
{
// Update existing user
existingUser.Name = name;
existingUser.ProfileImage = profileImage;
existingUser.UpdatedAt = DateTime.UtcNow;
return await _userRepository.UpdateAsync(existingUser);
}
// Create new user
var newUser = new User
{
Email = email,
Name = name,
ProfileImage = profileImage,
AuthProvider = provider,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
return await _userRepository.CreateAsync(newUser);
}
public async Task<User?> GetCurrentUserAsync(ClaimsPrincipal claimsPrincipal)
{
var email = claimsPrincipal.FindFirst(ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(email))
return null;
return await _userRepository.GetByEmailAsync(email);
}
public async Task<User?> GetUserByIdAsync(string userId)
{
return await _userRepository.GetByIdAsync(userId);
}
public async Task<User> UpdateUserAsync(User user)
{
return await _userRepository.UpdateAsync(user);
}
}

View File

@ -0,0 +1,191 @@
using BCards.Web.Models;
using BCards.Web.Repositories;
using System.Text.RegularExpressions;
using System.Globalization;
using System.Text;
namespace BCards.Web.Services;
public class CategoryService : ICategoryService
{
private readonly ICategoryRepository _categoryRepository;
public CategoryService(ICategoryRepository categoryRepository)
{
_categoryRepository = categoryRepository;
}
public async Task<List<Category>> GetAllCategoriesAsync()
{
return await _categoryRepository.GetAllActiveAsync();
}
public async Task<Category?> GetCategoryBySlugAsync(string slug)
{
return await _categoryRepository.GetBySlugAsync(slug);
}
public async Task<string> GenerateSlugAsync(string name)
{
var slug = GenerateSlug(name);
var originalSlug = slug;
var counter = 1;
while (await _categoryRepository.SlugExistsAsync(slug))
{
slug = $"{originalSlug}-{counter}";
counter++;
}
return slug;
}
public async Task<bool> ValidateSlugAsync(string slug, string? excludeId = null)
{
if (string.IsNullOrWhiteSpace(slug))
return false;
if (!IsValidSlugFormat(slug))
return false;
return !await _categoryRepository.SlugExistsAsync(slug, excludeId);
}
public async Task InitializeDefaultCategoriesAsync()
{
var categories = await _categoryRepository.GetAllActiveAsync();
if (categories.Any()) return;
var defaultCategories = new[]
{
new Category
{
Name = "Corretor de Imóveis",
Slug = "corretor",
Icon = "🏠",
Description = "Profissionais especializados em compra, venda e locação de imóveis",
SeoKeywords = new List<string> { "corretor", "imóveis", "casa", "apartamento", "venda", "locação" }
},
new Category
{
Name = "Tecnologia",
Slug = "tecnologia",
Icon = "💻",
Description = "Empresas e profissionais de tecnologia, desenvolvimento e TI",
SeoKeywords = new List<string> { "desenvolvimento", "software", "programação", "tecnologia", "TI" }
},
new Category
{
Name = "Saúde",
Slug = "saude",
Icon = "🏥",
Description = "Profissionais da saúde, clínicas e consultórios médicos",
SeoKeywords = new List<string> { "médico", "saúde", "clínica", "consulta", "tratamento" }
},
new Category
{
Name = "Educação",
Slug = "educacao",
Icon = "📚",
Description = "Professores, escolas, cursos e instituições de ensino",
SeoKeywords = new List<string> { "educação", "ensino", "professor", "curso", "escola" }
},
new Category
{
Name = "Comércio",
Slug = "comercio",
Icon = "🛍️",
Description = "Lojas, e-commerce e estabelecimentos comerciais",
SeoKeywords = new List<string> { "loja", "comércio", "venda", "produtos", "e-commerce" }
},
new Category
{
Name = "Serviços",
Slug = "servicos",
Icon = "🔧",
Description = "Prestadores de serviços gerais e especializados",
SeoKeywords = new List<string> { "serviços", "prestador", "profissional", "especializado" }
},
new Category
{
Name = "Alimentação",
Slug = "alimentacao",
Icon = "🍽️",
Description = "Restaurantes, delivery, food trucks e estabelecimentos alimentícios",
SeoKeywords = new List<string> { "restaurante", "comida", "delivery", "alimentação", "gastronomia" }
},
new Category
{
Name = "Beleza",
Slug = "beleza",
Icon = "💄",
Description = "Salões de beleza, barbearias, estética e cuidados pessoais",
SeoKeywords = new List<string> { "beleza", "salão", "estética", "cabeleireiro", "manicure" }
},
new Category
{
Name = "Advocacia",
Slug = "advocacia",
Icon = "⚖️",
Description = "Advogados, escritórios jurídicos e consultoria legal",
SeoKeywords = new List<string> { "advogado", "jurídico", "direito", "advocacia", "legal" }
},
new Category
{
Name = "Arquitetura",
Slug = "arquitetura",
Icon = "🏗️",
Description = "Arquitetos, engenheiros e profissionais da construção",
SeoKeywords = new List<string> { "arquiteto", "engenheiro", "construção", "projeto", "reforma" }
}
};
foreach (var category in defaultCategories)
{
await _categoryRepository.CreateAsync(category);
}
}
private static string GenerateSlug(string text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
// Remove acentos
text = RemoveDiacritics(text);
// Converter para minúsculas
text = text.ToLowerInvariant();
// Substituir espaços e caracteres especiais por hífens
text = Regex.Replace(text, @"[^a-z0-9\s-]", "");
text = Regex.Replace(text, @"[\s-]+", "-");
// Remover hífens do início e fim
text = text.Trim('-');
return text;
}
private static string RemoveDiacritics(string text)
{
var normalizedString = text.Normalize(NormalizationForm.FormD);
var stringBuilder = new StringBuilder();
foreach (var c in normalizedString)
{
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
{
stringBuilder.Append(c);
}
}
return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
}
private static bool IsValidSlugFormat(string slug)
{
return Regex.IsMatch(slug, @"^[a-z0-9-]+$") && !slug.StartsWith('-') && !slug.EndsWith('-');
}
}

View File

@ -0,0 +1,12 @@
using BCards.Web.Models;
using System.Security.Claims;
namespace BCards.Web.Services;
public interface IAuthService
{
Task<User> CreateOrUpdateUserFromClaimsAsync(ClaimsPrincipal claimsPrincipal);
Task<User?> GetCurrentUserAsync(ClaimsPrincipal claimsPrincipal);
Task<User?> GetUserByIdAsync(string userId);
Task<User> UpdateUserAsync(User user);
}

View File

@ -0,0 +1,12 @@
using BCards.Web.Models;
namespace BCards.Web.Services;
public interface ICategoryService
{
Task<List<Category>> GetAllCategoriesAsync();
Task<Category?> GetCategoryBySlugAsync(string slug);
Task InitializeDefaultCategoriesAsync();
Task<string> GenerateSlugAsync(string name);
Task<bool> ValidateSlugAsync(string slug, string? excludeId = null);
}

View File

@ -0,0 +1,15 @@
using BCards.Web.Models;
using Stripe;
namespace BCards.Web.Services;
public interface IPaymentService
{
Task<string> CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl);
Task<Customer> CreateOrGetCustomerAsync(string userId, string email, string name);
Task<Stripe.Subscription> HandleWebhookAsync(string requestBody, string signature);
Task<List<Price>> GetPricesAsync();
Task<bool> CancelSubscriptionAsync(string subscriptionId);
Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId);
Task<PlanLimitations> GetPlanLimitationsAsync(string planType);
}

View File

@ -0,0 +1,12 @@
using BCards.Web.Models;
namespace BCards.Web.Services;
public interface ISeoService
{
SeoSettings GenerateSeoSettings(UserPage userPage, Category category);
string GeneratePageTitle(UserPage userPage, Category category);
string GeneratePageDescription(UserPage userPage, Category category);
List<string> GenerateKeywords(UserPage userPage, Category category);
string GenerateCanonicalUrl(UserPage userPage);
}

View File

@ -0,0 +1,13 @@
using BCards.Web.Models;
namespace BCards.Web.Services;
public interface IThemeService
{
Task<List<PageTheme>> GetAvailableThemesAsync();
Task<PageTheme?> GetThemeByIdAsync(string themeId);
Task<PageTheme?> GetThemeByNameAsync(string themeName);
Task<string> GenerateCustomCssAsync(PageTheme theme);
Task InitializeDefaultThemesAsync();
PageTheme GetDefaultTheme();
}

View File

@ -0,0 +1,22 @@
using BCards.Web.Models;
namespace BCards.Web.Services;
public interface IUserPageService
{
Task<UserPage?> GetPageAsync(string category, string slug);
Task<UserPage?> GetUserPageAsync(string userId);
Task<UserPage?> GetPageByIdAsync(string id);
Task<List<UserPage>> GetUserPagesAsync(string userId);
Task<List<UserPage>> GetActivePagesAsync();
Task<UserPage> CreatePageAsync(UserPage userPage);
Task<UserPage> UpdatePageAsync(UserPage userPage);
Task DeletePageAsync(string id);
Task<bool> ValidateSlugAsync(string category, string slug, string? excludeId = null);
Task<string> GenerateSlugAsync(string category, string name);
Task<bool> CanCreateLinksAsync(string userId, int newLinksCount = 1);
Task RecordPageViewAsync(string pageId, string? referrer = null, string? userAgent = null);
Task RecordLinkClickAsync(string pageId, int linkIndex);
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
Task<List<UserPage>> GetPagesByCategoryAsync(string category, int limit = 20);
}

View File

@ -0,0 +1,322 @@
using BCards.Web.Configuration;
using BCards.Web.Models;
using BCards.Web.Repositories;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
namespace BCards.Web.Services;
public class PaymentService : IPaymentService
{
private readonly StripeSettings _stripeSettings;
private readonly IUserRepository _userRepository;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly IConfiguration _configuration;
public PaymentService(
IOptions<StripeSettings> stripeSettings,
IUserRepository userRepository,
ISubscriptionRepository subscriptionRepository,
IConfiguration configuration)
{
_stripeSettings = stripeSettings.Value;
_userRepository = userRepository;
_subscriptionRepository = subscriptionRepository;
_configuration = configuration;
StripeConfiguration.ApiKey = _stripeSettings.SecretKey;
}
public async Task<string> CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null) throw new InvalidOperationException("User not found");
var planConfig = _configuration.GetSection($"Plans:{planType}");
var priceId = planConfig["PriceId"];
if (string.IsNullOrEmpty(priceId))
throw new InvalidOperationException($"Price ID not found for plan: {planType}");
var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name);
var options = new SessionCreateOptions
{
PaymentMethodTypes = new List<string> { "card" },
Mode = "subscription",
Customer = customer.Id,
LineItems = new List<SessionLineItemOptions>
{
new()
{
Price = priceId,
Quantity = 1
}
},
SuccessUrl = returnUrl,
CancelUrl = cancelUrl,
Metadata = new Dictionary<string, string>
{
{ "user_id", userId },
{ "plan_type", planType }
}
};
var service = new SessionService();
var session = await service.CreateAsync(options);
return session.Url;
}
public async Task<Customer> CreateOrGetCustomerAsync(string userId, string email, string name)
{
var user = await _userRepository.GetByIdAsync(userId);
if (!string.IsNullOrEmpty(user?.StripeCustomerId))
{
var customerService = new CustomerService();
try
{
return await customerService.GetAsync(user.StripeCustomerId);
}
catch (StripeException)
{
// Customer doesn't exist, create new one
}
}
// Create new customer
var options = new CustomerCreateOptions
{
Email = email,
Name = name,
Metadata = new Dictionary<string, string>
{
{ "user_id", userId }
}
};
var service = new CustomerService();
var customer = await service.CreateAsync(options);
// Update user with customer ID
if (user != null)
{
user.StripeCustomerId = customer.Id;
await _userRepository.UpdateAsync(user);
}
return customer;
}
public async Task<Stripe.Subscription> HandleWebhookAsync(string requestBody, string signature)
{
try
{
var stripeEvent = EventUtility.ConstructEvent(requestBody, signature, _stripeSettings.WebhookSecret);
switch (stripeEvent.Type)
{
case Events.CheckoutSessionCompleted:
var session = stripeEvent.Data.Object as Session;
await HandleCheckoutSessionCompletedAsync(session!);
break;
case Events.InvoicePaymentSucceeded:
var invoice = stripeEvent.Data.Object as Invoice;
await HandleInvoicePaymentSucceededAsync(invoice!);
break;
case Events.CustomerSubscriptionUpdated:
case Events.CustomerSubscriptionDeleted:
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionUpdatedAsync(subscription!);
break;
}
return stripeEvent.Data.Object as Stripe.Subscription ?? new Stripe.Subscription();
}
catch (StripeException ex)
{
throw new InvalidOperationException($"Webhook signature verification failed: {ex.Message}");
}
}
public async Task<List<Price>> GetPricesAsync()
{
var service = new PriceService();
var options = new PriceListOptions
{
Active = true,
Type = "recurring"
};
var prices = await service.ListAsync(options);
return prices.Data;
}
public async Task<bool> CancelSubscriptionAsync(string subscriptionId)
{
var service = new SubscriptionService();
var options = new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = true
};
var subscription = await service.UpdateAsync(subscriptionId, options);
// Update local subscription
var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (localSubscription != null)
{
localSubscription.CancelAtPeriodEnd = true;
await _subscriptionRepository.UpdateAsync(localSubscription);
}
return subscription.CancelAtPeriodEnd;
}
public async Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId)
{
var service = new SubscriptionService();
var subscription = await service.GetAsync(subscriptionId);
var options = new SubscriptionUpdateOptions
{
Items = new List<SubscriptionItemOptions>
{
new()
{
Id = subscription.Items.Data[0].Id,
Price = newPriceId
}
}
};
return await service.UpdateAsync(subscriptionId, options);
}
public Task<PlanLimitations> GetPlanLimitationsAsync(string planType)
{
var limitations = planType.ToLower() switch
{
"basic" => new PlanLimitations
{
MaxLinks = 5,
AllowCustomThemes = false,
AllowAnalytics = true,
AllowCustomDomain = false,
AllowMultipleDomains = false,
PrioritySupport = false,
PlanType = "basic"
},
"professional" => new PlanLimitations
{
MaxLinks = 15,
AllowCustomThemes = false,
AllowAnalytics = true,
AllowCustomDomain = true,
AllowMultipleDomains = false,
PrioritySupport = false,
PlanType = "professional"
},
"premium" => new PlanLimitations
{
MaxLinks = -1, // Unlimited
AllowCustomThemes = true,
AllowAnalytics = true,
AllowCustomDomain = true,
AllowMultipleDomains = true,
PrioritySupport = true,
PlanType = "premium"
},
_ => new PlanLimitations
{
MaxLinks = 5,
AllowCustomThemes = false,
AllowAnalytics = false,
AllowCustomDomain = false,
AllowMultipleDomains = false,
PrioritySupport = false,
PlanType = "free"
}
};
return Task.FromResult(limitations);
}
private async Task HandleCheckoutSessionCompletedAsync(Session session)
{
var userId = session.Metadata["user_id"];
var planType = session.Metadata["plan_type"];
var subscriptionService = new SubscriptionService();
var stripeSubscription = await subscriptionService.GetAsync(session.SubscriptionId);
var limitations = await GetPlanLimitationsAsync(planType);
var subscription = new Models.Subscription
{
UserId = userId,
StripeSubscriptionId = session.SubscriptionId,
PlanType = planType,
Status = stripeSubscription.Status,
CurrentPeriodStart = stripeSubscription.CurrentPeriodStart,
CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd,
MaxLinks = limitations.MaxLinks,
AllowCustomThemes = limitations.AllowCustomThemes,
AllowAnalytics = limitations.AllowAnalytics,
AllowCustomDomain = limitations.AllowCustomDomain,
AllowMultipleDomains = limitations.AllowMultipleDomains,
PrioritySupport = limitations.PrioritySupport
};
await _subscriptionRepository.CreateAsync(subscription);
// Update user
var user = await _userRepository.GetByIdAsync(userId);
if (user != null)
{
user.CurrentPlan = planType;
user.SubscriptionStatus = "active";
await _userRepository.UpdateAsync(user);
}
}
private async Task HandleInvoicePaymentSucceededAsync(Invoice invoice)
{
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
if (subscription != null)
{
subscription.Status = "active";
await _subscriptionRepository.UpdateAsync(subscription);
}
}
private async Task HandleSubscriptionUpdatedAsync(Stripe.Subscription stripeSubscription)
{
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
subscription.Status = stripeSubscription.Status;
subscription.CurrentPeriodStart = stripeSubscription.CurrentPeriodStart;
subscription.CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd;
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
await _subscriptionRepository.UpdateAsync(subscription);
// Update user status
var user = await _userRepository.GetByIdAsync(subscription.UserId);
if (user != null)
{
user.SubscriptionStatus = stripeSubscription.Status;
if (stripeSubscription.Status != "active")
{
user.CurrentPlan = "free";
}
await _userRepository.UpdateAsync(user);
}
}
}
}

View File

@ -0,0 +1,79 @@
using BCards.Web.Models;
namespace BCards.Web.Services;
public class SeoService : ISeoService
{
private readonly string _baseUrl;
public SeoService(IConfiguration configuration)
{
_baseUrl = configuration["BaseUrl"] ?? "https://vcart.me";
}
public SeoSettings GenerateSeoSettings(UserPage userPage, Category category)
{
return new SeoSettings
{
Title = GeneratePageTitle(userPage, category),
Description = GeneratePageDescription(userPage, category),
Keywords = GenerateKeywords(userPage, category),
OgTitle = GeneratePageTitle(userPage, category),
OgDescription = GeneratePageDescription(userPage, category),
OgImage = !string.IsNullOrEmpty(userPage.ProfileImage) ? userPage.ProfileImage : $"{_baseUrl}/images/default-og.png",
CanonicalUrl = GenerateCanonicalUrl(userPage),
TwitterCard = "summary_large_image"
};
}
public string GeneratePageTitle(UserPage userPage, Category category)
{
var businessTypeText = userPage.BusinessType == "company" ? "Empresa" : "Profissional";
return $"{userPage.DisplayName} - {businessTypeText} de {category.Name} | BCards";
}
public string GeneratePageDescription(UserPage userPage, Category category)
{
if (!string.IsNullOrEmpty(userPage.Bio))
{
var bio = userPage.Bio.Length > 150 ? userPage.Bio[..147] + "..." : userPage.Bio;
return $"{bio} Conheça mais sobre {userPage.DisplayName}, {category.Name.ToLower()}.";
}
var businessTypeText = userPage.BusinessType == "company" ? "empresa" : "profissional";
return $"Conheça {userPage.DisplayName}, {businessTypeText} especializado em {category.Name.ToLower()}. Acesse os links e entre em contato.";
}
public List<string> GenerateKeywords(UserPage userPage, Category category)
{
var keywords = new List<string>
{
userPage.DisplayName.ToLower(),
category.Name.ToLower(),
userPage.BusinessType == "company" ? "empresa" : "profissional"
};
// Add category SEO keywords
keywords.AddRange(category.SeoKeywords);
// Add business type specific keywords
if (userPage.BusinessType == "company")
{
keywords.AddRange(new[] { "empresa", "negócio", "serviços", "contato" });
}
else
{
keywords.AddRange(new[] { "profissional", "especialista", "consultor", "contato" });
}
// Add location-based keywords if available
keywords.AddRange(new[] { "brasil", "br", "online", "digital" });
return keywords.Distinct().ToList();
}
public string GenerateCanonicalUrl(UserPage userPage)
{
return $"{_baseUrl}/{userPage.Category}/{userPage.Slug}";
}
}

View File

@ -0,0 +1,215 @@
using BCards.Web.Models;
using MongoDB.Driver;
namespace BCards.Web.Services;
public class ThemeService : IThemeService
{
private readonly IMongoCollection<PageTheme> _themes;
public ThemeService(IMongoDatabase database)
{
_themes = database.GetCollection<PageTheme>("themes");
}
public async Task<List<PageTheme>> GetAvailableThemesAsync()
{
return await _themes.Find(x => x.IsActive).ToListAsync();
}
public async Task<PageTheme?> GetThemeByIdAsync(string themeId)
{
return await _themes.Find(x => x.Id == themeId && x.IsActive).FirstOrDefaultAsync();
}
public async Task<PageTheme?> GetThemeByNameAsync(string themeName)
{
var theme = await _themes.Find(x => x.Name.ToLower() == themeName.ToLower() && x.IsActive).FirstOrDefaultAsync();
return theme ?? GetDefaultTheme();
}
public Task<string> GenerateCustomCssAsync(PageTheme theme)
{
var css = $@"
:root {{
--primary-color: {theme.PrimaryColor};
--secondary-color: {theme.SecondaryColor};
--background-color: {theme.BackgroundColor};
--text-color: {theme.TextColor};
}}
.user-page {{
background-color: var(--background-color);
color: var(--text-color);
{(!string.IsNullOrEmpty(theme.BackgroundImage) ? $"background-image: url('{theme.BackgroundImage}');" : "")}
background-size: cover;
background-position: center;
background-attachment: fixed;
}}
.profile-card {{
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}}
.profile-image {{
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid var(--primary-color);
object-fit: cover;
}}
.profile-name {{
color: var(--primary-color);
font-size: 2rem;
font-weight: 600;
margin-bottom: 0.5rem;
}}
.profile-bio {{
color: var(--text-color);
opacity: 0.8;
margin-bottom: 2rem;
}}
.link-button {{
background-color: var(--primary-color);
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 50px;
text-decoration: none;
display: block;
margin-bottom: 1rem;
text-align: center;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}}
.link-button:hover {{
background-color: var(--secondary-color);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
color: white;
text-decoration: none;
}}
.link-title {{
font-size: 1.1rem;
margin-bottom: 0.25rem;
}}
.link-description {{
font-size: 0.9rem;
opacity: 0.9;
}}
@media (max-width: 768px) {{
.profile-card {{
padding: 1.5rem;
margin: 1rem;
}}
.profile-image {{
width: 100px;
height: 100px;
}}
.profile-name {{
font-size: 1.75rem;
}}
.link-button {{
padding: 0.875rem 1.5rem;
}}
}}
";
return Task.FromResult(css);
}
public async Task InitializeDefaultThemesAsync()
{
var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync();
if (existingThemes.Any()) return;
var defaultThemes = new[]
{
new PageTheme
{
Name = "Minimalista",
PrimaryColor = "#2563eb",
SecondaryColor = "#1d4ed8",
BackgroundColor = "#ffffff",
TextColor = "#1f2937",
IsPremium = false,
CssTemplate = "minimal"
},
new PageTheme
{
Name = "Dark Mode",
PrimaryColor = "#10b981",
SecondaryColor = "#059669",
BackgroundColor = "#111827",
TextColor = "#f9fafb",
IsPremium = false,
CssTemplate = "dark"
},
new PageTheme
{
Name = "Natureza",
PrimaryColor = "#16a34a",
SecondaryColor = "#15803d",
BackgroundColor = "#f0fdf4",
TextColor = "#166534",
BackgroundImage = "/images/themes/nature-bg.jpg",
IsPremium = false,
CssTemplate = "nature"
},
new PageTheme
{
Name = "Corporativo",
PrimaryColor = "#1e40af",
SecondaryColor = "#1e3a8a",
BackgroundColor = "#f8fafc",
TextColor = "#0f172a",
IsPremium = false,
CssTemplate = "corporate"
},
new PageTheme
{
Name = "Vibrante",
PrimaryColor = "#dc2626",
SecondaryColor = "#b91c1c",
BackgroundColor = "#fef2f2",
TextColor = "#7f1d1d",
IsPremium = true,
CssTemplate = "vibrant"
}
};
foreach (var theme in defaultThemes)
{
await _themes.InsertOneAsync(theme);
}
}
public PageTheme GetDefaultTheme()
{
return new PageTheme
{
Name = "Padrão",
PrimaryColor = "#2563eb",
SecondaryColor = "#1d4ed8",
BackgroundColor = "#ffffff",
TextColor = "#1f2937",
IsPremium = false,
CssTemplate = "default"
};
}
}

View File

@ -0,0 +1,178 @@
using BCards.Web.Models;
using BCards.Web.Repositories;
using MongoDB.Driver;
namespace BCards.Web.Services;
public class TrialExpirationService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TrialExpirationService> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromHours(1); // Check every hour
public TrialExpirationService(
IServiceProvider serviceProvider,
ILogger<TrialExpirationService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessTrialExpirationsAsync();
await Task.Delay(_checkInterval, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing trial expirations");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); // Wait 5 minutes on error
}
}
}
private async Task ProcessTrialExpirationsAsync()
{
using var scope = _serviceProvider.CreateScope();
var subscriptionRepository = scope.ServiceProvider.GetRequiredService<ISubscriptionRepository>();
var userPageRepository = scope.ServiceProvider.GetRequiredService<IUserPageRepository>();
var userRepository = scope.ServiceProvider.GetRequiredService<IUserRepository>();
_logger.LogInformation("Checking for expired trials...");
// Get all active trial subscriptions
var trialSubscriptions = await subscriptionRepository.GetTrialSubscriptionsAsync();
var now = DateTime.UtcNow;
foreach (var subscription in trialSubscriptions)
{
try
{
var user = await userRepository.GetByIdAsync(subscription.UserId);
if (user == null) continue;
var daysUntilExpiration = (subscription.CurrentPeriodEnd - now).TotalDays;
if (daysUntilExpiration <= 0)
{
// Trial expired - deactivate page
_logger.LogInformation($"Trial expired for user {user.Email}");
await HandleTrialExpiredAsync(user, subscription, userPageRepository);
}
else if (daysUntilExpiration <= 2 && !user.NotifiedOfExpiration)
{
// Trial expiring soon - send notification
_logger.LogInformation($"Trial expiring in {daysUntilExpiration:F1} days for user {user.Email}");
await SendExpirationWarningAsync(user, subscription, daysUntilExpiration);
// Mark as notified
user.NotifiedOfExpiration = true;
await userRepository.UpdateAsync(user);
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error processing trial for subscription {subscription.Id}");
}
}
_logger.LogInformation("Finished checking trial expirations");
}
private async Task HandleTrialExpiredAsync(
User user,
Subscription subscription,
IUserPageRepository userPageRepository)
{
// Deactivate user page
var userPage = await userPageRepository.GetByUserIdAsync(user.Id);
if (userPage != null)
{
userPage.IsActive = false;
userPage.UpdatedAt = DateTime.UtcNow;
await userPageRepository.UpdateAsync(userPage);
}
// Update subscription status
subscription.Status = "expired";
subscription.UpdatedAt = DateTime.UtcNow;
using var scope = _serviceProvider.CreateScope();
var subscriptionRepository = scope.ServiceProvider.GetRequiredService<ISubscriptionRepository>();
await subscriptionRepository.UpdateAsync(subscription);
// Send expiration email
await SendTrialExpiredEmailAsync(user);
_logger.LogInformation($"Deactivated trial page for user {user.Email}");
}
private async Task SendExpirationWarningAsync(
User user,
Subscription subscription,
double daysRemaining)
{
// TODO: Implement email service
// For now, just log
_logger.LogInformation($"Should send expiration warning to {user.Email} - {daysRemaining:F1} days remaining");
// Example email content:
var subject = "Seu trial do BCards expira em breve!";
var message = $@"
Olá {user.Name},
Seu trial gratuito do BCards expira em {Math.Ceiling(daysRemaining)} dia(s).
Para continuar usando sua página de links, escolha um de nossos planos:
Básico - R$ 9,90/mês
Profissional - R$ 24,90/mês
Premium - R$ 29,90/mês
Acesse: {GetUpgradeUrl()}
Equipe BCards
";
// TODO: Send actual email when email service is implemented
await Task.CompletedTask;
}
private async Task SendTrialExpiredEmailAsync(User user)
{
// TODO: Implement email service
_logger.LogInformation($"Should send trial expired email to {user.Email}");
var subject = "Seu trial do BCards expirou";
var message = $@"
Olá {user.Name},
Seu trial gratuito do BCards expirou e sua página foi temporariamente desativada.
Para reativar sua página, escolha um de nossos planos:
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
Seus dados estão seguros e serão restaurados assim que você escolher um plano.
Acesse: {GetUpgradeUrl()}
Equipe BCards
";
// TODO: Send actual email when email service is implemented
await Task.CompletedTask;
}
private string GetUpgradeUrl()
{
// TODO: Get from configuration
return "https://bcards.com.br/pricing";
}
}

View File

@ -0,0 +1,249 @@
using BCards.Web.Models;
using BCards.Web.Repositories;
using System.Text.RegularExpressions;
using System.Globalization;
using System.Text;
namespace BCards.Web.Services;
public class UserPageService : IUserPageService
{
private readonly IUserPageRepository _userPageRepository;
private readonly IUserRepository _userRepository;
private readonly ISubscriptionRepository _subscriptionRepository;
public UserPageService(
IUserPageRepository userPageRepository,
IUserRepository userRepository,
ISubscriptionRepository subscriptionRepository)
{
_userPageRepository = userPageRepository;
_userRepository = userRepository;
_subscriptionRepository = subscriptionRepository;
}
public async Task<UserPage?> GetPageAsync(string category, string slug)
{
return await _userPageRepository.GetBySlugAsync(category, slug);
}
public async Task<UserPage?> GetUserPageAsync(string userId)
{
return await _userPageRepository.GetByUserIdAsync(userId);
}
public async Task<UserPage?> GetPageByIdAsync(string id)
{
return await _userPageRepository.GetByIdAsync(id);
}
public async Task<List<UserPage>> GetUserPagesAsync(string userId)
{
return await _userPageRepository.GetByUserIdAllAsync(userId);
}
public async Task<List<UserPage>> GetActivePagesAsync()
{
return await _userPageRepository.GetActivePagesAsync();
}
public async Task<UserPage> CreatePageAsync(UserPage userPage)
{
userPage.Slug = await GenerateSlugAsync(userPage.Category, userPage.DisplayName);
return await _userPageRepository.CreateAsync(userPage);
}
public async Task<UserPage> UpdatePageAsync(UserPage userPage)
{
return await _userPageRepository.UpdateAsync(userPage);
}
public async Task DeletePageAsync(string id)
{
await _userPageRepository.DeleteAsync(id);
}
public async Task<bool> ValidateSlugAsync(string category, string slug, string? excludeId = null)
{
if (string.IsNullOrWhiteSpace(slug) || string.IsNullOrWhiteSpace(category))
return false;
if (!IsValidSlugFormat(slug))
return false;
return !await _userPageRepository.SlugExistsAsync(category, slug, excludeId);
}
public async Task<string> GenerateSlugAsync(string category, string name)
{
var slug = GenerateSlug(name);
var originalSlug = slug;
var counter = 1;
while (await _userPageRepository.SlugExistsAsync(category, slug))
{
slug = $"{originalSlug}-{counter}";
counter++;
}
return slug;
}
public async Task<bool> CanCreateLinksAsync(string userId, int newLinksCount = 1)
{
var userPage = await _userPageRepository.GetByUserIdAsync(userId);
if (userPage == null) return true; // New page
var subscription = await _subscriptionRepository.GetByUserIdAsync(userId);
var maxLinks = subscription?.MaxLinks ?? 5; // Default free plan
if (maxLinks == -1) return true; // Unlimited
var currentLinksCount = userPage.Links?.Count(l => l.IsActive) ?? 0;
return (currentLinksCount + newLinksCount) <= maxLinks;
}
public async Task RecordPageViewAsync(string pageId, string? referrer = null, string? userAgent = null)
{
var page = await _userPageRepository.GetByIdAsync(pageId);
if (page?.PlanLimitations.AllowAnalytics != true) return;
var analytics = page.Analytics;
analytics.TotalViews++;
analytics.LastViewedAt = DateTime.UtcNow;
// Monthly stats
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
if (analytics.MonthlyViews.ContainsKey(monthKey))
analytics.MonthlyViews[monthKey]++;
else
analytics.MonthlyViews[monthKey] = 1;
// Referrer stats
if (!string.IsNullOrEmpty(referrer))
{
var domain = ExtractDomain(referrer);
if (!string.IsNullOrEmpty(domain))
{
if (analytics.TopReferrers.ContainsKey(domain))
analytics.TopReferrers[domain]++;
else
analytics.TopReferrers[domain] = 1;
}
}
// Device stats (simplified)
if (!string.IsNullOrEmpty(userAgent))
{
var deviceType = GetDeviceType(userAgent);
if (analytics.DeviceStats.ContainsKey(deviceType))
analytics.DeviceStats[deviceType]++;
else
analytics.DeviceStats[deviceType] = 1;
}
await _userPageRepository.UpdateAnalyticsAsync(pageId, analytics);
}
public async Task RecordLinkClickAsync(string pageId, int linkIndex)
{
var page = await _userPageRepository.GetByIdAsync(pageId);
if (page?.PlanLimitations.AllowAnalytics != true) return;
if (linkIndex >= 0 && linkIndex < page.Links.Count)
{
page.Links[linkIndex].Clicks++;
}
var analytics = page.Analytics;
analytics.TotalClicks++;
// Monthly clicks
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
if (analytics.MonthlyClicks.ContainsKey(monthKey))
analytics.MonthlyClicks[monthKey]++;
else
analytics.MonthlyClicks[monthKey] = 1;
await _userPageRepository.UpdateAsync(page);
}
public async Task<List<UserPage>> GetRecentPagesAsync(int limit = 10)
{
return await _userPageRepository.GetRecentPagesAsync(limit);
}
public async Task<List<UserPage>> GetPagesByCategoryAsync(string category, int limit = 20)
{
return await _userPageRepository.GetByCategoryAsync(category, limit);
}
private static string GenerateSlug(string text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
// Remove acentos
text = RemoveDiacritics(text);
// Converter para minúsculas
text = text.ToLowerInvariant();
// Substituir espaços e caracteres especiais por hífens
text = Regex.Replace(text, @"[^a-z0-9\s-]", "");
text = Regex.Replace(text, @"[\s-]+", "-");
// Remover hífens do início e fim
text = text.Trim('-');
return text;
}
private static string RemoveDiacritics(string text)
{
var normalizedString = text.Normalize(NormalizationForm.FormD);
var stringBuilder = new StringBuilder();
foreach (var c in normalizedString)
{
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
{
stringBuilder.Append(c);
}
}
return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
}
private static bool IsValidSlugFormat(string slug)
{
return Regex.IsMatch(slug, @"^[a-z0-9-]+$") && !slug.StartsWith('-') && !slug.EndsWith('-');
}
private static string ExtractDomain(string url)
{
try
{
var uri = new Uri(url);
return uri.Host;
}
catch
{
return string.Empty;
}
}
private static string GetDeviceType(string userAgent)
{
userAgent = userAgent.ToLowerInvariant();
if (userAgent.Contains("mobile") || userAgent.Contains("android") || userAgent.Contains("iphone"))
return "mobile";
if (userAgent.Contains("tablet") || userAgent.Contains("ipad"))
return "tablet";
return "desktop";
}
}

View File

@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
namespace BCards.Web.ViewModels;
public class CreatePageViewModel
{
[Required(ErrorMessage = "Nome é obrigatório")]
[StringLength(50, ErrorMessage = "Nome deve ter no máximo 50 caracteres")]
public string DisplayName { get; set; } = string.Empty;
[Required(ErrorMessage = "Categoria é obrigatória")]
public string Category { get; set; } = string.Empty;
[Required(ErrorMessage = "Tipo de negócio é obrigatório")]
public string BusinessType { get; set; } = "individual";
[StringLength(200, ErrorMessage = "Bio deve ter no máximo 200 caracteres")]
public string Bio { get; set; } = string.Empty;
[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();
public string Slug { get; set; } = string.Empty;
}
public class CreateLinkViewModel
{
[Required(ErrorMessage = "Título é obrigatório")]
[StringLength(50, ErrorMessage = "Título deve ter no máximo 50 caracteres")]
public string Title { get; set; } = string.Empty;
[Required(ErrorMessage = "URL é obrigatória")]
[Url(ErrorMessage = "URL inválida")]
public string Url { get; set; } = string.Empty;
[StringLength(100, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")]
public string Description { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
}

View File

@ -0,0 +1,108 @@
using System.ComponentModel.DataAnnotations;
using BCards.Web.Models;
namespace BCards.Web.ViewModels;
public class ManagePageViewModel
{
public string Id { get; set; } = string.Empty;
public bool IsNewPage { get; set; } = true;
[Required(ErrorMessage = "Nome é obrigatório")]
[StringLength(50, ErrorMessage = "Nome deve ter no máximo 50 caracteres")]
public string DisplayName { get; set; } = string.Empty;
[Required(ErrorMessage = "Categoria é obrigatória")]
public string Category { get; set; } = string.Empty;
[Required(ErrorMessage = "Tipo de negócio é obrigatório")]
public string BusinessType { get; set; } = "individual";
[StringLength(200, ErrorMessage = "Bio deve ter no máximo 200 caracteres")]
public string Bio { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
[Required(ErrorMessage = "Tema é obrigatório")]
public string SelectedTheme { get; set; } = "minimalist";
public string WhatsAppNumber { get; set; } = string.Empty;
public string FacebookUrl { get; set; } = string.Empty;
public string TwitterUrl { get; set; } = string.Empty;
public string InstagramUrl { get; set; } = string.Empty;
public List<ManageLinkViewModel> Links { get; set; } = new();
// Data for dropdowns and selections
public List<Category> AvailableCategories { get; set; } = new();
public List<PageTheme> AvailableThemes { get; set; } = new();
// Plan limitations
public int MaxLinksAllowed { get; set; } = 3;
public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower());
}
public class ManageLinkViewModel
{
public string Id { get; set; } = "new";
[Required(ErrorMessage = "Título é obrigatório")]
[StringLength(50, ErrorMessage = "Título deve ter no máximo 50 caracteres")]
public string Title { get; set; } = string.Empty;
[Required(ErrorMessage = "URL é obrigatória")]
[Url(ErrorMessage = "URL inválida")]
public string Url { get; set; } = string.Empty;
[StringLength(100, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")]
public string Description { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public int Order { get; set; } = 0;
public bool IsActive { get; set; } = true;
}
public class DashboardViewModel
{
public User CurrentUser { get; set; } = new();
public List<UserPageSummary> UserPages { get; set; } = new();
public PlanInfo CurrentPlan { get; set; } = new();
public bool CanCreateNewPage { get; set; } = false;
public int DaysRemaining { get; set; } = 0;
}
public class UserPageSummary
{
public string Id { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
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 DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}";
}
public class PlanInfo
{
public PlanType Type { get; set; } = PlanType.Trial;
public string Name { get; set; } = string.Empty;
public int MaxPages { get; set; } = 1;
public int MaxLinksPerPage { get; set; } = 3;
public int DurationDays { get; set; } = 7;
public decimal Price { get; set; } = 0;
public bool AllowsAnalytics { get; set; } = false;
public bool AllowsCustomThemes { get; set; } = false;
}
public enum PageStatus
{
Active, // Funcionando normalmente
Expired, // Trial vencido -> 301 redirect
PendingPayment, // Pagamento atrasado -> aviso na página
Inactive // Pausada pelo usuário
}

View File

@ -0,0 +1,610 @@
@model BCards.Web.ViewModels.CreatePageViewModel
@{
ViewData["Title"] = "Criar Página";
Layout = "_Layout";
}
<div class="container-fluid">
<div class="row">
<div class="col-12 col-lg-8 mx-auto">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="fas fa-magic"></i>
Criar Sua Página de Links
</h4>
</div>
<div class="card-body">
<!-- Progress Bar -->
<div class="progress mb-4" style="height: 8px;">
<div class="progress-bar" role="progressbar" style="width: 20%" id="wizardProgress"></div>
</div>
<form asp-action="CreatePage" method="post" id="createPageForm">
<!-- Step 1: Informações Básicas -->
<div class="wizard-step" id="step1">
<h5 class="step-title">
<span class="step-number">1</span>
Informações Básicas
</h5>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label asp-for="DisplayName" class="form-label">Nome da Página</label>
<input asp-for="DisplayName" class="form-control" placeholder="Ex: João Silva">
<span asp-validation-for="DisplayName" class="text-danger"></span>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label asp-for="Category" class="form-label">Categoria</label>
<select asp-for="Category" class="form-select">
<option value="">Selecione uma categoria</option>
@foreach (var category in ViewBag.Categories as List<BCards.Web.Models.Category> ?? new List<BCards.Web.Models.Category>())
{
<option value="@category.Name">@category.Name</option>
}
</select>
<span asp-validation-for="Category" class="text-danger"></span>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label asp-for="BusinessType" class="form-label">Tipo</label>
<select asp-for="BusinessType" class="form-select">
<option value="individual">Pessoa Física</option>
<option value="company">Empresa</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="slugPreview" class="form-label">URL da Página</label>
<div class="input-group">
<span class="input-group-text">page/</span>
<span class="input-group-text" id="categorySlug">categoria</span>
<span class="input-group-text">/</span>
<input type="text" class="form-control" id="slugPreview" readonly>
<input asp-for="Slug" type="hidden">
</div>
<small class="form-text text-muted">URL gerada automaticamente</small>
</div>
</div>
</div>
<div class="mb-3">
<label asp-for="Bio" class="form-label">Bio/Descrição</label>
<textarea asp-for="Bio" class="form-control" rows="3" placeholder="Uma breve descrição sobre você ou sua empresa..."></textarea>
<span asp-validation-for="Bio" class="text-danger"></span>
</div>
</div>
<!-- Step 2: Seleção de Tema -->
<div class="wizard-step d-none" id="step2">
<h5 class="step-title">
<span class="step-number">2</span>
Escolha Seu Tema Visual
</h5>
<div class="row">
@foreach (var theme in ViewBag.Themes as List<BCards.Web.Models.PageTheme> ?? new List<BCards.Web.Models.PageTheme>())
{
<div class="col-md-4 mb-3">
<div class="theme-card" data-theme="@theme.Name.ToLower()">
<div class="theme-preview" style="background: @theme.BackgroundColor; color: @theme.TextColor;">
<div class="theme-header" style="background-color: @theme.PrimaryColor;">
<div class="theme-avatar"></div>
<h6>@theme.Name</h6>
</div>
<div class="theme-links">
<div class="theme-link" style="background-color: @theme.PrimaryColor;"></div>
<div class="theme-link" style="background-color: @theme.SecondaryColor;"></div>
</div>
</div>
<div class="theme-name">
@theme.Name
@if (theme.IsPremium)
{
<span class="badge bg-warning">Premium</span>
}
</div>
</div>
</div>
}
</div>
<input asp-for="SelectedTheme" type="hidden">
</div>
<!-- Step 3: Links Principais -->
<div class="wizard-step d-none" id="step3">
<h5 class="step-title">
<span class="step-number">3</span>
Links Principais
</h5>
<div id="linksContainer">
<!-- Links will be added dynamically -->
</div>
<button type="button" class="btn btn-outline-primary" id="addLinkBtn">
<i class="fas fa-plus"></i> Adicionar Link
</button>
</div>
<!-- Step 4: Redes Sociais -->
<div class="wizard-step d-none" id="step4">
<h5 class="step-title">
<span class="step-number">4</span>
Redes Sociais
</h5>
<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
</label>
<input asp-for="WhatsAppNumber" class="form-control" placeholder="+55 11 99999-9999">
<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
</label>
<input asp-for="FacebookUrl" class="form-control" placeholder="https://facebook.com/seu-perfil">
<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
</label>
<input asp-for="TwitterUrl" class="form-control" placeholder="https://x.com/seu-perfil">
<span asp-validation-for="TwitterUrl" class="text-danger"></span>
</div>
</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">
<span asp-validation-for="InstagramUrl" class="text-danger"></span>
</div>
</div>
</div>
</div>
<!-- Step 5: Preview e Finalização -->
<div class="wizard-step d-none" id="step5">
<h5 class="step-title">
<span class="step-number">5</span>
Preview e Finalização
</h5>
<div class="preview-container">
<div class="preview-phone">
<div class="preview-screen" id="previewScreen">
<!-- Preview will be generated here -->
</div>
</div>
</div>
<div class="text-center mt-4">
<p class="text-muted">Sua página estará disponível em:</p>
<strong id="finalUrl">page/categoria/seu-slug</strong>
</div>
</div>
<!-- Navigation Buttons -->
<div class="wizard-navigation mt-4">
<button type="button" class="btn btn-secondary" id="prevBtn" style="display: none;">
<i class="fas fa-arrow-left"></i> Anterior
</button>
<button type="button" class="btn btn-primary float-end" id="nextBtn">
Próximo <i class="fas fa-arrow-right"></i>
</button>
<button type="submit" class="btn btn-success float-end d-none" id="submitBtn">
<i class="fas fa-check"></i> Criar Página
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<style>
.wizard-step {
min-height: 400px;
}
.step-title {
color: #495057;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e9ecef;
}
.step-number {
display: inline-block;
width: 30px;
height: 30px;
line-height: 30px;
background-color: #007bff;
color: white;
border-radius: 50%;
text-align: center;
margin-right: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
}
.theme-card {
cursor: pointer;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
}
.theme-card:hover,
.theme-card.selected {
border-color: #007bff;
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3);
}
.theme-preview {
height: 120px;
position: relative;
padding: 1rem;
}
.theme-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.theme-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
}
.theme-header h6 {
margin: 0;
font-size: 0.75rem;
color: white;
}
.theme-links {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.theme-link {
height: 8px;
border-radius: 4px;
opacity: 0.8;
}
.theme-name {
padding: 0.75rem;
text-align: center;
font-weight: 500;
background-color: #f8f9fa;
}
.link-input-group {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.preview-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.preview-phone {
width: 300px;
height: 400px;
border: 8px solid #333;
border-radius: 20px;
background-color: #000;
padding: 20px 10px;
position: relative;
}
.preview-screen {
width: 100%;
height: 100%;
background-color: #fff;
border-radius: 12px;
overflow-y: auto;
padding: 1rem;
}
.wizard-navigation {
border-top: 1px solid #dee2e6;
padding-top: 1rem;
}
</style>
<script>
let currentStep = 1;
const totalSteps = 5;
let linkCount = 0;
$(document).ready(function() {
initializeWizard();
// Generate slug when name or category changes
$('#DisplayName, #Category').on('input change', function() {
generateSlug();
});
// Theme selection
$('.theme-card').on('click', function() {
$('.theme-card').removeClass('selected');
$(this).addClass('selected');
const themeName = $(this).data('theme');
$('#SelectedTheme').val(themeName);
});
// Navigation
$('#nextBtn').on('click', function() {
if (validateCurrentStep()) {
nextStep();
}
});
$('#prevBtn').on('click', function() {
prevStep();
});
// Add link functionality
$('#addLinkBtn').on('click', function() {
addLinkInput();
});
// Form submission
$('#createPageForm').on('submit', function(e) {
generateLinksData();
});
});
function initializeWizard() {
updateProgressBar();
updateNavigationButtons();
addLinkInput(); // Add first link input
}
function nextStep() {
if (currentStep < totalSteps) {
$(`#step${currentStep}`).addClass('d-none');
currentStep++;
$(`#step${currentStep}`).removeClass('d-none');
if (currentStep === 5) {
generatePreview();
}
updateProgressBar();
updateNavigationButtons();
}
}
function prevStep() {
if (currentStep > 1) {
$(`#step${currentStep}`).addClass('d-none');
currentStep--;
$(`#step${currentStep}`).removeClass('d-none');
updateProgressBar();
updateNavigationButtons();
}
}
function updateProgressBar() {
const progress = (currentStep / totalSteps) * 100;
$('#wizardProgress').css('width', progress + '%');
}
function updateNavigationButtons() {
$('#prevBtn').toggle(currentStep > 1);
if (currentStep === totalSteps) {
$('#nextBtn').addClass('d-none');
$('#submitBtn').removeClass('d-none');
} else {
$('#nextBtn').removeClass('d-none');
$('#submitBtn').addClass('d-none');
}
}
function validateCurrentStep() {
let isValid = true;
switch (currentStep) {
case 1:
if (!$('#DisplayName').val() || !$('#Category').val()) {
alert('Por favor, preencha o nome e a categoria.');
isValid = false;
}
break;
case 2:
if (!$('#SelectedTheme').val()) {
alert('Por favor, selecione um tema.');
isValid = false;
}
break;
}
return isValid;
}
function generateSlug() {
const name = $('#DisplayName').val();
const category = $('#Category').val();
if (name && category) {
$.post('/Admin/GenerateSlug', { category: category, name: name })
.done(function(data) {
$('#Slug').val(data.slug);
$('#slugPreview').val(data.slug);
$('#categorySlug').text(category);
$('#finalUrl').text(`page/${category}/${data.slug}`);
});
}
}
function addLinkInput() {
linkCount++;
const linkHtml = `
<div class="link-input-group" data-link="${linkCount}">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Link ${linkCount}</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-2">
<label class="form-label">Título</label>
<input type="text" class="form-control link-title" placeholder="Ex: Meu Site">
</div>
</div>
<div class="col-md-6">
<div class="mb-2">
<label class="form-label">URL</label>
<input type="url" class="form-control link-url" placeholder="https://exemplo.com">
</div>
</div>
</div>
<div class="mb-2">
<label class="form-label">Descrição (opcional)</label>
<input type="text" class="form-control link-description" placeholder="Breve descrição do link">
</div>
</div>
`;
$('#linksContainer').append(linkHtml);
// Add remove functionality
$('.remove-link-btn').off('click').on('click', function() {
$(this).closest('.link-input-group').remove();
});
}
function generateLinksData() {
const links = [];
$('.link-input-group').each(function() {
const title = $(this).find('.link-title').val();
const url = $(this).find('.link-url').val();
const description = $(this).find('.link-description').val();
if (title && url) {
links.push({
Title: title,
Url: url,
Description: description,
Icon: ''
});
}
});
// Create hidden inputs for links
$('#linksContainer').append('<div id="linksData"></div>');
$('#linksData').empty();
links.forEach((link, index) => {
$('#linksData').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}" />
`);
});
}
function generatePreview() {
const name = $('#DisplayName').val();
const bio = $('#Bio').val();
const selectedTheme = $('#SelectedTheme').val();
let previewHtml = `
<div class="text-center">
<div class="mb-3">
<div style="width: 60px; height: 60px; background-color: #ddd; border-radius: 50%; margin: 0 auto;"></div>
</div>
<h5 class="mb-2">${name}</h5>
<p class="text-muted small mb-3">${bio}</p>
<div class="d-grid gap-2">
`;
// Add links preview
$('.link-input-group').each(function() {
const title = $(this).find('.link-title').val();
if (title) {
previewHtml += `<div class="btn btn-primary btn-sm">${title}</div>`;
}
});
// Add social media preview
if ($('#WhatsAppNumber').val()) {
previewHtml += `<div class="btn btn-success btn-sm"><i class="fab fa-whatsapp"></i> WhatsApp</div>`;
}
if ($('#FacebookUrl').val()) {
previewHtml += `<div class="btn btn-primary btn-sm"><i class="fab fa-facebook"></i> Facebook</div>`;
}
if ($('#TwitterUrl').val()) {
previewHtml += `<div class="btn btn-dark btn-sm"><i class="fab fa-x-twitter"></i> X / Twitter</div>`;
}
if ($('#InstagramUrl').val()) {
previewHtml += `<div class="btn btn-danger btn-sm"><i class="fab fa-instagram"></i> Instagram</div>`;
}
previewHtml += `</div></div>`;
$('#previewScreen').html(previewHtml);
}
</script>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

View File

@ -0,0 +1,304 @@
@model BCards.Web.ViewModels.DashboardViewModel
@{
ViewData["Title"] = "Dashboard - BCards";
Layout = "_Layout";
}
<div class="container py-4">
<div class="row">
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1>Olá, @Model.CurrentUser.Name!</h1>
<p class="text-muted mb-0">Gerencie suas páginas profissionais</p>
</div>
<div>
@if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage))
{
<img src="@Model.CurrentUser.ProfileImage" alt="@Model.CurrentUser.Name"
class="rounded-circle" style="width: 60px; height: 60px; object-fit: cover;">
}
</div>
</div>
<!-- Lista de Páginas -->
<div class="row">
@foreach (var page 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-body">
<h6 class="card-title">@(page.DisplayName)</h6>
<p class="text-muted small mb-2">@(page.Category)/@(page.Slug)</p>
<div class="mb-2">
@switch (page.Status)
{
case BCards.Web.ViewModels.PageStatus.Active:
<span class="badge bg-success">Ativa</span>
break;
case BCards.Web.ViewModels.PageStatus.Expired:
<span class="badge bg-danger">Expirada</span>
break;
case BCards.Web.ViewModels.PageStatus.PendingPayment:
<span class="badge bg-warning">Pagamento Pendente</span>
break;
case BCards.Web.ViewModels.PageStatus.Inactive:
<span class="badge bg-secondary">Inativa</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-muted">Visualizações</div>
</div>
<div class="col-6">
<div class="text-success fw-bold">@(page.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>
</div>
<div class="card-footer bg-transparent">
<small class="text-muted">Criada em @(page.CreatedAt.ToString("dd/MM/yyyy"))</small>
</div>
</div>
</div>
}
<!-- Card para Criar Nova Página -->
@if (Model.CanCreateNewPage)
{
<div class="col-md-6 col-lg-4 mb-3">
<div class="card h-100 border-dashed text-center" style="border: 2px dashed #dee2e6;">
<div class="card-body d-flex align-items-center justify-content-center">
<div>
<i class="fas fa-plus fa-2x text-muted mb-3"></i>
<h6 class="text-muted">Criar Nova Página</h6>
<a href="@Url.Action("ManagePage", new { id = "new" })"
class="btn btn-primary">Começar</a>
</div>
</div>
</div>
</div>
}
else if (!Model.UserPages.Any())
{
<!-- Primeira Página -->
<div class="col-12">
<div class="card border-primary">
<div class="card-body text-center p-5">
<div class="mb-4">
<i class="display-1 text-primary">🚀</i>
</div>
<h3>Crie sua primeira página!</h3>
<p class="text-muted mb-4">
Comece criando sua página profissional personalizada com seus links organizados.
</p>
<a href="@Url.Action("ManagePage", new { id = "new" })" class="btn btn-primary btn-lg">
Criar Minha Página
</a>
</div>
</div>
</div>
}
else
{
<!-- Limite atingido -->
<div class="col-12">
<div class="alert alert-warning d-flex align-items-center">
<i class="fas fa-exclamation-triangle me-3"></i>
<div>
<strong>Limite atingido!</strong>
Você já criou o máximo de @Model.CurrentPlan.MaxPages página(s) para seu plano atual.
<a href="@Url.Action("Pricing", "Home")" class="alert-link ms-2">Fazer upgrade</a>
</div>
</div>
</div>
}
</div>
</div>
<div class="col-md-4">
<!-- Plano Atual -->
<div class="card mb-4 @(Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial ? "border-warning" : "")">
<div class="card-header @(Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial ? "bg-warning" : "bg-primary") text-white">
<h6 class="mb-0">
<i class="fas fa-crown me-2"></i>
Plano Atual
</h6>
</div>
<div class="card-body">
<h5 class="text-capitalize mb-1">@Model.CurrentPlan.Name</h5>
@if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial)
{
<p class="text-warning mb-2">
<i class="fas fa-clock me-1"></i>
@Model.DaysRemaining dia(s) restante(s)
</p>
}
else
{
<p class="text-muted small mb-2">R$ @Model.CurrentPlan.Price.ToString("F2")/mês</p>
}
<div class="mb-3">
<div class="d-flex justify-content-between small mb-1">
<span>Páginas</span>
<span>@Model.UserPages.Count/@Model.CurrentPlan.MaxPages</span>
</div>
<div class="progress" style="height: 6px;">
@{
var pagesPercentage = Model.CurrentPlan.MaxPages > 0 ?
(double)Model.UserPages.Count / Model.CurrentPlan.MaxPages * 100 : 0;
}
<div class="progress-bar @(pagesPercentage >= 80 ? "bg-warning" : "bg-primary")"
style="width: @pagesPercentage%"></div>
</div>
</div>
<div class="small mb-2">
<i class="fas fa-link me-2"></i>
Links por página: @(Model.CurrentPlan.MaxLinksPerPage == int.MaxValue ? "Ilimitado" : Model.CurrentPlan.MaxLinksPerPage.ToString())
</div>
<div class="small mb-2">
<i class="fas fa-chart-bar me-2"></i>
Analytics: @(Model.CurrentPlan.AllowsAnalytics ? "✅" : "❌")
</div>
<div class="small mb-3">
<i class="fas fa-palette me-2"></i>
Temas customizáveis: @(Model.CurrentPlan.AllowsCustomThemes ? "✅" : "❌")
</div>
@if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial)
{
<a href="@Url.Action("Pricing", "Home")" class="btn btn-warning w-100">
<i class="fas fa-rocket me-2"></i>
Fazer Upgrade
</a>
}
else
{
<a href="@Url.Action("ManageSubscription", "Payment")" class="btn btn-outline-secondary w-100">
<i class="fas fa-cog me-2"></i>
Gerenciar Assinatura
</a>
}
</div>
</div>
<!-- Estatísticas Rápidas -->
@if (Model.CurrentPlan.AllowsAnalytics && Model.UserPages.Any())
{
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-chart-line me-2"></i>
Estatísticas Gerais
</h6>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<div class="h4 text-primary mb-0">@Model.UserPages.Sum(p => p.TotalViews)</div>
<small class="text-muted">Total de Visualizações</small>
</div>
<div class="col-6">
<div class="h4 text-success mb-0">@Model.UserPages.Sum(p => p.TotalClicks)</div>
<small class="text-muted">Total de Cliques</small>
</div>
</div>
@if (Model.UserPages.Sum(p => p.TotalViews) > 0)
{
<hr class="my-3">
<div class="text-center">
<div class="h5 text-info mb-0">
@((Model.UserPages.Sum(p => p.TotalClicks) * 100.0 / Model.UserPages.Sum(p => p.TotalViews)).ToString("F1"))%
</div>
<small class="text-muted">Taxa de Cliques</small>
</div>
}
</div>
</div>
}
<!-- Dicas -->
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-lightbulb me-2"></i>
💡 Dicas
</h6>
</div>
<div class="card-body">
<ul class="list-unstyled small mb-0">
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
Use uma bio clara e objetiva
</li>
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
Organize seus links por importância
</li>
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
Escolha URLs fáceis de lembrar
</li>
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
Atualize regularmente seus links
</li>
<li class="mb-0">
<i class="fas fa-check text-success me-2"></i>
Monitore suas estatísticas
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
@if (TempData["Success"] != null)
{
<div class="toast-container position-fixed top-0 end-0 p-3">
<div class="toast show" role="alert">
<div class="toast-header">
<i class="fas fa-check-circle text-success me-2"></i>
<strong class="me-auto">Sucesso</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">
@TempData["Success"]
</div>
</div>
</div>
}
@if (TempData["Error"] != null)
{
<div class="toast-container position-fixed top-0 end-0 p-3">
<div class="toast show" role="alert">
<div class="toast-header">
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
<strong class="me-auto">Atenção</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">
@TempData["Error"]
</div>
</div>
</div>
}

View File

@ -0,0 +1,716 @@
@model BCards.Web.ViewModels.ManagePageViewModel
@{
ViewData["Title"] = Model.IsNewPage ? "Criar Página" : "Editar Página";
Layout = "_Layout";
}
<div class="container-fluid">
<div class="row">
<div class="col-12 col-lg-8 mx-auto">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="fas fa-@(Model.IsNewPage ? "plus" : "edit")"></i>
@(Model.IsNewPage ? "Assistente de Criação de Página" : "Editar Página")
</h4>
</div>
<div class="card-body">
<form asp-action="ManagePage" method="post" id="managePageForm" novalidate>
<input asp-for="Id" type="hidden">
<input asp-for="IsNewPage" type="hidden">
<!-- Progress Bar -->
@if (Model.IsNewPage)
{
<div class="mb-4">
<div class="progress" style="height: 6px;">
<div class="progress-bar" role="progressbar" style="width: 25%" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<div class="d-flex justify-content-between mt-2">
<small class="text-muted">Passo 1 de 4</small>
<small class="text-muted">Informações Básicas</small>
</div>
</div>
}
<!-- Accordion -->
<div class="accordion" id="pageWizard">
<!-- Passo 1: Informações Básicas -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingBasic">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseBasic" aria-expanded="true" aria-controls="collapseBasic">
<i class="fas fa-info-circle me-2"></i>
Passo 1: Informações Básicas
<span class="badge bg-success ms-auto me-3" id="step1Status" style="display: none;">✓</span>
</button>
</h2>
<div id="collapseBasic" class="accordion-collapse collapse show" aria-labelledby="headingBasic" data-bs-parent="#pageWizard">
<div class="accordion-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label asp-for="DisplayName" class="form-label">Nome da Página <span class="text-danger">*</span></label>
<input asp-for="DisplayName" class="form-control" placeholder="Ex: João Silva" required>
<span asp-validation-for="DisplayName" class="text-danger"></span>
<div class="form-text">Nome que aparecerá no topo da sua página</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label asp-for="Category" class="form-label">Categoria <span class="text-danger">*</span></label>
<select asp-for="Category" class="form-select" required>
<option value="">Selecione uma categoria</option>
@foreach (var category in Model.AvailableCategories)
{
<option value="@category.Name">@category.Name</option>
}
</select>
<span asp-validation-for="Category" class="text-danger"></span>
<div class="form-text">Categoria define o tipo da sua página</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label asp-for="BusinessType" class="form-label">Tipo</label>
<select asp-for="BusinessType" class="form-select">
<option value="individual" selected>Pessoa Física</option>
<option value="company">Empresa</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="slugPreview" class="form-label">URL da Página</label>
<div class="input-group">
<span class="input-group-text">page/</span>
<span class="input-group-text" id="categorySlug">categoria</span>
<span class="input-group-text">/</span>
<input type="text" class="form-control" id="slugPreview" value="@Model.Slug" readonly>
<input asp-for="Slug" type="hidden">
</div>
<small class="form-text text-muted">URL gerada automaticamente</small>
</div>
</div>
</div>
<div class="mb-3">
<label asp-for="Bio" class="form-label">Bio/Descrição</label>
<textarea asp-for="Bio" class="form-control" rows="3" placeholder="Uma breve descrição sobre você ou sua empresa..."></textarea>
<span asp-validation-for="Bio" class="text-danger"></span>
<div class="form-text">Máximo 200 caracteres</div>
</div>
<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>
</button>
</div>
</div>
</div>
</div>
<!-- Passo 2: Tema Visual -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingTheme">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTheme" aria-expanded="false" aria-controls="collapseTheme">
<i class="fas fa-palette me-2"></i>
Passo 2: Tema Visual
<span class="badge bg-success ms-auto me-3" id="step2Status" style="display: none;">✓</span>
</button>
</h2>
<div id="collapseTheme" class="accordion-collapse collapse" aria-labelledby="headingTheme" data-bs-parent="#pageWizard">
<div class="accordion-body">
<p class="text-muted mb-4">Escolha um tema que combine com sua personalidade ou marca:</p>
<div class="row">
@foreach (var theme in Model.AvailableThemes)
{
<div class="col-md-4 col-lg-3 mb-3">
<div class="theme-card @(Model.SelectedTheme == theme.Name.ToLower() ? "selected" : "")" data-theme="@theme.Name.ToLower()">
<div class="theme-preview" style="background: @theme.BackgroundColor; color: @theme.TextColor;">
<div class="theme-header" style="background-color: @theme.PrimaryColor;">
<div class="theme-avatar"></div>
<h6>@theme.Name</h6>
</div>
<div class="theme-links">
<div class="theme-link" style="background-color: @theme.PrimaryColor;"></div>
<div class="theme-link" style="background-color: @theme.SecondaryColor;"></div>
</div>
</div>
<div class="theme-name">
@theme.Name
@if (theme.IsPremium)
{
<span class="badge bg-warning">Premium</span>
}
</div>
</div>
</div>
}
</div>
<input asp-for="SelectedTheme" type="hidden">
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(1)">
<i class="fas fa-arrow-left me-1"></i> Anterior
</button>
<button type="button" class="btn btn-primary" onclick="nextStep(3)">
Próximo <i class="fas fa-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Passo 3: Links Principais -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingLinks">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLinks" aria-expanded="false" aria-controls="collapseLinks">
<i class="fas fa-link me-2"></i>
Passo 3: Links Principais
<span class="badge bg-success ms-auto me-3" id="step3Status" style="display: none;">✓</span>
</button>
</h2>
<div id="collapseLinks" class="accordion-collapse collapse" aria-labelledby="headingLinks" data-bs-parent="#pageWizard">
<div class="accordion-body">
<p class="text-muted mb-4">Adicione os links mais importantes (Máximo: @Model.MaxLinksAllowed):</p>
<div id="linksContainer">
@for (int i = 0; i < Model.Links.Count; i++)
{
var myList = new List<string>()
{
"facebook",
"whatsapp",
"twitter",
"instagram"
};
var match = myList.FirstOrDefault(stringToCheck => Model.Links[i].Icon.Contains(stringToCheck));
if (match==null) {
<div class="link-input-group" data-link="@i">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Link @(i + 1)</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-2">
<label class="form-label">Título</label>
<input asp-for="Links[i].Title" class="form-control link-title" placeholder="Ex: Meu Site">
<span asp-validation-for="Links[i].Title" class="text-danger"></span>
</div>
</div>
<div class="col-md-6">
<div class="mb-2">
<label class="form-label">URL</label>
<input asp-for="Links[i].Url" class="form-control link-url" placeholder="https://exemplo.com">
<span asp-validation-for="Links[i].Url" class="text-danger"></span>
</div>
</div>
</div>
<div class="mb-2">
<label class="form-label">Descrição (opcional)</label>
<input asp-for="Links[i].Description" class="form-control link-description" placeholder="Breve descrição do link">
</div>
<input asp-for="Links[i].Id" type="hidden">
<input asp-for="Links[i].Icon" type="hidden">
<input asp-for="Links[i].Order" type="hidden">
<input asp-for="Links[i].IsActive" type="hidden" value="true">
</div>
}
}
</div>
<button type="button" class="btn btn-outline-primary mb-4" id="addLinkBtn" data-bs-toggle="modal" data-bs-target="#addLinkModal">
<i class="fas fa-plus"></i> Adicionar Link
</button>
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(2)">
<i class="fas fa-arrow-left me-1"></i> Anterior
</button>
<button type="button" class="btn btn-primary" onclick="nextStep(4)">
Próximo <i class="fas fa-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
</div>
@{
var facebook = Model.Links.Where(x => x.Icon.Contains("facebook")).FirstOrDefault();
var twitter = Model.Links.Where(x => x.Icon.Contains("twitter")).FirstOrDefault();
var whatsapp = Model.Links.Where(x => x.Icon.Contains("whatsapp")).FirstOrDefault();
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 instagramUrl = instagram !=null ? instagram.Url : "";
}
<!-- Passo 4: Redes Sociais (Opcional) -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingSocial">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseSocial" aria-expanded="false" aria-controls="collapseSocial">
<i class="fab fa-twitter me-2"></i>
Passo 4: Redes Sociais (Opcional)
<span class="badge bg-success ms-auto me-3" id="step4Status" style="display: none;">✓</span>
</button>
</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="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
</label>
<input asp-for="WhatsAppNumber" class="form-control" placeholder="+55 11 99999-9999" 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
</label>
<input asp-for="FacebookUrl" class="form-control" placeholder="https://facebook.com/seu-perfil" 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
</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>
<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">
<span asp-validation-for="InstagramUrl" class="text-danger"></span>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<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")
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Modal para Adicionar Link -->
<div class="modal fade" id="addLinkModal" tabindex="-1" aria-labelledby="addLinkModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addLinkModalLabel">
<i class="fas fa-link me-2"></i>
Adicionar Novo Link
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="addLinkForm">
<div class="mb-3">
<label for="linkTitle" class="form-label">Título do Link</label>
<input type="text" class="form-control" id="linkTitle" placeholder="Ex: Meu Site, Portfólio, Instagram..." required>
<div class="form-text">Nome que aparecerá no botão</div>
</div>
<div class="mb-3">
<label for="linkUrl" class="form-label">URL</label>
<input type="url" class="form-control" id="linkUrl" placeholder="https://exemplo.com" required>
<div class="form-text">Link completo incluindo https://</div>
</div>
<div class="mb-3">
<label for="linkDescription" class="form-label">Descrição (opcional)</label>
<input type="text" class="form-control" id="linkDescription" placeholder="Breve descrição do link">
<div class="form-text">Texto adicional que aparece abaixo do título</div>
</div>
<div class="mb-3">
<label for="linkIcon" class="form-label">Ícone (opcional)</label>
<select class="form-select" id="linkIcon">
<option value="">Sem ícone</option>
<option value="fas fa-globe">🌐 Site</option>
<option value="fas fa-shopping-cart">🛒 Loja</option>
<option value="fas fa-briefcase">💼 Portfólio</option>
<option value="fas fa-envelope">✉️ Email</option>
<option value="fas fa-phone">📞 Telefone</option>
<option value="fas fa-map-marker-alt">📍 Localização</option>
<option value="fab fa-youtube">📺 YouTube</option>
<option value="fab fa-linkedin">💼 LinkedIn</option>
<option value="fab fa-github">💻 GitHub</option>
<option value="fas fa-download">⬇️ Download</option>
<option value="fas fa-calendar">📅 Agenda</option>
<option value="fas fa-heart">❤️ Favorito</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times"></i> Cancelar
</button>
<button type="button" class="btn btn-primary" id="saveLinkBtn">
<i class="fas fa-plus"></i> Adicionar Link
</button>
</div>
</div>
</div>
</div>
<style>
.theme-card {
cursor: pointer;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
}
.theme-card:hover,
.theme-card.selected {
border-color: #007bff;
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3);
transform: translateY(-2px);
}
.theme-preview {
height: 100px;
position: relative;
padding: 0.75rem;
}
.theme-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 0.75rem;
}
.theme-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
}
.theme-header h6 {
margin: 0;
font-size: 0.7rem;
color: white;
}
.theme-links {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.theme-link {
height: 6px;
border-radius: 3px;
opacity: 0.8;
}
.theme-name {
padding: 0.5rem;
text-align: center;
font-weight: 500;
background-color: #f8f9fa;
font-size: 0.85rem;
}
.link-input-group {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.link-input-group:hover {
border-color: #007bff;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
}
.accordion-button:not(.collapsed) {
background-color: rgba(13, 110, 253, 0.1);
border-color: rgba(13, 110, 253, 0.25);
}
.progress-bar {
transition: width 0.6s ease;
}
.btn {
border: none !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
</style>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script>
let linkCount = @Model.Links.Count;
let currentStep = 1;
$(document).ready(function() {
// Generate slug when name or category changes
$('#DisplayName, #Category').on('input change', function() {
generateSlug();
updateProgress();
});
// Theme selection
$('.theme-card').on('click', function() {
$('.theme-card').removeClass('selected');
$(this).addClass('selected');
const themeName = $(this).data('theme');
$('#SelectedTheme').val(themeName);
markStepComplete(2);
});
// Add link functionality via modal
$('#addLinkBtn').on('click', function() {
if (linkCount >= @Model.MaxLinksAllowed) {
alert('Você atingiu o limite de links para seu plano atual.');
return false;
}
});
// Save link from modal
$(document).on('click', '#saveLinkBtn', function() {
console.log('Save button clicked');
const title = $('#linkTitle').val().trim();
const url = $('#linkUrl').val().trim();
const description = $('#linkDescription').val().trim();
const icon = $('#linkIcon').val();
console.log('Values:', { title, url, description, icon });
if (!title || !url) {
alert('Por favor, preencha pelo menos o título e a URL do link.');
return;
}
// Basic URL validation
if (!url.startsWith('http://') && !url.startsWith('https://')) {
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
return;
}
addLinkInput(title, url, description, icon);
// Clear modal form
$('#addLinkForm')[0].reset();
// Close modal using Bootstrap 5 syntax
var modal = bootstrap.Modal.getInstance(document.getElementById('addLinkModal'));
if (modal) {
modal.hide();
}
markStepComplete(3);
});
// Remove link functionality
$(document).on('click', '.remove-link-btn', function() {
$(this).closest('.link-input-group').remove();
linkCount--;
updateLinkNumbers();
});
// Form validation
$('#managePageForm').on('submit', function(e) {
console.log('Form submitted');
// Allow submission but add loading state
$(this).find('button[type="submit"]').prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-2"></i>Criando...');
});
});
function nextStep(step) {
if (validateCurrentStep()) {
markStepComplete(currentStep);
currentStep = step;
updateProgress();
// Close current accordion and open next
$('.accordion-collapse.show').collapse('hide');
setTimeout(() => {
$(`#collapse${getStepName(step)}`).collapse('show');
}, 300);
}
}
function previousStep(step) {
currentStep = step;
updateProgress();
// Close current accordion and open previous
$('.accordion-collapse.show').collapse('hide');
setTimeout(() => {
$(`#collapse${getStepName(step)}`).collapse('show');
}, 300);
}
function skipStep(step) {
markStepComplete(step);
// Show create button or next step
updateProgress();
}
function getStepName(step) {
const names = ['', 'Basic', 'Theme', 'Links', 'Social'];
return names[step];
}
function validateCurrentStep() {
if (currentStep === 1) {
const name = $('#DisplayName').val().trim();
const category = $('#Category').val().trim();
if (!name || !category) {
alert('Por favor, preencha o nome da página e selecione uma categoria.');
return false;
}
} else if (currentStep === 2) {
const theme = $('#SelectedTheme').val();
if (!theme) {
alert('Por favor, selecione um tema visual.');
return false;
}
}
return true;
}
function markStepComplete(step) {
$(`#step${step}Status`).show();
}
function updateProgress() {
const progress = (currentStep / 4) * 100;
$('.progress-bar').css('width', `${progress}%`).attr('aria-valuenow', progress);
$('.progress').next().find('small').first().text(`Passo ${currentStep} de 4`);
}
function generateSlug() {
const name = $('#DisplayName').val();
const category = $('#Category').val();
if (name && category) {
$.post('@Url.Action("GenerateSlug", "Admin")', { category: category, name: name })
.done(function(data) {
$('#Slug').val(data.slug);
$('#slugPreview').val(data.slug);
$('#categorySlug').text(category);
});
}
}
function addLinkInput(title = '', url = '', description = '', icon = '') {
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
const linkHtml = `
<div class="link-input-group" data-link="${linkCount}">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">
${iconHtml}Link ${linkCount + 1}: ${title || 'Novo Link'}
</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-2">
<input type="hidden" name="Links[${linkCount}].Id" value="new">
<label class="form-label">Título</label>
<input type="text" name="Links[${linkCount}].Title" class="form-control link-title" value="${title}" placeholder="Ex: Meu Site" readonly>
</div>
</div>
<div class="col-md-6">
<div class="mb-2">
<label class="form-label">URL</label>
<input type="url" name="Links[${linkCount}].Url" class="form-control link-url" value="${url}" placeholder="https://exemplo.com" readonly>
</div>
</div>
</div>
<div class="mb-2">
<label class="form-label">Descrição (opcional)</label>
<input type="text" name="Links[${linkCount}].Description" class="form-control link-description" value="${description}" placeholder="Breve descrição do link" readonly>
</div>
<input type="hidden" name="Links[${linkCount}].Id" value="">
<input type="hidden" name="Links[${linkCount}].Icon" value="${icon}">
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
</div>
`;
$('#linksContainer').append(linkHtml);
linkCount++;
}
function updateLinkNumbers() {
$('.link-input-group').each(function(index) {
$(this).find('h6').text('Link ' + (index + 1));
$(this).attr('data-link', index);
});
}
</script>
}

View File

@ -0,0 +1,89 @@
@{
ViewData["Title"] = "Login - BCards";
var returnUrl = ViewBag.ReturnUrl as string;
var isPreview = ViewBag.IsPreview as bool? ?? false;
Layout = isPreview ? "_Layout" : "_UserPageLayout";
}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow">
<div class="card-body p-4">
<div class="text-center mb-4">
<h2 class="text-primary fw-bold">BCards</h2>
<p class="text-muted">Entre na sua conta</p>
</div>
<form asp-controller="Auth" asp-action="LoginWithGoogle" method="post" class="mb-3">
@if (!string.IsNullOrEmpty(returnUrl))
{
<input type="hidden" name="returnUrl" value="@returnUrl" />
}
<button type="submit" class="btn btn-danger w-100 mb-2 d-flex align-items-center justify-content-center">
<i class="me-2">🔴</i>
Entrar com Google
</button>
</form>
<form asp-controller="Auth" asp-action="LoginWithMicrosoft" method="post" class="mb-3">
@if (!string.IsNullOrEmpty(returnUrl))
{
<input type="hidden" name="returnUrl" value="@returnUrl" />
}
<button type="submit" class="btn btn-primary w-100 d-flex align-items-center justify-content-center">
<i class="me-2">Ⓜ️</i>
Entrar com Microsoft
</button>
</form>
<div class="text-center">
<small class="text-muted">
Não temos acesso à sua senha. <br>
Usamos apenas autenticação segura via OAuth.
</small>
</div>
<hr class="my-4">
<div class="text-center">
<small class="text-muted">
Não tem uma conta? É grátis para começar!
</small>
</div>
</div>
</div>
<div class="text-center mt-4">
<a asp-controller="Home" asp-action="Index" class="text-decoration-none">
← Voltar ao início
</a>
</div>
</div>
</div>
</div>
@section Styles {
<style>
.card {
border-radius: 15px;
border: none;
}
.btn {
border-radius: 10px;
font-weight: 500;
padding: 0.75rem 1rem;
}
.btn-danger {
background-color: #db4437;
border-color: #db4437;
}
.btn-danger:hover {
background-color: #c23321;
border-color: #c23321;
}
</style>
}

View File

@ -0,0 +1,180 @@
@{
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";
}
<div class="hero-section bg-primary bg-gradient text-white py-5 mb-5">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-6">
<h1 class="display-4 fw-bold mb-4">
Crie sua página profissional em minutos
</h1>
<p class="lead mb-4">
A melhor alternativa ao LinkTree 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">
@if (User.Identity?.IsAuthenticated == true)
{
<a asp-controller="Admin" asp-action="Dashboard" class="btn btn-light btn-lg px-4">
Acessar Dashboard
</a>
}
else
{
<a asp-controller="Auth" asp-action="Login" class="btn btn-light btn-lg px-4">
Começar Grátis
</a>
}
<a asp-controller="Home" asp-action="Pricing" class="btn btn-outline-light btn-lg px-4">
Ver Planos
</a>
</div>
</div>
<div class="col-lg-6 text-center">
<img src="~/images/hero-mockup.svg" alt="Exemplo de página BCards" class="img-fluid rounded shadow-lg" style="max-height: 400px;">
</div>
</div>
</div>
</div>
<div class="container">
<!-- Categorias Populares -->
@if (categories.Any())
{
<section class="mb-5">
<h2 class="text-center mb-4">Categorias Populares</h2>
<div class="row g-3">
@foreach (var category in categories.Take(8))
{
<div class="col-6 col-md-3">
<a href="@Url.Action("Category", "Home", new { categorySlug = category.Slug })"
class="text-decoration-none">
<div class="card h-100 border-0 shadow-sm hover-card">
<div class="card-body text-center">
<div class="fs-1 mb-2">@category.Icon</div>
<h6 class="card-title mb-0 text-dark">@category.Name</h6>
</div>
</div>
</a>
</div>
}
</div>
</section>
}
<!-- Funcionalidades -->
<section class="mb-5">
<h2 class="text-center mb-4">Por que escolher o BCards?</h2>
<div class="row g-4">
<div class="col-md-4">
<div class="text-center">
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
<i class="fs-2 text-primary">🎨</i>
</div>
<h5>Temas Profissionais</h5>
<p class="text-muted">Escolha entre diversos temas profissionais ou personalize as cores da sua página.</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
<i class="fs-2 text-primary">📊</i>
</div>
<h5>Analytics Avançado</h5>
<p class="text-muted">Acompanhe quantas pessoas visitaram sua página e clicaram nos seus links.</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
<i class="fs-2 text-primary">🔗</i>
</div>
<h5>URLs Organizadas</h5>
<p class="text-muted">Suas URLs são organizadas por categoria: vcart.me/corretor/seu-nome</p>
</div>
</div>
</div>
</section>
<!-- Páginas Recentes -->
@if (recentPages.Any())
{
<section class="mb-5">
<h2 class="text-center mb-4">Profissionais que confiam no BCards</h2>
<div class="row g-3">
@foreach (var page in recentPages)
{
<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))
{
<img src="@(page.ProfileImage)" 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>
</div>
}
<h6 class="card-title">@(page.DisplayName)</h6>
<small class="text-muted text-capitalize">@(page.Category)</small>
<div class="mt-2">
<a href="~/@(page.Category)/@(page.Slug)" target="_blank"
class="btn btn-sm btn-outline-primary">
Ver Página
</a>
</div>
</div>
</div>
</div>
}
</div>
</section>
}
<!-- CTA Final -->
<section class="text-center py-5 bg-light rounded-3 mb-5">
<div class="container">
<h2 class="mb-3">Pronto para começar?</h2>
<p class="lead mb-4 text-muted">
Crie sua página profissional agora mesmo e comece a organizar seus links.
</p>
@if (User.Identity?.IsAuthenticated == true)
{
<a asp-controller="Admin" asp-action="Dashboard" class="btn btn-primary btn-lg">
Criar Minha Página
</a>
}
else
{
<a asp-controller="Auth" asp-action="Login" class="btn btn-primary btn-lg">
Começar Grátis
</a>
}
</div>
</section>
</div>
@section Styles {
<style>
.hero-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.hover-card {
transition: transform 0.2s ease-in-out;
}
.hover-card:hover {
transform: translateY(-5px);
}
</style>
}

View File

@ -0,0 +1,345 @@
@{
ViewData["Title"] = "Planos e Preços - BCards";
var isPreview = ViewBag.IsPreview as bool? ?? false;
Layout = isPreview ? "_Layout" : "_UserPageLayout";
}
<div class="container py-5">
<div class="text-center mb-5">
<h1 class="display-5 fw-bold mb-3">Escolha o plano ideal para você</h1>
<p class="lead text-muted">Comece grátis e faça upgrade quando precisar de mais recursos</p>
</div>
<div class="row g-4 justify-content-center">
<!-- Plano Trial -->
<div class="col-lg-3 col-md-6">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-success bg-opacity-10 text-center py-4">
<h4 class="mb-0">Trial Gratuito</h4>
<div class="mt-3">
<span class="display-4 fw-bold text-success">R$ 0</span>
<span class="text-muted">/7 dias</span>
</div>
</div>
<div class="card-body p-4">
<ul class="list-unstyled">
<li class="mb-3">
<i class="text-success me-2">✓</i>
Até <strong>3 links</strong>
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
1 tema básico
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
7 dias grátis
</li>
<li class="mb-3">
<i class="text-muted me-2">✗</i>
<span class="text-muted">Analytics</span>
</li>
<li class="mb-3">
<i class="text-muted me-2">✗</i>
<span class="text-muted">Domínio personalizado</span>
</li>
</ul>
</div>
<div class="card-footer bg-transparent p-4">
@if (User.Identity?.IsAuthenticated == true)
{
<a asp-controller="Admin" asp-action="CreatePage" class="btn btn-success w-100">Começar Grátis</a>
}
else
{
<a asp-controller="Auth" asp-action="Login" class="btn btn-success w-100">Começar Grátis</a>
}
</div>
</div>
</div>
<!-- Plano Básico -->
<div class="col-lg-3 col-md-6">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-light text-center py-4">
<h4 class="mb-0">Básico</h4>
<div class="mt-3">
<span class="display-4 fw-bold text-primary">R$ 9,90</span>
<span class="text-muted">/mês</span>
</div>
</div>
<div class="card-body p-4">
<ul class="list-unstyled">
<li class="mb-3">
<i class="text-success me-2">✓</i>
<strong>3 páginas</strong>, 8 links cada
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Temas básicos
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Analytics simples
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
URL personalizada
</li>
<li class="mb-3">
<i class="text-muted me-2">✗</i>
<span class="text-muted">Domínio personalizado</span>
</li>
<li class="mb-3">
<i class="text-muted me-2">✗</i>
<span class="text-muted">Temas customizáveis</span>
</li>
</ul>
</div>
<div class="card-footer bg-transparent p-4">
@if (User.Identity?.IsAuthenticated == true)
{
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
<input type="hidden" name="planType" value="basic" />
<button type="submit" class="btn btn-outline-primary w-100">Escolher Básico</button>
</form>
}
else
{
<a asp-controller="Auth" asp-action="Login" class="btn btn-outline-primary w-100">Escolher Básico</a>
}
</div>
</div>
</div>
<!-- Plano Profissional (Decoy) -->
<div class="col-lg-3 col-md-6">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-warning bg-opacity-10 text-center py-4">
<h4 class="mb-0">Profissional</h4>
<div class="mt-3">
<span class="display-4 fw-bold text-warning">R$ 24,90</span>
<span class="text-muted">/mês</span>
</div>
</div>
<div class="card-body p-4">
<ul class="list-unstyled">
<li class="mb-3">
<i class="text-success me-2">✓</i>
<strong>5 páginas</strong>, 20 links cada
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Todos os temas
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Analytics avançado
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Domínio personalizado
</li>
<li class="mb-3">
<i class="text-muted me-2">✗</i>
<span class="text-muted">Links ilimitados</span>
</li>
<li class="mb-3">
<i class="text-muted me-2">✗</i>
<span class="text-muted">Suporte prioritário</span>
</li>
</ul>
</div>
<div class="card-footer bg-transparent p-4">
@if (User.Identity?.IsAuthenticated == true)
{
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
<input type="hidden" name="planType" value="professional" />
<button type="submit" class="btn btn-warning w-100">Escolher Profissional</button>
</form>
}
else
{
<a asp-controller="Auth" asp-action="Login" class="btn btn-warning w-100">Escolher Profissional</a>
}
</div>
</div>
</div>
<!-- Plano Premium (Mais Popular) -->
<div class="col-lg-3 col-md-6">
<div class="card h-100 border-primary shadow position-relative">
<div class="position-absolute top-0 start-50 translate-middle">
<span class="badge bg-primary px-3 py-2">Mais Popular</span>
</div>
<div class="card-header bg-primary text-white text-center py-4">
<h4 class="mb-0">Premium</h4>
<div class="mt-3">
<span class="display-4 fw-bold">R$ 29,90</span>
<span class="opacity-75">/mês</span>
</div>
<small class="opacity-75">Melhor custo-benefício!</small>
</div>
<div class="card-body p-4">
<ul class="list-unstyled">
<li class="mb-3">
<i class="text-success me-2">✓</i>
<strong>15 páginas</strong>, links ilimitados
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Temas customizáveis
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Analytics completo
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Múltiplos domínios
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Suporte prioritário
</li>
<li class="mb-3">
<i class="text-success me-2">✓</i>
Recursos exclusivos
</li>
</ul>
</div>
<div class="card-footer bg-transparent p-4">
@if (User.Identity?.IsAuthenticated == true)
{
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
<input type="hidden" name="planType" value="premium" />
<button type="submit" class="btn btn-primary w-100 fw-bold">Escolher Premium</button>
</form>
}
else
{
<a asp-controller="Auth" asp-action="Login" class="btn btn-primary w-100 fw-bold">Escolher Premium</a>
}
</div>
</div>
</div>
</div>
<!-- Comparação de recursos -->
<div class="mt-5 pt-5">
<h2 class="text-center mb-4">Compare todos os recursos</h2>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Recursos</th>
<th class="text-center">Trial</th>
<th class="text-center">Básico</th>
<th class="text-center">Profissional</th>
<th class="text-center">Premium</th>
</tr>
</thead>
<tbody>
<tr>
<td>Páginas</td>
<td class="text-center">1</td>
<td class="text-center">3</td>
<td class="text-center">5</td>
<td class="text-center"><strong>15</strong></td>
</tr>
<tr>
<td>Links por página</td>
<td class="text-center">3</td>
<td class="text-center">8</td>
<td class="text-center">20</td>
<td class="text-center"><strong>Ilimitado</strong></td>
</tr>
<tr>
<td>Temas disponíveis</td>
<td class="text-center">1 básico</td>
<td class="text-center">Básicos</td>
<td class="text-center">Todos</td>
<td class="text-center"><strong>Customizáveis</strong></td>
</tr>
<tr>
<td>Analytics</td>
<td class="text-center">❌</td>
<td class="text-center">Simples</td>
<td class="text-center">Avançado</td>
<td class="text-center"><strong>Completo</strong></td>
</tr>
<tr>
<td>Domínio personalizado</td>
<td class="text-center">❌</td>
<td class="text-center">❌</td>
<td class="text-center">✅</td>
<td class="text-center">✅</td>
</tr>
<tr>
<td>Múltiplos domínios</td>
<td class="text-center">❌</td>
<td class="text-center">❌</td>
<td class="text-center">❌</td>
<td class="text-center"><strong>✅</strong></td>
</tr>
<tr>
<td>Suporte prioritário</td>
<td class="text-center">❌</td>
<td class="text-center">❌</td>
<td class="text-center">❌</td>
<td class="text-center"><strong>✅</strong></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- FAQ -->
<div class="mt-5 pt-5">
<h2 class="text-center mb-4">Perguntas Frequentes</h2>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="accordion" id="faqAccordion">
<div class="accordion-item">
<h3 class="accordion-header" id="faq1">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapse1">
Posso cancelar minha assinatura a qualquer momento?
</button>
</h3>
<div id="collapse1" class="accordion-collapse collapse show" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Sim! Você pode cancelar sua assinatura a qualquer momento. O cancelamento será aplicado no final do período de cobrança atual.
</div>
</div>
</div>
<div class="accordion-item">
<h3 class="accordion-header" id="faq2">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse2">
Como funciona o domínio personalizado?
</button>
</h3>
<div id="collapse2" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Com os planos Profissional e Premium, você pode conectar seu próprio domínio (ex: meusite.com) à sua página BCards.
</div>
</div>
</div>
<div class="accordion-item">
<h3 class="accordion-header" id="faq3">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse3">
O que acontece se eu exceder o limite de links?
</button>
</h3>
<div id="collapse3" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Se você atingir o limite de links do seu plano, será sugerido fazer upgrade. Seus links existentes continuarão funcionando normalmente.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="pt-BR">
<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>
@if (ViewBag.SeoSettings != null)
{
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
<meta name="description" content="@seo?.Description" />
<meta name="keywords" content="@string.Join(", ", seo?.Keywords ?? new List<string>())" />
<link rel="canonical" href="@seo?.CanonicalUrl" />
<!-- Open Graph -->
<meta property="og:title" content="@seo?.OgTitle" />
<meta property="og:description" content="@seo?.OgDescription" />
<meta property="og:image" content="@seo?.OgImage" />
<meta property="og:url" content="@seo?.CanonicalUrl" />
<meta property="og:type" content="profile" />
<!-- Twitter Card -->
<meta name="twitter:card" content="@seo?.TwitterCard" />
<meta name="twitter:title" content="@seo?.OgTitle" />
<meta name="twitter:description" content="@seo?.OgDescription" />
<meta name="twitter:image" content="@seo?.OgImage" />
}
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" />
}
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
@await RenderSectionAsync("Styles", required: false)
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container-fluid">
<a class="navbar-brand fw-bold 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">
<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>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Pricing">Planos</a>
</li>
</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">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Logout">Sair</a>
</li>
}
else
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Login">Entrar</a>
</li>
}
</ul>
</div>
</div>
</nav>
</header>
<div class="container-fluid">
<main role="main">
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (TempData["Info"] != null)
{
<div class="alert alert-info alert-dismissible fade show" role="alert">
@TempData["Info"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
<div class="row py-4">
<div class="col-md-6">
&copy; 2024 - BCards - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacidade</a>
</div>
<div class="col-md-6 text-end">
<small>Crie sua página profissional em minutos</small>
</div>
</div>
</div>
</footer>
<script src="~/lib/jquery/jquery.min.js"></script>
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -0,0 +1,215 @@
@model BCards.Web.Models.PageTheme
@{
var theme = Model ?? new BCards.Web.Models.PageTheme
{
PrimaryColor = "#2563eb",
SecondaryColor = "#1d4ed8",
BackgroundColor = "#ffffff",
TextColor = "#1f2937"
};
}
:root {
--primary-color: @theme.PrimaryColor;
--secondary-color: @theme.SecondaryColor;
--background-color: @theme.BackgroundColor;
--text-color: @theme.TextColor;
}
.user-page {
background-color: var(--background-color);
color: var(--text-color);
@if (!string.IsNullOrEmpty(theme.BackgroundImage))
{
@:background-image: url('@theme.BackgroundImage');
@:background-size: cover;
@:background-position: center;
@:background-attachment: fixed;
}
}
.profile-card {
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
max-width: 500px;
}
.profile-image {
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid var(--primary-color);
object-fit: cover;
margin: 0 auto;
}
.profile-image-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid var(--primary-color);
background-color: rgba(255, 255, 255, 0.1);
color: var(--primary-color);
}
.profile-name {
color: var(--primary-color);
font-size: 2rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.profile-bio {
color: var(--text-color);
opacity: 0.8;
margin-bottom: 2rem;
font-size: 1.1rem;
}
.links-container {
margin-bottom: 2rem;
}
.link-button {
background-color: var(--primary-color);
color: white !important;
border: none;
padding: 1rem 2rem;
border-radius: 50px;
text-decoration: none;
display: block;
margin-bottom: 1rem;
text-align: center;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
}
.link-button:hover {
background-color: var(--secondary-color);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
color: white !important;
text-decoration: none;
}
.link-button:active {
transform: translateY(0);
}
.link-title {
font-size: 1.1rem;
margin-bottom: 0.25rem;
font-weight: 600;
}
.link-description {
font-size: 0.9rem;
opacity: 0.9;
}
.link-icon {
font-size: 1.2rem;
margin-right: 0.5rem;
}
.profile-footer {
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding-top: 1rem;
}
.profile-footer a {
color: var(--primary-color);
font-weight: 500;
}
.profile-footer a:hover {
color: var(--secondary-color);
}
/* Responsive Design */
@@media (max-width: 768px) {
.profile-card {
padding: 1.5rem;
margin: 1rem;
border-radius: 15px;
}
.profile-image,
.profile-image-placeholder {
width: 100px;
height: 100px;
}
.profile-name {
font-size: 1.75rem;
}
.profile-bio {
font-size: 1rem;
}
.link-button {
padding: 0.875rem 1.5rem;
font-size: 0.95rem;
}
.link-title {
font-size: 1rem;
}
.link-description {
font-size: 0.85rem;
}
}
@@media (max-width: 480px) {
.profile-card {
padding: 1rem;
margin: 0.5rem;
}
.profile-image,
.profile-image-placeholder {
width: 80px;
height: 80px;
}
.profile-name {
font-size: 1.5rem;
}
.link-button {
padding: 0.75rem 1.25rem;
}
}
/* Dark theme adjustments */
@@media (prefers-color-scheme: dark) {
.user-page[data-theme="dark"] .profile-card {
background-color: rgba(17, 24, 39, 0.95);
color: #f9fafb;
}
}
/* Animation for link buttons */
.link-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.link-button:hover::before {
left: 100%;
}

View File

@ -0,0 +1,57 @@
@{
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
}
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(seo?.Title ?? ViewData["Title"])</title>
@if (seo != null)
{
<meta name="description" content="@seo.Description" />
<meta name="keywords" content="@string.Join(", ", seo.Keywords)" />
<link rel="canonical" href="@seo.CanonicalUrl" />
<!-- Open Graph -->
<meta property="og:title" content="@seo.OgTitle" />
<meta property="og:description" content="@seo.OgDescription" />
<meta property="og:image" content="@seo.OgImage" />
<meta property="og:url" content="@seo.CanonicalUrl" />
<meta property="og:type" content="profile" />
<meta property="og:site_name" content="BCards" />
<!-- Twitter Card -->
<meta name="twitter:card" content="@seo.TwitterCard" />
<meta name="twitter:title" content="@seo.OgTitle" />
<meta name="twitter:description" content="@seo.OgDescription" />
<meta name="twitter:image" content="@seo.OgImage" />
<!-- Schema.org markup -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "Person",
"name": "@seo.OgTitle?.Split('-').FirstOrDefault()?.Trim()",
"description": "@seo.Description",
"image": "@seo.OgImage",
"url": "@seo.CanonicalUrl"
}
</script>
}
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" />
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
@await RenderSectionAsync("Styles", required: false)
</head>
<body>
@RenderBody()
<script src="~/lib/jquery/jquery.min.js"></script>
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -0,0 +1,8 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.5/jquery.validate.min.js"
crossorigin="anonymous"
integrity="sha512-rstIgDs0xPgmG6RX1Aba4KV5cWJbAMcvRCVmglpam9SoHZiUCyQVDdH2LPlxoHtrv17XWblE/V/PP+Tr04hbtA==">
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.12/jquery.validate.unobtrusive.min.js"
crossorigin="anonymous"
integrity="sha512-o6XqxgrUsKmchwy9G5VRNWSSxTS4Urr4loO6/0hYdpWmFUfHqGzawGxeQGMDqYzxjY9sbktPbNlkIQJWagVZQg==">
</script>

View File

@ -0,0 +1,117 @@
@model BCards.Web.Models.UserPage
@{
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
var category = ViewBag.Category as BCards.Web.Models.Category;
var isPreview = ViewBag.IsPreview as bool? ?? false;
ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}";
Layout = isPreview ? "_Layout" : "_UserPageLayout";
}
@if (!isPreview)
{
@section Styles {
<style>
@Html.Raw(await Html.PartialAsync("_ThemeStyles", Model.Theme))
</style>
}
}
<div class="user-page min-vh-100 d-flex align-items-center py-4">
<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))
{
<img src="@Model.ProfileImage" alt="@Model.DisplayName" class="profile-image mb-3">
}
else
{
<div class="profile-image-placeholder mb-3 mx-auto d-flex align-items-center justify-content-center">
<i class="fs-1">👤</i>
</div>
}
<!-- Profile Info -->
<h1 class="profile-name">@Model.DisplayName</h1>
@if (!string.IsNullOrEmpty(Model.Bio))
{
<p class="profile-bio">@Model.Bio</p>
}
<!-- Links -->
<div class="links-container">
@if (Model.Links?.Any(l => l.IsActive) == true)
{
@for (int i = 0; i < Model.Links.Count; i++)
{
var link = Model.Links[i];
if (link.IsActive)
{
<a href="@link.Url"
target="_blank"
rel="noopener noreferrer"
class="link-button"
data-link-index="@i"
onclick="recordClick('@Model.Id', @i)">
@if (!string.IsNullOrEmpty(link.Icon))
{
<span class="link-icon me-2">@link.Icon</span>
}
<div>
<div class="link-title">@link.Title</div>
@if (!string.IsNullOrEmpty(link.Description))
{
<div class="link-description">@link.Description</div>
}
</div>
</a>
}
}
}
else
{
<div class="text-muted">
<p>Nenhum link disponível no momento.</p>
</div>
}
</div>
<!-- Footer -->
<div class="profile-footer mt-4 pt-3 border-top">
<small class="text-muted">
Criado com <a href="@Url.Action("Index", "Home")" class="text-decoration-none">BCards</a>
</small>
</div>
</div>
</div>
</div>
</div>
</div>
@if (isPreview)
{
<div class="position-fixed top-0 start-0 w-100 bg-warning text-dark text-center py-2" style="z-index: 9999;">
<strong>MODO PREVIEW</strong> - Esta é uma prévia da sua página
</div>
}
@section Scripts {
<script>
function recordClick(pageId, linkIndex) {
// Record click asynchronously
fetch('/click/' + pageId, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ linkIndex: linkIndex })
}).catch(function(error) {
console.log('Error recording click:', error);
});
}
</script>
}

View File

@ -0,0 +1,3 @@
@using BCards.Web
@using BCards.Web.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -0,0 +1,48 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"MongoDb": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "BCardsDB"
},
"Stripe": {
"PublishableKey": "pk_test_your_publishable_key_here",
"SecretKey": "sk_test_your_secret_key_here",
"WebhookSecret": "whsec_your_webhook_secret_here"
},
"Authentication": {
"Google": {
"ClientId": "your_google_client_id",
"ClientSecret": "your_google_client_secret"
},
"Microsoft": {
"ClientId": "b411606a-e574-4f59-b7cd-10dd941b9fa3",
"ClientSecret": "T0.8Q~an.51iW1H0DVjL2i1bmSK_qTgVQOuEmapK"
}
},
"Plans": {
"Basic": {
"PriceId": "price_basic_monthly",
"Price": 9.90,
"MaxLinks": 5,
"Features": ["basic_themes", "simple_analytics"]
},
"Professional": {
"PriceId": "price_professional_monthly",
"Price": 24.90,
"MaxLinks": 15,
"Features": ["all_themes", "advanced_analytics", "custom_domain"]
},
"Premium": {
"PriceId": "price_premium_monthly",
"Price": 29.90,
"MaxLinks": -1,
"Features": ["custom_themes", "full_analytics", "multiple_domains", "priority_support"]
}
}
}

View File

@ -0,0 +1,24 @@
{
"version": "1.0",
"defaultProvider": "cdnjs",
"libraries": [
{
"library": "bootstrap@5.3.2",
"destination": "wwwroot/lib/bootstrap/",
"files": [
"css/bootstrap.min.css",
"css/bootstrap.css",
"js/bootstrap.bundle.min.js",
"js/bootstrap.bundle.js"
]
},
{
"library": "jquery@3.7.1",
"destination": "wwwroot/lib/jquery/",
"files": [
"jquery.min.js",
"jquery.js"
]
}
]
}

View File

@ -0,0 +1,219 @@
html {
font-size: 14px;
}
@media (min-width: 768px) {
html {
font-size: 16px;
}
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
html {
position: relative;
min-height: 100%;
}
body {
margin-bottom: 60px;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
}
/* Custom Styles */
.hero-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: white !important;
}
.hover-card {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.hover-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15) !important;
}
.navbar-brand {
font-size: 1.5rem;
}
.profile-image-placeholder {
width: 120px;
height: 120px;
background-color: #f8f9fa;
border: 4px solid #007bff;
border-radius: 50%;
font-size: 3rem;
color: #6c757d;
}
/* Link Animation */
.link-button {
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.link-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s;
}
.link-button:hover::before {
left: 100%;
}
/* Responsive improvements */
@media (max-width: 768px) {
.hero-section h1 {
font-size: 2.5rem;
}
.hero-section .lead {
font-size: 1.1rem;
}
}
/* Card improvements */
.card {
border-radius: 15px;
overflow: hidden;
}
.card-header {
border-bottom: none;
}
.badge {
font-size: 0.8rem;
border-radius: 20px;
}
/* Button improvements */
.btn {
border-radius: 50px;
font-weight: 500;
padding: 0.6rem 1.5rem;
transition: all 0.3s ease;
}
.btn-lg {
padding: 0.8rem 2rem;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(37, 140, 251, 0.3);
}
/* Alert improvements */
.alert {
border-radius: 15px;
border: none;
}
/* Form improvements */
.form-control {
border-radius: 10px;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
}
/* Navbar improvements */
.navbar {
border-radius: 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.nav-link {
border-radius: 20px;
margin: 0 0.2rem;
transition: all 0.3s ease;
}
.nav-link:hover {
background-color: rgba(0,123,255,0.1);
}
/* Table improvements */
.table {
border-radius: 15px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.table thead th {
border-bottom: none;
font-weight: 600;
}
/* Accordion improvements */
.accordion-item {
border-radius: 10px;
margin-bottom: 1rem;
border: 1px solid #e9ecef;
}
.accordion-button {
border-radius: 10px;
font-weight: 500;
}
.accordion-button:not(.collapsed) {
background-color: #f8f9fa;
color: #007bff;
}
/* Loading animation */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Utility classes */
.text-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.shadow-primary {
box-shadow: 0 4px 15px rgba(0,123,255,0.3);
}
.border-gradient {
border: 2px solid;
border-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%) 1;
}

View File

@ -0,0 +1,282 @@
/* User Page Specific Styles */
.user-page {
min-height: 100vh;
background-color: #ffffff;
background-size: cover;
background-position: center;
background-attachment: fixed;
}
.profile-card {
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
max-width: 480px;
margin: 0 auto;
}
.profile-image {
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid #007bff;
object-fit: cover;
display: block;
margin: 0 auto;
}
.profile-image-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid #007bff;
background-color: #f8f9fa;
color: #6c757d;
}
.profile-name {
color: #007bff;
font-size: 2rem;
font-weight: 600;
margin-bottom: 0.5rem;
text-align: center;
}
.profile-bio {
color: #6c757d;
opacity: 0.8;
margin-bottom: 2rem;
text-align: center;
line-height: 1.6;
}
.links-container {
margin-bottom: 2rem;
}
.link-button {
background-color: #007bff;
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 50px;
text-decoration: none;
display: block;
margin-bottom: 1rem;
text-align: center;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
}
.link-button:hover {
background-color: #0056b3;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
color: white;
text-decoration: none;
}
.link-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s;
}
.link-button:hover::before {
left: 100%;
}
.link-icon {
font-size: 1.2rem;
margin-right: 0.5rem;
}
.link-title {
font-size: 1.1rem;
margin-bottom: 0.25rem;
font-weight: 600;
}
.link-description {
font-size: 0.9rem;
opacity: 0.9;
font-weight: 400;
}
.profile-footer {
text-align: center;
border-top: 1px solid rgba(108, 117, 125, 0.2);
padding-top: 1rem;
margin-top: 2rem;
}
.profile-footer a {
color: #007bff;
font-weight: 500;
}
.profile-footer a:hover {
text-decoration: underline;
}
/* Theme Variables (will be overridden by dynamic CSS) */
:root {
--primary-color: #007bff;
--secondary-color: #0056b3;
--background-color: #ffffff;
--text-color: #212529;
--card-background: rgba(255, 255, 255, 0.95);
}
/* Responsive Design */
@media (max-width: 768px) {
.profile-card {
padding: 1.5rem;
margin: 1rem;
border-radius: 15px;
}
.profile-image,
.profile-image-placeholder {
width: 100px;
height: 100px;
}
.profile-name {
font-size: 1.75rem;
}
.link-button {
padding: 0.875rem 1.5rem;
font-size: 0.95rem;
}
.link-title {
font-size: 1rem;
}
.link-description {
font-size: 0.85rem;
}
}
@media (max-width: 480px) {
.profile-card {
padding: 1rem;
margin: 0.5rem;
}
.profile-name {
font-size: 1.5rem;
}
.link-button {
padding: 0.75rem 1rem;
font-size: 0.9rem;
}
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.user-page {
background-color: #121212;
color: #ffffff;
}
.profile-card {
background-color: rgba(33, 37, 41, 0.95);
color: #ffffff;
}
.profile-bio {
color: #adb5bd;
}
.profile-footer {
border-top-color: rgba(255, 255, 255, 0.2);
}
}
/* Print styles */
@media print {
.user-page {
background: white !important;
color: black !important;
}
.profile-card {
background: white !important;
color: black !important;
box-shadow: none !important;
border: 1px solid #ccc;
}
.link-button {
background: white !important;
color: black !important;
border: 1px solid #ccc !important;
page-break-inside: avoid;
}
}
/* Loading states */
.link-button.loading {
pointer-events: none;
opacity: 0.7;
}
.link-button.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
border: 2px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Accessibility improvements */
.link-button:focus {
outline: 2px solid #fff;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.link-button,
.link-button::before {
transition: none;
}
.link-button.loading::after {
animation: none;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.profile-card {
border: 2px solid;
}
.link-button {
border: 2px solid;
}
}

View File

@ -0,0 +1 @@
data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A

View File

@ -0,0 +1 @@
data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgdmlld0JveD0iMCAwIDQwMCA0MDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIiBmaWxsPSIjRkJGQkZCIi8+CjxyZWN0IHg9IjUwIiB5PSI1MCIgd2lkdGg9IjMwMCIgaGVpZ2h0PSIzMDAiIHJ4PSIyMCIgZmlsbD0id2hpdGUiIHN0cm9rZT0iI0U1RTdFQiIgc3Ryb2tlLXdpZHRoPSIyIi8+CjxjaXJjbGUgY3g9IjIwMCIgY3k9IjEyMCIgcj0iMzAiIGZpbGw9IiMzQjgyRjYiLz4KPHN2ZyB4PSIxODAiIHk9IjEwMCIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiBmaWxsPSJ3aGl0ZSI+CjxyZWN0IHdpZHRoPSI0IiBoZWlnaHQ9IjI0IiB4PSI0IiB5PSI4Ii8+CjxyZWN0IHdpZHRoPSI0IiBoZWlnaHQ9IjI0IiB4PSIzMiIgeT0iOCIvPgo8cmVjdCB3aWR0aD0iMTIiIGhlaWdodD0iNCIgeD0iMTQiIHk9IjgiLz4KPHJlY3Qgd2lkdGg9IjEyIiBoZWlnaHQ9IjQiIHg9IjE0IiB5PSIyOCIvPgo8L3N2Zz4KPHN2ZyB4PSIxMDAiIHk9IjE2MCIgd2lkdGg9IjIwMCIgaGVpZ2h0PSI0MCIgZmlsbD0iIzM5ODNGNiI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iNDAiIHJ4PSIyMCIvPgo8dGV4dCB4PSIxMDAiIHk9IjI2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSJ3aGl0ZSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE0Ij5NZXUgUG9ydGbDs2xpbzwvdGV4dD4KPHN2ZyB4PSIxMDAiIHk9IjIyMCIgd2lkdGg9IjIwMCIgaGVpZ2h0PSI0MCIgZmlsbD0iIzEwQjk4MSI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iNDAiIHJ4PSIyMCIvPgo8dGV4dCB4PSIxMDAiIHk9IjI2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSJ3aGl0ZSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE0Ij5NZXVzIFNlcnZpw6dvczwvdGV4dD4KPHN2ZyB4PSIxMDAiIHk9IjI4MCIgd2lkdGg9IjIwMCIgaGVpZ2h0PSI0MCIgZmlsbD0iI0Y1OTUwRiI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iNDAiIHJ4PSIyMCIvPgo8dGV4dCB4PSIxMDAiIHk9IjI2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSJ3aGl0ZSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE0Ij5Db250YXRvPC90ZXh0Pgo8L3N2Zz4KPC9zdmc+CjwvdGV4dD4KPC9zdmc+CjwvdGV4dD4KPC9zdmc+CjwvdGV4dD4KPC9zdmc+

View File

@ -0,0 +1,22 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Phone mockup -->
<rect width="400" height="400" fill="#F8FAFC"/>
<rect x="50" y="50" width="300" height="300" rx="20" fill="white" stroke="#E5E7EB" stroke-width="2"/>
<!-- Profile picture -->
<circle cx="200" cy="120" r="30" fill="#3B82F6"/>
<text x="200" y="127" text-anchor="middle" fill="white" font-family="Arial" font-size="24">👤</text>
<!-- Name -->
<rect x="150" y="160" width="100" height="20" rx="4" fill="#1F2937"/>
<!-- Links -->
<rect x="100" y="200" width="200" height="40" rx="20" fill="#3983F6"/>
<text x="200" y="226" text-anchor="middle" fill="white" font-family="Arial" font-size="14">Meu Portfólio</text>
<rect x="100" y="250" width="200" height="40" rx="20" fill="#10B981"/>
<text x="200" y="276" text-anchor="middle" fill="white" font-family="Arial" font-size="14">Meus Serviços</text>
<rect x="100" y="300" width="200" height="40" rx="20" fill="#F59E0B"/>
<text x="200" y="326" text-anchor="middle" fill="white" font-family="Arial" font-size="14">Contato</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,274 @@
// Site-wide JavaScript functionality
// Utility functions
function showLoading(element) {
element.classList.add('loading');
element.disabled = true;
}
function hideLoading(element) {
element.classList.remove('loading');
element.disabled = false;
}
function showToast(message, type = 'info') {
// Create toast element
const toast = document.createElement('div');
toast.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
toast.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
toast.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(toast);
// Auto remove after 5 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 5000);
}
// Slug generation and validation
function generateSlug(text) {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
.replace(/[\s-]+/g, '-') // Replace spaces and multiple hyphens
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
}
// Form validation helpers
function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function validateUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
// Initialize Bootstrap tooltips and popovers
document.addEventListener('DOMContentLoaded', function() {
// Initialize tooltips
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Initialize popovers
const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
// Auto-hide alerts after 5 seconds
const alerts = document.querySelectorAll('.alert:not(.alert-permanent)');
alerts.forEach(alert => {
setTimeout(() => {
if (alert.parentNode) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 5000);
});
});
// Smooth scrolling for anchor links
document.addEventListener('click', function(e) {
const link = e.target.closest('a[href^="#"]');
if (link) {
e.preventDefault();
const targetId = link.getAttribute('href');
const targetElement = document.querySelector(targetId);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
});
// Form submission helpers
function submitFormWithLoading(form, button) {
showLoading(button);
// Enable form submission
form.submit();
// Keep loading state until page reload
return false;
}
// Copy to clipboard helper
function copyToClipboard(text, successMessage = 'Copiado!') {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
showToast(successMessage, 'success');
});
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showToast(successMessage, 'success');
} catch (error) {
showToast('Erro ao copiar', 'error');
}
document.body.removeChild(textArea);
}
}
// Analytics tracking (if enabled)
function trackEvent(category, action, label = null, value = null) {
if (typeof gtag !== 'undefined') {
gtag('event', action, {
event_category: category,
event_label: label,
value: value
});
}
}
// Image lazy loading for older browsers
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
observer.unobserve(img);
}
}
});
});
document.addEventListener('DOMContentLoaded', () => {
const lazyImages = document.querySelectorAll('img[data-src]');
lazyImages.forEach(img => imageObserver.observe(img));
});
}
// Service Worker registration (for PWA support) - Commented out until sw.js is created
/*
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered: ', registration);
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
*/
// Dark mode toggle (if implemented)
function toggleDarkMode() {
const body = document.body;
const isDark = body.classList.contains('dark-mode');
if (isDark) {
body.classList.remove('dark-mode');
localStorage.setItem('theme', 'light');
} else {
body.classList.add('dark-mode');
localStorage.setItem('theme', 'dark');
}
}
// Initialize theme from localStorage
document.addEventListener('DOMContentLoaded', () => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.body.classList.add('dark-mode');
}
});
// Prevent form double submission
document.addEventListener('submit', function(e) {
const form = e.target;
const submitButton = form.querySelector('button[type="submit"], input[type="submit"]');
if (submitButton && !submitButton.disabled) {
setTimeout(() => {
showLoading(submitButton);
}, 100);
}
});
// Auto-resize textareas
document.addEventListener('input', function(e) {
if (e.target.tagName === 'TEXTAREA' && e.target.classList.contains('auto-resize')) {
e.target.style.height = 'auto';
e.target.style.height = (e.target.scrollHeight) + 'px';
}
});
// Enhanced form validation
function validateForm(formElement) {
const inputs = formElement.querySelectorAll('input[required], textarea[required], select[required]');
let isValid = true;
inputs.forEach(input => {
if (!input.value.trim()) {
input.classList.add('is-invalid');
isValid = false;
} else {
input.classList.remove('is-invalid');
input.classList.add('is-valid');
}
// Specific validations
if (input.type === 'email' && input.value && !validateEmail(input.value)) {
input.classList.add('is-invalid');
input.classList.remove('is-valid');
isValid = false;
}
if (input.type === 'url' && input.value && !validateUrl(input.value)) {
input.classList.add('is-invalid');
input.classList.remove('is-valid');
isValid = false;
}
});
return isValid;
}
// Export functions for use in other scripts
window.BCards = {
showLoading,
hideLoading,
showToast,
generateSlug,
validateEmail,
validateUrl,
copyToClipboard,
trackEvent,
validateForm,
submitFormWithLoading
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

10716
src/BCards.Web/wwwroot/lib/jquery/jquery.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\BCards.Web\BCards.Web.csproj" />
</ItemGroup>
</Project>