diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..48b5eab --- /dev/null +++ b/.claude/settings.local.json @@ -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 +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bcaa5f --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/BCards.sln b/BCards.sln new file mode 100644 index 0000000..9b1a83d --- /dev/null +++ b/BCards.sln @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0fd2c38 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/scripts/init-mongo.js b/scripts/init-mongo.js new file mode 100644 index 0000000..16765a3 --- /dev/null +++ b/scripts/init-mongo.js @@ -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."); \ No newline at end of file diff --git a/src/BCards.Web/BCards.Web.csproj b/src/BCards.Web/BCards.Web.csproj new file mode 100644 index 0000000..f5427fc --- /dev/null +++ b/src/BCards.Web/BCards.Web.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/BCards.Web/Configuration/GoogleAuthSettings.cs b/src/BCards.Web/Configuration/GoogleAuthSettings.cs new file mode 100644 index 0000000..de94ad5 --- /dev/null +++ b/src/BCards.Web/Configuration/GoogleAuthSettings.cs @@ -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; +} \ No newline at end of file diff --git a/src/BCards.Web/Configuration/MongoDbSettings.cs b/src/BCards.Web/Configuration/MongoDbSettings.cs new file mode 100644 index 0000000..ef2c320 --- /dev/null +++ b/src/BCards.Web/Configuration/MongoDbSettings.cs @@ -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; +} \ No newline at end of file diff --git a/src/BCards.Web/Configuration/StripeSettings.cs b/src/BCards.Web/Configuration/StripeSettings.cs new file mode 100644 index 0000000..73b157a --- /dev/null +++ b/src/BCards.Web/Configuration/StripeSettings.cs @@ -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; +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs new file mode 100644 index 0000000..77f547a --- /dev/null +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -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 _logger; + + public AdminController( + IAuthService authService, + IUserPageService userPageService, + ICategoryService categoryService, + IThemeService themeService, + ILogger logger) + { + _authService = authService; + _userPageService = userPageService; + _categoryService = categoryService; + _themeService = themeService; + _logger = logger; + } + + + [HttpGet] + [Route("Dashboard")] + public async Task Dashboard() + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + var userPlanType = Enum.TryParse(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 ManagePage(string id = null) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + var userPlanType = Enum.TryParse(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 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 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() + }; + + // Add social media links + var socialLinks = new List(); + 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 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 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 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 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 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 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 categories, List 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(), + AvailableCategories = categories, + AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), + MaxLinksAllowed = userPlanType.GetMaxLinksPerPage() + }; + } + + private async Task 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() + }; + + // 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(); + 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(); + + // 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(); + 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); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/AuthController.cs b/src/BCards.Web/Controllers/AuthController.cs new file mode 100644 index 0000000..7c4a359 --- /dev/null +++ b/src/BCards.Web/Controllers/AuthController.cs @@ -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 GoogleCallback(string? returnUrl = null) + { + var result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme); + if (!result.Succeeded) + { + TempData["Error"] = "Falha na autenticao 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 MicrosoftCallback(string? returnUrl = null) + { + var result = await HttpContext.AuthenticateAsync(MicrosoftAccountDefaults.AuthenticationScheme); + if (!result.Succeeded) + { + TempData["Error"] = "Falha na autenticao 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 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"); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/HomeController.cs b/src/BCards.Web/Controllers/HomeController.cs new file mode 100644 index 0000000..55fd817 --- /dev/null +++ b/src/BCards.Web/Controllers/HomeController.cs @@ -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 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 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(); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/PaymentController.cs b/src/BCards.Web/Controllers/PaymentController.cs new file mode 100644 index 0000000..ee25573 --- /dev/null +++ b/src/BCards.Web/Controllers/PaymentController.cs @@ -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 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 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 ManageSubscription() + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + return View(user); + } + + [HttpPost] + public async Task 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"); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/SitemapController.cs b/src/BCards.Web/Controllers/SitemapController.cs new file mode 100644 index 0000000..400ef62 --- /dev/null +++ b/src/BCards.Web/Controllers/SitemapController.cs @@ -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 _logger; + + public SitemapController(IUserPageService userPageService, ILogger logger) + { + _userPageService = userPageService; + _logger = logger; + } + + [Route("sitemap.xml")] + [ResponseCache(Duration = 86400)] // Cache for 24 hours + public async Task 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); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/StripeWebhookController.cs b/src/BCards.Web/Controllers/StripeWebhookController.cs new file mode 100644 index 0000000..d165c7e --- /dev/null +++ b/src/BCards.Web/Controllers/StripeWebhookController.cs @@ -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 _logger; + private readonly ISubscriptionRepository _subscriptionRepository; + private readonly IUserPageService _userPageService; + private readonly string _webhookSecret; + + public StripeWebhookController( + ILogger logger, + ISubscriptionRepository subscriptionRepository, + IUserPageService userPageService, + IOptions stripeSettings) + { + _logger = logger; + _subscriptionRepository = subscriptionRepository; + _userPageService = userPageService; + _webhookSecret = stripeSettings.Value.WebhookSecret ?? ""; + } + + [HttpPost("webhook")] + public async Task 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" + }; + } +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/UserPageController.cs b/src/BCards.Web/Controllers/UserPageController.cs new file mode 100644 index 0000000..81cb418 --- /dev/null +++ b/src/BCards.Web/Controllers/UserPageController.cs @@ -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 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 RecordClick(string pageId, int linkIndex) + { + await _userPageService.RecordLinkClickAsync(pageId, linkIndex); + return Ok(); + } + + [Route("preview/{category}/{slug}")] + public async Task 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); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Dockerfile b/src/BCards.Web/Dockerfile new file mode 100644 index 0000000..d7dc062 --- /dev/null +++ b/src/BCards.Web/Dockerfile @@ -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"] \ No newline at end of file diff --git a/src/BCards.Web/Middleware/PageStatusMiddleware.cs b/src/BCards.Web/Middleware/PageStatusMiddleware.cs new file mode 100644 index 0000000..6d6ca76 --- /dev/null +++ b/src/BCards.Web/Middleware/PageStatusMiddleware.cs @@ -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 _logger; + + public PageStatusMiddleware(RequestDelegate next, ILogger 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 = $@" + + + + {page.DisplayName} - Pagamento Pendente + + + + + + + + + + + + + Pagamento Pendente + + + + {page.DisplayName} + Esta página está temporariamente indisponível devido a um pagamento pendente. + Para reativar esta página, o proprietário deve regularizar o pagamento. + Ver Planos + + + + + + +"; + + context.Response.ContentType = "text/html"; + context.Response.StatusCode = 200; // Keep as 200 for SEO, but show warning + await context.Response.WriteAsync(html); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Middleware/PlanLimitationMiddleware.cs b/src/BCards.Web/Middleware/PlanLimitationMiddleware.cs new file mode 100644 index 0000000..4bad512 --- /dev/null +++ b/src/BCards.Web/Middleware/PlanLimitationMiddleware.cs @@ -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 _logger; + + public PlanLimitationMiddleware(RequestDelegate next, ILogger 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(); + var subscriptionRepository = scope.ServiceProvider.GetRequiredService(); + var userPageRepository = scope.ServiceProvider.GetRequiredService(); + + 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" + } + }; + } +} \ No newline at end of file diff --git a/src/BCards.Web/Models/Category.cs b/src/BCards.Web/Models/Category.cs new file mode 100644 index 0000000..2365d44 --- /dev/null +++ b/src/BCards.Web/Models/Category.cs @@ -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 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; +} \ No newline at end of file diff --git a/src/BCards.Web/Models/LinkItem.cs b/src/BCards.Web/Models/LinkItem.cs new file mode 100644 index 0000000..5dd05ce --- /dev/null +++ b/src/BCards.Web/Models/LinkItem.cs @@ -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; +} \ No newline at end of file diff --git a/src/BCards.Web/Models/PageAnalytics.cs b/src/BCards.Web/Models/PageAnalytics.cs new file mode 100644 index 0000000..8fa77b1 --- /dev/null +++ b/src/BCards.Web/Models/PageAnalytics.cs @@ -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 MonthlyViews { get; set; } = new(); // "2024-01" -> count + + [BsonElement("monthlyClicks")] + public Dictionary MonthlyClicks { get; set; } = new(); + + [BsonElement("topReferrers")] + public Dictionary TopReferrers { get; set; } = new(); + + [BsonElement("deviceStats")] + public Dictionary DeviceStats { get; set; } = new(); // mobile, desktop, tablet + + [BsonElement("countryStats")] + public Dictionary CountryStats { get; set; } = new(); +} \ No newline at end of file diff --git a/src/BCards.Web/Models/PageTheme.cs b/src/BCards.Web/Models/PageTheme.cs new file mode 100644 index 0000000..78d036b --- /dev/null +++ b/src/BCards.Web/Models/PageTheme.cs @@ -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; +} \ No newline at end of file diff --git a/src/BCards.Web/Models/PlanLimitations.cs b/src/BCards.Web/Models/PlanLimitations.cs new file mode 100644 index 0000000..f2d0585 --- /dev/null +++ b/src/BCards.Web/Models/PlanLimitations.cs @@ -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"; +} \ No newline at end of file diff --git a/src/BCards.Web/Models/PlanType.cs b/src/BCards.Web/Models/PlanType.cs new file mode 100644 index 0000000..e6db8e9 --- /dev/null +++ b/src/BCards.Web/Models/PlanType.cs @@ -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; + } +} \ No newline at end of file diff --git a/src/BCards.Web/Models/SeoSettings.cs b/src/BCards.Web/Models/SeoSettings.cs new file mode 100644 index 0000000..fc137a3 --- /dev/null +++ b/src/BCards.Web/Models/SeoSettings.cs @@ -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 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; +} \ No newline at end of file diff --git a/src/BCards.Web/Models/Subscription.cs b/src/BCards.Web/Models/Subscription.cs new file mode 100644 index 0000000..733b5f2 --- /dev/null +++ b/src/BCards.Web/Models/Subscription.cs @@ -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; +} \ No newline at end of file diff --git a/src/BCards.Web/Models/User.cs b/src/BCards.Web/Models/User.cs new file mode 100644 index 0000000..739b19d --- /dev/null +++ b/src/BCards.Web/Models/User.cs @@ -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; +} \ No newline at end of file diff --git a/src/BCards.Web/Models/UserPage.cs b/src/BCards.Web/Models/UserPage.cs new file mode 100644 index 0000000..3a4e2be --- /dev/null +++ b/src/BCards.Web/Models/UserPage.cs @@ -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 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}"; +} \ No newline at end of file diff --git a/src/BCards.Web/Program.cs b/src/BCards.Web/Program.cs new file mode 100644 index 0000000..5c55c74 --- /dev/null +++ b/src/BCards.Web/Program.cs @@ -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( + builder.Configuration.GetSection("MongoDb")); + +builder.Services.AddSingleton(serviceProvider => +{ + var settings = serviceProvider.GetRequiredService>().Value; + return new MongoClient(settings.ConnectionString); +}); + +builder.Services.AddScoped(serviceProvider => +{ + var client = serviceProvider.GetRequiredService(); + var settings = serviceProvider.GetRequiredService>().Value; + return client.GetDatabase(settings.DatabaseName); +}); + +// Stripe Configuration +builder.Services.Configure( + builder.Configuration.GetSection("Stripe")); + +// OAuth Configuration +builder.Services.Configure( + builder.Configuration.GetSection("Authentication:Google")); + +builder.Services.Configure( + 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(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(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Background Services +builder.Services.AddHostedService(); + +// 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(); +app.UseMiddleware(); + +app.UseResponseCaching(); + +// Rota padr�o primeiro (mais espec�fica) +app.MapControllerRoute( + name: "default", + pattern: "{controller=Home}/{action=Index}/{id?}"); + +//Rota customizada depois (mais gen�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(); + var categoryService = scope.ServiceProvider.GetRequiredService(); + + 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>(); + logger.LogError(ex, "Error initializing default data"); + } +} + +app.Run(); \ No newline at end of file diff --git a/src/BCards.Web/Properties/launchSettings.json b/src/BCards.Web/Properties/launchSettings.json new file mode 100644 index 0000000..b12dbb0 --- /dev/null +++ b/src/BCards.Web/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "BCards.Web": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:49178;http://localhost:49179" + } + } +} \ No newline at end of file diff --git a/src/BCards.Web/Repositories/CategoryRepository.cs b/src/BCards.Web/Repositories/CategoryRepository.cs new file mode 100644 index 0000000..ba45496 --- /dev/null +++ b/src/BCards.Web/Repositories/CategoryRepository.cs @@ -0,0 +1,70 @@ +using BCards.Web.Models; +using MongoDB.Driver; + +namespace BCards.Web.Repositories; + +public class CategoryRepository : ICategoryRepository +{ + private readonly IMongoCollection _categories; + + public CategoryRepository(IMongoDatabase database) + { + _categories = database.GetCollection("categories"); + + // Create indexes + var slugIndex = Builders.IndexKeys.Ascending(x => x.Slug); + _categories.Indexes.CreateOneAsync(new CreateIndexModel(slugIndex, new CreateIndexOptions { Unique = true })); + } + + public async Task> GetAllActiveAsync() + { + return await _categories.Find(x => x.IsActive).SortBy(x => x.Name).ToListAsync(); + } + + public async Task GetBySlugAsync(string slug) + { + return await _categories.Find(x => x.Slug == slug && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task GetByIdAsync(string id) + { + return await _categories.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task CreateAsync(Category category) + { + category.CreatedAt = DateTime.UtcNow; + await _categories.InsertOneAsync(category); + return category; + } + + public async Task 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.Update.Set(x => x.IsActive, false) + ); + } + + public async Task SlugExistsAsync(string slug, string? excludeId = null) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Slug, slug), + Builders.Filter.Eq(x => x.IsActive, true) + ); + + if (!string.IsNullOrEmpty(excludeId)) + { + filter = Builders.Filter.And(filter, + Builders.Filter.Ne(x => x.Id, excludeId)); + } + + return await _categories.Find(filter).AnyAsync(); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Repositories/ICategoryRepository.cs b/src/BCards.Web/Repositories/ICategoryRepository.cs new file mode 100644 index 0000000..4e03eac --- /dev/null +++ b/src/BCards.Web/Repositories/ICategoryRepository.cs @@ -0,0 +1,14 @@ +using BCards.Web.Models; + +namespace BCards.Web.Repositories; + +public interface ICategoryRepository +{ + Task> GetAllActiveAsync(); + Task GetBySlugAsync(string slug); + Task GetByIdAsync(string id); + Task CreateAsync(Category category); + Task UpdateAsync(Category category); + Task DeleteAsync(string id); + Task SlugExistsAsync(string slug, string? excludeId = null); +} \ No newline at end of file diff --git a/src/BCards.Web/Repositories/ISubscriptionRepository.cs b/src/BCards.Web/Repositories/ISubscriptionRepository.cs new file mode 100644 index 0000000..49a9c82 --- /dev/null +++ b/src/BCards.Web/Repositories/ISubscriptionRepository.cs @@ -0,0 +1,14 @@ +using BCards.Web.Models; + +namespace BCards.Web.Repositories; + +public interface ISubscriptionRepository +{ + Task GetByUserIdAsync(string userId); + Task GetByStripeSubscriptionIdAsync(string stripeSubscriptionId); + Task CreateAsync(Subscription subscription); + Task UpdateAsync(Subscription subscription); + Task DeleteAsync(string id); + Task> GetExpiringSoonAsync(int days = 7); + Task> GetTrialSubscriptionsAsync(); +} \ No newline at end of file diff --git a/src/BCards.Web/Repositories/IUserPageRepository.cs b/src/BCards.Web/Repositories/IUserPageRepository.cs new file mode 100644 index 0000000..4b2baf4 --- /dev/null +++ b/src/BCards.Web/Repositories/IUserPageRepository.cs @@ -0,0 +1,19 @@ +using BCards.Web.Models; + +namespace BCards.Web.Repositories; + +public interface IUserPageRepository +{ + Task GetByIdAsync(string id); + Task GetBySlugAsync(string category, string slug); + Task GetByUserIdAsync(string userId); + Task> GetByUserIdAllAsync(string userId); + Task> GetActivePagesAsync(); + Task CreateAsync(UserPage userPage); + Task UpdateAsync(UserPage userPage); + Task DeleteAsync(string id); + Task SlugExistsAsync(string category, string slug, string? excludeId = null); + Task> GetRecentPagesAsync(int limit = 10); + Task> GetByCategoryAsync(string category, int limit = 20); + Task UpdateAnalyticsAsync(string id, PageAnalytics analytics); +} \ No newline at end of file diff --git a/src/BCards.Web/Repositories/IUserRepository.cs b/src/BCards.Web/Repositories/IUserRepository.cs new file mode 100644 index 0000000..8c1ee13 --- /dev/null +++ b/src/BCards.Web/Repositories/IUserRepository.cs @@ -0,0 +1,13 @@ +using BCards.Web.Models; + +namespace BCards.Web.Repositories; + +public interface IUserRepository +{ + Task GetByIdAsync(string id); + Task GetByEmailAsync(string email); + Task CreateAsync(User user); + Task UpdateAsync(User user); + Task DeleteAsync(string id); + Task ExistsAsync(string email); +} \ No newline at end of file diff --git a/src/BCards.Web/Repositories/SubscriptionRepository.cs b/src/BCards.Web/Repositories/SubscriptionRepository.cs new file mode 100644 index 0000000..80dc345 --- /dev/null +++ b/src/BCards.Web/Repositories/SubscriptionRepository.cs @@ -0,0 +1,64 @@ +using BCards.Web.Models; +using MongoDB.Driver; + +namespace BCards.Web.Repositories; + +public class SubscriptionRepository : ISubscriptionRepository +{ + private readonly IMongoCollection _subscriptions; + + public SubscriptionRepository(IMongoDatabase database) + { + _subscriptions = database.GetCollection("subscriptions"); + + // Create indexes + var userIndex = Builders.IndexKeys.Ascending(x => x.UserId); + _subscriptions.Indexes.CreateOneAsync(new CreateIndexModel(userIndex)); + + var stripeIndex = Builders.IndexKeys.Ascending(x => x.StripeSubscriptionId); + _subscriptions.Indexes.CreateOneAsync(new CreateIndexModel(stripeIndex)); + } + + public async Task GetByUserIdAsync(string userId) + { + return await _subscriptions.Find(x => x.UserId == userId).FirstOrDefaultAsync(); + } + + public async Task GetByStripeSubscriptionIdAsync(string stripeSubscriptionId) + { + return await _subscriptions.Find(x => x.StripeSubscriptionId == stripeSubscriptionId).FirstOrDefaultAsync(); + } + + public async Task CreateAsync(Subscription subscription) + { + subscription.CreatedAt = DateTime.UtcNow; + subscription.UpdatedAt = DateTime.UtcNow; + await _subscriptions.InsertOneAsync(subscription); + return subscription; + } + + public async Task 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> 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> GetTrialSubscriptionsAsync() + { + return await _subscriptions.Find(x => x.PlanType == "trial" && x.Status == "active") + .ToListAsync(); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Repositories/UserPageRepository.cs b/src/BCards.Web/Repositories/UserPageRepository.cs new file mode 100644 index 0000000..5954196 --- /dev/null +++ b/src/BCards.Web/Repositories/UserPageRepository.cs @@ -0,0 +1,119 @@ +using BCards.Web.Models; +using MongoDB.Driver; + +namespace BCards.Web.Repositories; + +public class UserPageRepository : IUserPageRepository +{ + private readonly IMongoCollection _pages; + + public UserPageRepository(IMongoDatabase database) + { + _pages = database.GetCollection("userpages"); + + // Create indexes + var slugIndex = Builders.IndexKeys + .Ascending(x => x.Category) + .Ascending(x => x.Slug); + _pages.Indexes.CreateOneAsync(new CreateIndexModel(slugIndex, new CreateIndexOptions { Unique = true })); + + var userIndex = Builders.IndexKeys.Ascending(x => x.UserId); + _pages.Indexes.CreateOneAsync(new CreateIndexModel(userIndex)); + + var categoryIndex = Builders.IndexKeys.Ascending(x => x.Category); + _pages.Indexes.CreateOneAsync(new CreateIndexModel(categoryIndex)); + } + + public async Task GetByIdAsync(string id) + { + return await _pages.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task GetBySlugAsync(string category, string slug) + { + return await _pages.Find(x => x.Category == category.ToLower() && x.Slug == slug && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task GetByUserIdAsync(string userId) + { + return await _pages.Find(x => x.UserId == userId && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task> GetByUserIdAllAsync(string userId) + { + return await _pages.Find(x => x.UserId == userId && x.IsActive).ToListAsync(); + } + + public async Task CreateAsync(UserPage userPage) + { + userPage.CreatedAt = DateTime.UtcNow; + userPage.UpdatedAt = DateTime.UtcNow; + await _pages.InsertOneAsync(userPage); + return userPage; + } + + public async Task 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.Update.Set(x => x.IsActive, false).Set(x => x.UpdatedAt, DateTime.UtcNow) + ); + } + + public async Task SlugExistsAsync(string category, string slug, string? excludeId = null) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Category, category), + Builders.Filter.Eq(x => x.Slug, slug), + Builders.Filter.Eq(x => x.IsActive, true) + ); + + if (!string.IsNullOrEmpty(excludeId)) + { + filter = Builders.Filter.And(filter, + Builders.Filter.Ne(x => x.Id, excludeId)); + } + + return await _pages.Find(filter).AnyAsync(); + } + + public async Task> GetRecentPagesAsync(int limit = 10) + { + return await _pages.Find(x => x.IsActive && x.PublishedAt != null) + .SortByDescending(x => x.PublishedAt) + .Limit(limit) + .ToListAsync(); + } + + public async Task> 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> 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.Update + .Set(x => x.Analytics, analytics) + .Set(x => x.UpdatedAt, DateTime.UtcNow) + ); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Repositories/UserRepository.cs b/src/BCards.Web/Repositories/UserRepository.cs new file mode 100644 index 0000000..48b6a28 --- /dev/null +++ b/src/BCards.Web/Repositories/UserRepository.cs @@ -0,0 +1,57 @@ +using BCards.Web.Models; +using MongoDB.Driver; + +namespace BCards.Web.Repositories; + +public class UserRepository : IUserRepository +{ + private readonly IMongoCollection _users; + + public UserRepository(IMongoDatabase database) + { + _users = database.GetCollection("users"); + + // Create indexes + var indexKeys = Builders.IndexKeys.Ascending(x => x.Email); + var indexOptions = new CreateIndexOptions { Unique = true }; + _users.Indexes.CreateOneAsync(new CreateIndexModel(indexKeys, indexOptions)); + } + + public async Task GetByIdAsync(string id) + { + return await _users.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task GetByEmailAsync(string email) + { + return await _users.Find(x => x.Email == email && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task CreateAsync(User user) + { + user.CreatedAt = DateTime.UtcNow; + user.UpdatedAt = DateTime.UtcNow; + await _users.InsertOneAsync(user); + return user; + } + + public async Task 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.Update.Set(x => x.IsActive, false).Set(x => x.UpdatedAt, DateTime.UtcNow) + ); + } + + public async Task ExistsAsync(string email) + { + return await _users.Find(x => x.Email == email && x.IsActive).AnyAsync(); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Resources/pt-BR/SharedResource.resx b/src/BCards.Web/Resources/pt-BR/SharedResource.resx new file mode 100644 index 0000000..5621468 --- /dev/null +++ b/src/BCards.Web/Resources/pt-BR/SharedResource.resx @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Início + + + Planos + + + Entrar + + + Dashboard + + + Sair + + + Criar Página + + + Editar Página + + + Salvar + + + Cancelar + + + Excluir + + + Nome + + + E-mail + + + Biografia + + + Categoria + + + URL + + + Links + + + Título + + + URL + + + Descrição + + + Tema + + + Analytics + + + Visualizações + + + Cliques + + \ No newline at end of file diff --git a/src/BCards.Web/Services/AuthService.cs b/src/BCards.Web/Services/AuthService.cs new file mode 100644 index 0000000..a579f26 --- /dev/null +++ b/src/BCards.Web/Services/AuthService.cs @@ -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 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 GetCurrentUserAsync(ClaimsPrincipal claimsPrincipal) + { + var email = claimsPrincipal.FindFirst(ClaimTypes.Email)?.Value; + if (string.IsNullOrEmpty(email)) + return null; + + return await _userRepository.GetByEmailAsync(email); + } + + public async Task GetUserByIdAsync(string userId) + { + return await _userRepository.GetByIdAsync(userId); + } + + public async Task UpdateUserAsync(User user) + { + return await _userRepository.UpdateAsync(user); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/CategoryService.cs b/src/BCards.Web/Services/CategoryService.cs new file mode 100644 index 0000000..90032c1 --- /dev/null +++ b/src/BCards.Web/Services/CategoryService.cs @@ -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> GetAllCategoriesAsync() + { + return await _categoryRepository.GetAllActiveAsync(); + } + + public async Task GetCategoryBySlugAsync(string slug) + { + return await _categoryRepository.GetBySlugAsync(slug); + } + + public async Task 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 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 { "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 { "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 { "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 { "educação", "ensino", "professor", "curso", "escola" } + }, + new Category + { + Name = "Comércio", + Slug = "comercio", + Icon = "🛍️", + Description = "Lojas, e-commerce e estabelecimentos comerciais", + SeoKeywords = new List { "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 { "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 { "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 { "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 { "advogado", "jurídico", "direito", "advocacia", "legal" } + }, + new Category + { + Name = "Arquitetura", + Slug = "arquitetura", + Icon = "🏗️", + Description = "Arquitetos, engenheiros e profissionais da construção", + SeoKeywords = new List { "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('-'); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/IAuthService.cs b/src/BCards.Web/Services/IAuthService.cs new file mode 100644 index 0000000..d624a2e --- /dev/null +++ b/src/BCards.Web/Services/IAuthService.cs @@ -0,0 +1,12 @@ +using BCards.Web.Models; +using System.Security.Claims; + +namespace BCards.Web.Services; + +public interface IAuthService +{ + Task CreateOrUpdateUserFromClaimsAsync(ClaimsPrincipal claimsPrincipal); + Task GetCurrentUserAsync(ClaimsPrincipal claimsPrincipal); + Task GetUserByIdAsync(string userId); + Task UpdateUserAsync(User user); +} \ No newline at end of file diff --git a/src/BCards.Web/Services/ICategoryService.cs b/src/BCards.Web/Services/ICategoryService.cs new file mode 100644 index 0000000..6f48f7b --- /dev/null +++ b/src/BCards.Web/Services/ICategoryService.cs @@ -0,0 +1,12 @@ +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public interface ICategoryService +{ + Task> GetAllCategoriesAsync(); + Task GetCategoryBySlugAsync(string slug); + Task InitializeDefaultCategoriesAsync(); + Task GenerateSlugAsync(string name); + Task ValidateSlugAsync(string slug, string? excludeId = null); +} \ No newline at end of file diff --git a/src/BCards.Web/Services/IPaymentService.cs b/src/BCards.Web/Services/IPaymentService.cs new file mode 100644 index 0000000..ea8cd0c --- /dev/null +++ b/src/BCards.Web/Services/IPaymentService.cs @@ -0,0 +1,15 @@ +using BCards.Web.Models; +using Stripe; + +namespace BCards.Web.Services; + +public interface IPaymentService +{ + Task CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl); + Task CreateOrGetCustomerAsync(string userId, string email, string name); + Task HandleWebhookAsync(string requestBody, string signature); + Task> GetPricesAsync(); + Task CancelSubscriptionAsync(string subscriptionId); + Task UpdateSubscriptionAsync(string subscriptionId, string newPriceId); + Task GetPlanLimitationsAsync(string planType); +} \ No newline at end of file diff --git a/src/BCards.Web/Services/ISeoService.cs b/src/BCards.Web/Services/ISeoService.cs new file mode 100644 index 0000000..ea4da09 --- /dev/null +++ b/src/BCards.Web/Services/ISeoService.cs @@ -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 GenerateKeywords(UserPage userPage, Category category); + string GenerateCanonicalUrl(UserPage userPage); +} \ No newline at end of file diff --git a/src/BCards.Web/Services/IThemeService.cs b/src/BCards.Web/Services/IThemeService.cs new file mode 100644 index 0000000..bed15e8 --- /dev/null +++ b/src/BCards.Web/Services/IThemeService.cs @@ -0,0 +1,13 @@ +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public interface IThemeService +{ + Task> GetAvailableThemesAsync(); + Task GetThemeByIdAsync(string themeId); + Task GetThemeByNameAsync(string themeName); + Task GenerateCustomCssAsync(PageTheme theme); + Task InitializeDefaultThemesAsync(); + PageTheme GetDefaultTheme(); +} \ No newline at end of file diff --git a/src/BCards.Web/Services/IUserPageService.cs b/src/BCards.Web/Services/IUserPageService.cs new file mode 100644 index 0000000..d5be8f6 --- /dev/null +++ b/src/BCards.Web/Services/IUserPageService.cs @@ -0,0 +1,22 @@ +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public interface IUserPageService +{ + Task GetPageAsync(string category, string slug); + Task GetUserPageAsync(string userId); + Task GetPageByIdAsync(string id); + Task> GetUserPagesAsync(string userId); + Task> GetActivePagesAsync(); + Task CreatePageAsync(UserPage userPage); + Task UpdatePageAsync(UserPage userPage); + Task DeletePageAsync(string id); + Task ValidateSlugAsync(string category, string slug, string? excludeId = null); + Task GenerateSlugAsync(string category, string name); + Task CanCreateLinksAsync(string userId, int newLinksCount = 1); + Task RecordPageViewAsync(string pageId, string? referrer = null, string? userAgent = null); + Task RecordLinkClickAsync(string pageId, int linkIndex); + Task> GetRecentPagesAsync(int limit = 10); + Task> GetPagesByCategoryAsync(string category, int limit = 20); +} \ No newline at end of file diff --git a/src/BCards.Web/Services/PaymentService.cs b/src/BCards.Web/Services/PaymentService.cs new file mode 100644 index 0000000..bdbccb8 --- /dev/null +++ b/src/BCards.Web/Services/PaymentService.cs @@ -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, + IUserRepository userRepository, + ISubscriptionRepository subscriptionRepository, + IConfiguration configuration) + { + _stripeSettings = stripeSettings.Value; + _userRepository = userRepository; + _subscriptionRepository = subscriptionRepository; + _configuration = configuration; + + StripeConfiguration.ApiKey = _stripeSettings.SecretKey; + } + + public async Task 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 { "card" }, + Mode = "subscription", + Customer = customer.Id, + LineItems = new List + { + new() + { + Price = priceId, + Quantity = 1 + } + }, + SuccessUrl = returnUrl, + CancelUrl = cancelUrl, + Metadata = new Dictionary + { + { "user_id", userId }, + { "plan_type", planType } + } + }; + + var service = new SessionService(); + var session = await service.CreateAsync(options); + + return session.Url; + } + + public async Task 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 + { + { "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 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> 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 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 UpdateSubscriptionAsync(string subscriptionId, string newPriceId) + { + var service = new SubscriptionService(); + var subscription = await service.GetAsync(subscriptionId); + + var options = new SubscriptionUpdateOptions + { + Items = new List + { + new() + { + Id = subscription.Items.Data[0].Id, + Price = newPriceId + } + } + }; + + return await service.UpdateAsync(subscriptionId, options); + } + + public Task 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); + } + } + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/SeoService.cs b/src/BCards.Web/Services/SeoService.cs new file mode 100644 index 0000000..51e2016 --- /dev/null +++ b/src/BCards.Web/Services/SeoService.cs @@ -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 GenerateKeywords(UserPage userPage, Category category) + { + var keywords = new List + { + 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}"; + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/ThemeService.cs b/src/BCards.Web/Services/ThemeService.cs new file mode 100644 index 0000000..fd11302 --- /dev/null +++ b/src/BCards.Web/Services/ThemeService.cs @@ -0,0 +1,215 @@ +using BCards.Web.Models; +using MongoDB.Driver; + +namespace BCards.Web.Services; + +public class ThemeService : IThemeService +{ + private readonly IMongoCollection _themes; + + public ThemeService(IMongoDatabase database) + { + _themes = database.GetCollection("themes"); + } + + public async Task> GetAvailableThemesAsync() + { + return await _themes.Find(x => x.IsActive).ToListAsync(); + } + + public async Task GetThemeByIdAsync(string themeId) + { + return await _themes.Find(x => x.Id == themeId && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task GetThemeByNameAsync(string themeName) + { + var theme = await _themes.Find(x => x.Name.ToLower() == themeName.ToLower() && x.IsActive).FirstOrDefaultAsync(); + return theme ?? GetDefaultTheme(); + } + + public Task 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" + }; + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/TrialExpirationService.cs b/src/BCards.Web/Services/TrialExpirationService.cs new file mode 100644 index 0000000..04313df --- /dev/null +++ b/src/BCards.Web/Services/TrialExpirationService.cs @@ -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 _logger; + private readonly TimeSpan _checkInterval = TimeSpan.FromHours(1); // Check every hour + + public TrialExpirationService( + IServiceProvider serviceProvider, + ILogger 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(); + var userPageRepository = scope.ServiceProvider.GetRequiredService(); + var userRepository = scope.ServiceProvider.GetRequiredService(); + + _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(); + 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"; + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/UserPageService.cs b/src/BCards.Web/Services/UserPageService.cs new file mode 100644 index 0000000..7c375ee --- /dev/null +++ b/src/BCards.Web/Services/UserPageService.cs @@ -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 GetPageAsync(string category, string slug) + { + return await _userPageRepository.GetBySlugAsync(category, slug); + } + + public async Task GetUserPageAsync(string userId) + { + return await _userPageRepository.GetByUserIdAsync(userId); + } + + public async Task GetPageByIdAsync(string id) + { + return await _userPageRepository.GetByIdAsync(id); + } + + public async Task
Esta página está temporariamente indisponível devido a um pagamento pendente.
Para reativar esta página, o proprietário deve regularizar o pagamento.