feat: primeira versão
This commit is contained in:
parent
b2d54a1cc0
commit
6ba824c155
22
.claude/settings.local.json
Normal file
22
.claude/settings.local.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet --version)",
|
||||
"Bash(dotnet restore:*)",
|
||||
"Bash(dotnet build)",
|
||||
"Bash(dotnet test)",
|
||||
"Bash(dotnet tool install:*)",
|
||||
"Bash(~/.dotnet/tools/libman restore)",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(dotnet clean:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(sudo rm:*)",
|
||||
"Bash(rm:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": false
|
||||
}
|
||||
382
.gitignore
vendored
Normal file
382
.gitignore
vendored
Normal file
@ -0,0 +1,382 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/*.HxS
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these files may be disclosed. Comment the next line if you want to checkin
|
||||
# your web deploy settings, but sensitive information contained in these files may
|
||||
# be disclosed. Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.azurePubxml
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment the next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
CConvertLog*.txt
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Custom
|
||||
appsettings.Local.json
|
||||
appsettings.Production.json
|
||||
*.Development.json
|
||||
logs/
|
||||
uploads/
|
||||
temp/
|
||||
*.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Certificates
|
||||
*.pfx
|
||||
*.crt
|
||||
*.key
|
||||
27
BCards.sln
Normal file
27
BCards.sln
Normal file
@ -0,0 +1,27 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Tests", "tests\BCards.Tests\BCards.Tests.csproj", "{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
55
docker-compose.yml
Normal file
55
docker-compose.yml
Normal file
@ -0,0 +1,55 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:7.0
|
||||
container_name: bcards-mongodb
|
||||
ports:
|
||||
- "27017:27017"
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: admin
|
||||
MONGO_INITDB_ROOT_PASSWORD: password123
|
||||
MONGO_INITDB_DATABASE: BCardsDB
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
- ./scripts/init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro
|
||||
networks:
|
||||
- bcards-network
|
||||
|
||||
bcards-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: src/BCards.Web/Dockerfile
|
||||
container_name: bcards-web
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8443:443"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- MongoDb__ConnectionString=mongodb://admin:password123@mongodb:27017/BCardsDB?authSource=admin
|
||||
- MongoDb__DatabaseName=BCardsDB
|
||||
depends_on:
|
||||
- mongodb
|
||||
networks:
|
||||
- bcards-network
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: bcards-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks:
|
||||
- bcards-network
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
bcards-network:
|
||||
driver: bridge
|
||||
194
scripts/init-mongo.js
Normal file
194
scripts/init-mongo.js
Normal file
@ -0,0 +1,194 @@
|
||||
// MongoDB initialization script
|
||||
db = db.getSiblingDB('BCardsDB');
|
||||
|
||||
// Create collections
|
||||
db.createCollection('users');
|
||||
db.createCollection('userpages');
|
||||
db.createCollection('categories');
|
||||
db.createCollection('subscriptions');
|
||||
db.createCollection('themes');
|
||||
|
||||
// Create indexes for users
|
||||
db.users.createIndex({ "email": 1 }, { unique: true });
|
||||
db.users.createIndex({ "stripeCustomerId": 1 });
|
||||
|
||||
// Create indexes for userpages
|
||||
db.userpages.createIndex({ "userId": 1 });
|
||||
db.userpages.createIndex({ "category": 1, "slug": 1 }, { unique: true });
|
||||
db.userpages.createIndex({ "category": 1 });
|
||||
db.userpages.createIndex({ "isActive": 1 });
|
||||
db.userpages.createIndex({ "publishedAt": -1 });
|
||||
|
||||
// Create indexes for categories
|
||||
db.categories.createIndex({ "slug": 1 }, { unique: true });
|
||||
db.categories.createIndex({ "isActive": 1 });
|
||||
|
||||
// Create indexes for subscriptions
|
||||
db.subscriptions.createIndex({ "userId": 1 });
|
||||
db.subscriptions.createIndex({ "stripeSubscriptionId": 1 });
|
||||
db.subscriptions.createIndex({ "status": 1 });
|
||||
|
||||
// Create indexes for themes
|
||||
db.themes.createIndex({ "isActive": 1 });
|
||||
db.themes.createIndex({ "isPremium": 1 });
|
||||
|
||||
// Insert default categories
|
||||
db.categories.insertMany([
|
||||
{
|
||||
name: "Corretor de Imóveis",
|
||||
slug: "corretor",
|
||||
icon: "🏠",
|
||||
description: "Profissionais especializados em compra, venda e locação de imóveis",
|
||||
seoKeywords: ["corretor", "imóveis", "casa", "apartamento", "venda", "locação"],
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Tecnologia",
|
||||
slug: "tecnologia",
|
||||
icon: "💻",
|
||||
description: "Empresas e profissionais de tecnologia, desenvolvimento e TI",
|
||||
seoKeywords: ["desenvolvimento", "software", "programação", "tecnologia", "TI"],
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Saúde",
|
||||
slug: "saude",
|
||||
icon: "🏥",
|
||||
description: "Profissionais da saúde, clínicas e consultórios médicos",
|
||||
seoKeywords: ["médico", "saúde", "clínica", "consulta", "tratamento"],
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Educação",
|
||||
slug: "educacao",
|
||||
icon: "📚",
|
||||
description: "Professores, escolas, cursos e instituições de ensino",
|
||||
seoKeywords: ["educação", "ensino", "professor", "curso", "escola"],
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Comércio",
|
||||
slug: "comercio",
|
||||
icon: "🛍️",
|
||||
description: "Lojas, e-commerce e estabelecimentos comerciais",
|
||||
seoKeywords: ["loja", "comércio", "venda", "produtos", "e-commerce"],
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Serviços",
|
||||
slug: "servicos",
|
||||
icon: "🔧",
|
||||
description: "Prestadores de serviços gerais e especializados",
|
||||
seoKeywords: ["serviços", "prestador", "profissional", "especializado"],
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Alimentação",
|
||||
slug: "alimentacao",
|
||||
icon: "🍽️",
|
||||
description: "Restaurantes, delivery, food trucks e estabelecimentos alimentícios",
|
||||
seoKeywords: ["restaurante", "comida", "delivery", "alimentação", "gastronomia"],
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Beleza",
|
||||
slug: "beleza",
|
||||
icon: "💄",
|
||||
description: "Salões de beleza, barbearias, estética e cuidados pessoais",
|
||||
seoKeywords: ["beleza", "salão", "estética", "cabeleireiro", "manicure"],
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Advocacia",
|
||||
slug: "advocacia",
|
||||
icon: "⚖️",
|
||||
description: "Advogados, escritórios jurídicos e consultoria legal",
|
||||
seoKeywords: ["advogado", "jurídico", "direito", "advocacia", "legal"],
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Arquitetura",
|
||||
slug: "arquitetura",
|
||||
icon: "🏗️",
|
||||
description: "Arquitetos, engenheiros e profissionais da construção",
|
||||
seoKeywords: ["arquiteto", "engenheiro", "construção", "projeto", "reforma"],
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
}
|
||||
]);
|
||||
|
||||
// Insert default themes
|
||||
db.themes.insertMany([
|
||||
{
|
||||
name: "Minimalista",
|
||||
primaryColor: "#2563eb",
|
||||
secondaryColor: "#1d4ed8",
|
||||
backgroundColor: "#ffffff",
|
||||
textColor: "#1f2937",
|
||||
backgroundImage: "",
|
||||
isPremium: false,
|
||||
cssTemplate: "minimal",
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Dark Mode",
|
||||
primaryColor: "#10b981",
|
||||
secondaryColor: "#059669",
|
||||
backgroundColor: "#111827",
|
||||
textColor: "#f9fafb",
|
||||
backgroundImage: "",
|
||||
isPremium: false,
|
||||
cssTemplate: "dark",
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Natureza",
|
||||
primaryColor: "#16a34a",
|
||||
secondaryColor: "#15803d",
|
||||
backgroundColor: "#f0fdf4",
|
||||
textColor: "#166534",
|
||||
backgroundImage: "/images/themes/nature-bg.jpg",
|
||||
isPremium: false,
|
||||
cssTemplate: "nature",
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Corporativo",
|
||||
primaryColor: "#1e40af",
|
||||
secondaryColor: "#1e3a8a",
|
||||
backgroundColor: "#f8fafc",
|
||||
textColor: "#0f172a",
|
||||
backgroundImage: "",
|
||||
isPremium: false,
|
||||
cssTemplate: "corporate",
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
name: "Vibrante",
|
||||
primaryColor: "#dc2626",
|
||||
secondaryColor: "#b91c1c",
|
||||
backgroundColor: "#fef2f2",
|
||||
textColor: "#7f1d1d",
|
||||
backgroundImage: "",
|
||||
isPremium: true,
|
||||
cssTemplate: "vibrant",
|
||||
isActive: true,
|
||||
createdAt: new Date()
|
||||
}
|
||||
]);
|
||||
|
||||
print("Database initialized successfully!");
|
||||
print("Collections created with indexes and default data inserted.");
|
||||
30
src/BCards.Web/BCards.Web.csproj
Normal file
30
src/BCards.Web/BCards.Web.csproj
Normal file
@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
|
||||
<PackageReference Include="Stripe.net" Version="44.7.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Localization" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\**\*.resx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Views\Payment\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
13
src/BCards.Web/Configuration/GoogleAuthSettings.cs
Normal file
13
src/BCards.Web/Configuration/GoogleAuthSettings.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace BCards.Web.Configuration;
|
||||
|
||||
public class GoogleAuthSettings
|
||||
{
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class MicrosoftAuthSettings
|
||||
{
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
}
|
||||
7
src/BCards.Web/Configuration/MongoDbSettings.cs
Normal file
7
src/BCards.Web/Configuration/MongoDbSettings.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace BCards.Web.Configuration;
|
||||
|
||||
public class MongoDbSettings
|
||||
{
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
public string DatabaseName { get; set; } = string.Empty;
|
||||
}
|
||||
8
src/BCards.Web/Configuration/StripeSettings.cs
Normal file
8
src/BCards.Web/Configuration/StripeSettings.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace BCards.Web.Configuration;
|
||||
|
||||
public class StripeSettings
|
||||
{
|
||||
public string PublishableKey { get; set; } = string.Empty;
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
public string WebhookSecret { get; set; } = string.Empty;
|
||||
}
|
||||
673
src/BCards.Web/Controllers/AdminController.cs
Normal file
673
src/BCards.Web/Controllers/AdminController.cs
Normal file
@ -0,0 +1,673 @@
|
||||
using BCards.Web.Models;
|
||||
using BCards.Web.Services;
|
||||
using BCards.Web.ViewModels;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace BCards.Web.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[Route("Admin")]
|
||||
public class AdminController : Controller
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
private readonly IUserPageService _userPageService;
|
||||
private readonly ICategoryService _categoryService;
|
||||
private readonly IThemeService _themeService;
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
|
||||
public AdminController(
|
||||
IAuthService authService,
|
||||
IUserPageService userPageService,
|
||||
ICategoryService categoryService,
|
||||
IThemeService themeService,
|
||||
ILogger<AdminController> logger)
|
||||
{
|
||||
_authService = authService;
|
||||
_userPageService = userPageService;
|
||||
_categoryService = categoryService;
|
||||
_themeService = themeService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
||||
[HttpGet]
|
||||
[Route("Dashboard")]
|
||||
public async Task<IActionResult> Dashboard()
|
||||
{
|
||||
var user = await _authService.GetCurrentUserAsync(User);
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
|
||||
var userPages = await _userPageService.GetUserPagesAsync(user.Id);
|
||||
|
||||
var dashboardModel = new DashboardViewModel
|
||||
{
|
||||
CurrentUser = user,
|
||||
UserPages = userPages.Select(p => new UserPageSummary
|
||||
{
|
||||
Id = p.Id,
|
||||
DisplayName = p.DisplayName,
|
||||
Slug = p.Slug,
|
||||
Category = p.Category,
|
||||
Status = p.Status,
|
||||
TotalClicks = p.Analytics?.TotalClicks ?? 0,
|
||||
TotalViews = p.Analytics?.TotalViews ?? 0,
|
||||
CreatedAt = p.CreatedAt
|
||||
}).ToList(),
|
||||
CurrentPlan = new PlanInfo
|
||||
{
|
||||
Type = userPlanType,
|
||||
Name = userPlanType.GetDisplayName(),
|
||||
MaxPages = userPlanType.GetMaxPages(),
|
||||
MaxLinksPerPage = userPlanType.GetMaxLinksPerPage(),
|
||||
DurationDays = userPlanType.GetTrialDays(),
|
||||
Price = userPlanType.GetPrice(),
|
||||
AllowsAnalytics = userPlanType.AllowsAnalytics(),
|
||||
AllowsCustomThemes = userPlanType.AllowsCustomThemes()
|
||||
},
|
||||
CanCreateNewPage = userPages.Count < userPlanType.GetMaxPages(),
|
||||
DaysRemaining = userPlanType == PlanType.Trial ? CalculateTrialDaysRemaining(user) : 0
|
||||
};
|
||||
|
||||
return View(dashboardModel);
|
||||
}
|
||||
|
||||
private int CalculateTrialDaysRemaining(User user)
|
||||
{
|
||||
// This would be calculated based on subscription data
|
||||
// For now, return a default value
|
||||
return 7;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("ManagePage")]
|
||||
public async Task<IActionResult> ManagePage(string id = null)
|
||||
{
|
||||
var user = await _authService.GetCurrentUserAsync(User);
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
var themes = await _themeService.GetAvailableThemesAsync();
|
||||
|
||||
if (string.IsNullOrEmpty(id) || id == "new")
|
||||
{
|
||||
// Check if user can create new page
|
||||
var existingPages = await _userPageService.GetUserPagesAsync(user.Id);
|
||||
var maxPages = userPlanType.GetMaxPages();
|
||||
|
||||
if (existingPages.Count >= maxPages)
|
||||
{
|
||||
TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas.";
|
||||
return RedirectToAction("Dashboard");
|
||||
}
|
||||
|
||||
// CRIAR NOVA PÁGINA
|
||||
var model = new ManagePageViewModel
|
||||
{
|
||||
IsNewPage = true,
|
||||
AvailableCategories = categories,
|
||||
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
|
||||
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage()
|
||||
};
|
||||
return View(model);
|
||||
}
|
||||
else
|
||||
{
|
||||
// EDITAR PÁGINA EXISTENTE
|
||||
var page = await _userPageService.GetPageByIdAsync(id);
|
||||
if (page == null || page.UserId != user.Id)
|
||||
return NotFound();
|
||||
|
||||
var model = MapToManageViewModel(page, categories, themes, userPlanType);
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("ManagePage")]
|
||||
public async Task<IActionResult> ManagePage(ManagePageViewModel model)
|
||||
{
|
||||
var user = await _authService.GetCurrentUserAsync(User);
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
_logger.LogInformation($"ManagePage POST: IsNewPage={model.IsNewPage}, DisplayName={model.DisplayName}, Category={model.Category}, Links={model.Links?.Count ?? 0}");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
_logger.LogWarning("ModelState is invalid:");
|
||||
foreach (var error in ModelState)
|
||||
{
|
||||
_logger.LogWarning($"Key: {error.Key}, Errors: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}");
|
||||
}
|
||||
|
||||
// Repopulate dropdowns
|
||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (model.IsNewPage)
|
||||
{
|
||||
// Generate slug if not provided
|
||||
if (string.IsNullOrEmpty(model.Slug))
|
||||
{
|
||||
model.Slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName);
|
||||
}
|
||||
|
||||
// Check if slug is available
|
||||
if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug))
|
||||
{
|
||||
ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra.");
|
||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||
return View(model);
|
||||
}
|
||||
|
||||
// Check if user can create the requested number of links
|
||||
var activeLinksCount = model.Links?.Count ?? 0;
|
||||
if (activeLinksCount > model.MaxLinksAllowed)
|
||||
{
|
||||
ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual.");
|
||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||
return View(model);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Create new page
|
||||
var userPage = await MapToUserPage(model, user.Id);
|
||||
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
|
||||
|
||||
await _userPageService.CreatePageAsync(userPage);
|
||||
_logger.LogInformation("Page created successfully!");
|
||||
|
||||
TempData["Success"] = "Página criada com sucesso!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating page");
|
||||
ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
|
||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update existing page
|
||||
var existingPage = await _userPageService.GetPageByIdAsync(model.Id);
|
||||
if (existingPage == null || existingPage.UserId != user.Id)
|
||||
return NotFound();
|
||||
|
||||
UpdateUserPageFromModel(existingPage, model);
|
||||
await _userPageService.UpdatePageAsync(existingPage);
|
||||
TempData["Success"] = "Página atualizada com sucesso!";
|
||||
}
|
||||
|
||||
return RedirectToAction("Dashboard");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("CreatePage")]
|
||||
public async Task<IActionResult> CreatePage(CreatePageViewModel model)
|
||||
{
|
||||
var user = await _authService.GetCurrentUserAsync(User);
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
// Check if user already has a page
|
||||
var existingPage = await _userPageService.GetUserPageAsync(user.Id);
|
||||
if (existingPage != null)
|
||||
return RedirectToAction("EditPage");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
var themes = await _themeService.GetAvailableThemesAsync();
|
||||
ViewBag.Categories = categories;
|
||||
ViewBag.Themes = themes;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
// Generate slug if not provided
|
||||
if (string.IsNullOrEmpty(model.Slug))
|
||||
{
|
||||
model.Slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName);
|
||||
}
|
||||
|
||||
// Check if slug is available
|
||||
if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug))
|
||||
{
|
||||
ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra.");
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
var themes = await _themeService.GetAvailableThemesAsync();
|
||||
ViewBag.Categories = categories;
|
||||
ViewBag.Themes = themes;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
// Check if user can create the requested number of links
|
||||
var activeLinksCount = model.Links?.Count ?? 0;
|
||||
if (!await _userPageService.CanCreateLinksAsync(user.Id, activeLinksCount))
|
||||
{
|
||||
ModelState.AddModelError("", "Você excedeu o limite de links do seu plano atual.");
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
var themes = await _themeService.GetAvailableThemesAsync();
|
||||
ViewBag.Categories = categories;
|
||||
ViewBag.Themes = themes;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
// Convert ViewModel to UserPage
|
||||
var userPage = new UserPage
|
||||
{
|
||||
UserId = user.Id,
|
||||
DisplayName = model.DisplayName,
|
||||
Category = model.Category,
|
||||
BusinessType = model.BusinessType,
|
||||
Bio = model.Bio,
|
||||
Slug = model.Slug,
|
||||
Theme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme(),
|
||||
Links = model.Links?.Select(l => new LinkItem
|
||||
{
|
||||
Title = l.Title,
|
||||
Url = l.Url,
|
||||
Description = l.Description,
|
||||
Icon = l.Icon,
|
||||
IsActive = true,
|
||||
Order = model.Links.IndexOf(l)
|
||||
}).ToList() ?? new List<LinkItem>()
|
||||
};
|
||||
|
||||
// Add social media links
|
||||
var socialLinks = new List<LinkItem>();
|
||||
if (!string.IsNullOrEmpty(model.WhatsAppNumber))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "WhatsApp",
|
||||
Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}",
|
||||
Icon = "fab fa-whatsapp",
|
||||
IsActive = true,
|
||||
Order = userPage.Links.Count + socialLinks.Count
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.FacebookUrl))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "Facebook",
|
||||
Url = model.FacebookUrl,
|
||||
Icon = "fab fa-facebook",
|
||||
IsActive = true,
|
||||
Order = userPage.Links.Count + socialLinks.Count
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.TwitterUrl))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "X / Twitter",
|
||||
Url = model.TwitterUrl,
|
||||
Icon = "fab fa-x-twitter",
|
||||
IsActive = true,
|
||||
Order = userPage.Links.Count + socialLinks.Count
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.InstagramUrl))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "Instagram",
|
||||
Url = model.InstagramUrl,
|
||||
Icon = "fab fa-instagram",
|
||||
IsActive = true,
|
||||
Order = userPage.Links.Count + socialLinks.Count
|
||||
});
|
||||
}
|
||||
|
||||
userPage.Links.AddRange(socialLinks);
|
||||
|
||||
await _userPageService.CreatePageAsync(userPage);
|
||||
|
||||
TempData["Success"] = "Página criada com sucesso!";
|
||||
return RedirectToAction("Dashboard");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("EditPage")]
|
||||
public async Task<IActionResult> EditPage()
|
||||
{
|
||||
var user = await _authService.GetCurrentUserAsync(User);
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
var userPage = await _userPageService.GetUserPageAsync(user.Id);
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
var themes = await _themeService.GetAvailableThemesAsync();
|
||||
|
||||
ViewBag.Categories = categories;
|
||||
ViewBag.Themes = themes;
|
||||
ViewBag.IsNew = userPage == null;
|
||||
|
||||
return View(userPage ?? new UserPage { UserId = user.Id });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("EditPage")]
|
||||
public async Task<IActionResult> EditPage(UserPage model)
|
||||
{
|
||||
var user = await _authService.GetCurrentUserAsync(User);
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
var themes = await _themeService.GetAvailableThemesAsync();
|
||||
ViewBag.Categories = categories;
|
||||
ViewBag.Themes = themes;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
// Check if user can create the requested number of links
|
||||
var activeLinksCount = model.Links?.Count(l => l.IsActive) ?? 0;
|
||||
if (!await _userPageService.CanCreateLinksAsync(user.Id, activeLinksCount))
|
||||
{
|
||||
ModelState.AddModelError("", "Você excedeu o limite de links do seu plano atual.");
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
var themes = await _themeService.GetAvailableThemesAsync();
|
||||
ViewBag.Categories = categories;
|
||||
ViewBag.Themes = themes;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
model.UserId = user.Id;
|
||||
|
||||
// Check if slug is available
|
||||
if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug, model.Id))
|
||||
{
|
||||
ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra.");
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
var themes = await _themeService.GetAvailableThemesAsync();
|
||||
ViewBag.Categories = categories;
|
||||
ViewBag.Themes = themes;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(model.Id))
|
||||
{
|
||||
await _userPageService.CreatePageAsync(model);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _userPageService.UpdatePageAsync(model);
|
||||
}
|
||||
|
||||
TempData["Success"] = "Página salva com sucesso!";
|
||||
return RedirectToAction("Dashboard");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("CheckSlugAvailability")]
|
||||
public async Task<IActionResult> CheckSlugAvailability(string category, string slug, string? excludeId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(slug))
|
||||
return Json(new { available = false, message = "Categoria e slug são obrigatórios." });
|
||||
|
||||
var isValid = await _userPageService.ValidateSlugAsync(category, slug, excludeId);
|
||||
|
||||
return Json(new { available = isValid, message = isValid ? "URL disponível!" : "Esta URL já está em uso." });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("GenerateSlug")]
|
||||
public async Task<IActionResult> GenerateSlug(string category, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name))
|
||||
return Json(new { slug = "" });
|
||||
|
||||
var slug = await _userPageService.GenerateSlugAsync(category, name);
|
||||
return Json(new { slug });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("Analytics")]
|
||||
public async Task<IActionResult> Analytics()
|
||||
{
|
||||
var user = await _authService.GetCurrentUserAsync(User);
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
var userPage = await _userPageService.GetUserPageAsync(user.Id);
|
||||
if (userPage == null || !userPage.PlanLimitations.AllowAnalytics)
|
||||
return RedirectToAction("Dashboard");
|
||||
|
||||
return View(userPage);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> DeletePage()
|
||||
{
|
||||
var user = await _authService.GetCurrentUserAsync(User);
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
var userPage = await _userPageService.GetUserPageAsync(user.Id);
|
||||
if (userPage != null)
|
||||
{
|
||||
await _userPageService.DeletePageAsync(userPage.Id);
|
||||
TempData["Success"] = "Página excluída com sucesso!";
|
||||
}
|
||||
|
||||
return RedirectToAction("Dashboard");
|
||||
}
|
||||
|
||||
private ManagePageViewModel MapToManageViewModel(UserPage page, List<Category> categories, List<PageTheme> themes, PlanType userPlanType)
|
||||
{
|
||||
return new ManagePageViewModel
|
||||
{
|
||||
Id = page.Id,
|
||||
IsNewPage = false,
|
||||
DisplayName = page.DisplayName,
|
||||
Category = page.Category,
|
||||
BusinessType = page.BusinessType,
|
||||
Bio = page.Bio,
|
||||
Slug = page.Slug,
|
||||
SelectedTheme = page.Theme?.Name ?? "minimalist",
|
||||
Links = page.Links?.Select((l, index) => new ManageLinkViewModel
|
||||
{
|
||||
Id = $"link_{index}",
|
||||
Title = l.Title,
|
||||
Url = l.Url,
|
||||
Description = l.Description,
|
||||
Icon = l.Icon,
|
||||
Order = l.Order,
|
||||
IsActive = l.IsActive
|
||||
}).ToList() ?? new List<ManageLinkViewModel>(),
|
||||
AvailableCategories = categories,
|
||||
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
|
||||
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<UserPage> MapToUserPage(ManagePageViewModel model, string userId)
|
||||
{
|
||||
var theme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme();
|
||||
|
||||
var userPage = new UserPage
|
||||
{
|
||||
UserId = userId,
|
||||
DisplayName = model.DisplayName,
|
||||
Category = model.Category.ToLower(),
|
||||
BusinessType = model.BusinessType,
|
||||
Bio = model.Bio,
|
||||
Slug = model.Slug.ToLower(),
|
||||
Theme = theme,
|
||||
Status = ViewModels.PageStatus.Active,
|
||||
Links = new List<LinkItem>()
|
||||
};
|
||||
|
||||
// Add regular links
|
||||
if (model.Links?.Any() == true)
|
||||
{
|
||||
userPage.Links.AddRange(model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url))
|
||||
.Select((l, index) => new LinkItem
|
||||
{
|
||||
Title = l.Title,
|
||||
Url = l.Url.ToLower(),
|
||||
Description = l.Description,
|
||||
Icon = l.Icon,
|
||||
IsActive = l.IsActive,
|
||||
Order = index
|
||||
}));
|
||||
}
|
||||
|
||||
// Add social media links
|
||||
var socialLinks = new List<LinkItem>();
|
||||
var currentOrder = userPage.Links.Count;
|
||||
|
||||
if (!string.IsNullOrEmpty(model.WhatsAppNumber))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "WhatsApp",
|
||||
Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}",
|
||||
Icon = "fab fa-whatsapp",
|
||||
IsActive = true,
|
||||
Order = currentOrder++
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.FacebookUrl))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "Facebook",
|
||||
Url = model.FacebookUrl,
|
||||
Icon = "fab fa-facebook",
|
||||
IsActive = true,
|
||||
Order = currentOrder++
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.TwitterUrl))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "X / Twitter",
|
||||
Url = model.TwitterUrl,
|
||||
Icon = "fab fa-x-twitter",
|
||||
IsActive = true,
|
||||
Order = currentOrder++
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.InstagramUrl))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "Instagram",
|
||||
Url = model.InstagramUrl,
|
||||
Icon = "fab fa-instagram",
|
||||
IsActive = true,
|
||||
Order = currentOrder++
|
||||
});
|
||||
}
|
||||
|
||||
userPage.Links.AddRange(socialLinks);
|
||||
return userPage;
|
||||
}
|
||||
|
||||
private void UpdateUserPageFromModel(UserPage page, ManagePageViewModel model)
|
||||
{
|
||||
page.DisplayName = model.DisplayName;
|
||||
page.Category = model.Category;
|
||||
page.BusinessType = model.BusinessType;
|
||||
page.Bio = model.Bio;
|
||||
page.Slug = model.Slug;
|
||||
page.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Update links
|
||||
page.Links = new List<LinkItem>();
|
||||
|
||||
// Add regular links
|
||||
if (model.Links?.Any() == true)
|
||||
{
|
||||
page.Links.AddRange(model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url))
|
||||
.Select((l, index) => new LinkItem
|
||||
{
|
||||
Title = l.Title,
|
||||
Url = l.Url,
|
||||
Description = l.Description,
|
||||
Icon = l.Icon,
|
||||
IsActive = l.IsActive,
|
||||
Order = index
|
||||
}));
|
||||
}
|
||||
|
||||
// Add social media links (same logic as create)
|
||||
var socialLinks = new List<LinkItem>();
|
||||
var currentOrder = page.Links.Count;
|
||||
|
||||
if (!string.IsNullOrEmpty(model.WhatsAppNumber))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "WhatsApp",
|
||||
Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}",
|
||||
Icon = "fab fa-whatsapp",
|
||||
IsActive = true,
|
||||
Order = currentOrder++
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.FacebookUrl))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "Facebook",
|
||||
Url = model.FacebookUrl,
|
||||
Icon = "fab fa-facebook",
|
||||
IsActive = true,
|
||||
Order = currentOrder++
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.TwitterUrl))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "X / Twitter",
|
||||
Url = model.TwitterUrl,
|
||||
Icon = "fab fa-x-twitter",
|
||||
IsActive = true,
|
||||
Order = currentOrder++
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.InstagramUrl))
|
||||
{
|
||||
socialLinks.Add(new LinkItem
|
||||
{
|
||||
Title = "Instagram",
|
||||
Url = model.InstagramUrl,
|
||||
Icon = "fab fa-instagram",
|
||||
IsActive = true,
|
||||
Order = currentOrder++
|
||||
});
|
||||
}
|
||||
|
||||
page.Links.AddRange(socialLinks);
|
||||
}
|
||||
}
|
||||
125
src/BCards.Web/Controllers/AuthController.cs
Normal file
125
src/BCards.Web/Controllers/AuthController.cs
Normal file
@ -0,0 +1,125 @@
|
||||
using BCards.Web.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.Google;
|
||||
using Microsoft.AspNetCore.Authentication.MicrosoftAccount;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace BCards.Web.Controllers;
|
||||
|
||||
[Route("Auth")]
|
||||
public class AuthController : Controller
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
|
||||
public AuthController(IAuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("Login")]
|
||||
public IActionResult Login(string? returnUrl = null)
|
||||
{
|
||||
ViewBag.ReturnUrl = returnUrl;
|
||||
return View();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("LoginWithGoogle")]
|
||||
public IActionResult LoginWithGoogle(string? returnUrl = null)
|
||||
{
|
||||
var redirectUrl = Url.Action("GoogleCallback", "Auth", new { returnUrl });
|
||||
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
|
||||
return Challenge(properties, GoogleDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("LoginWithMicrosoft")]
|
||||
public IActionResult LoginWithMicrosoft(string? returnUrl = null)
|
||||
{
|
||||
var redirectUrl = Url.Action("MicrosoftCallback", "Auth", new { returnUrl });
|
||||
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
|
||||
return Challenge(properties, MicrosoftAccountDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("GoogleCallback")]
|
||||
public async Task<IActionResult> GoogleCallback(string? returnUrl = null)
|
||||
{
|
||||
var result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
TempData["Error"] = "Falha na autenticação com Google";
|
||||
return RedirectToAction("Login");
|
||||
}
|
||||
|
||||
var user = await _authService.CreateOrUpdateUserFromClaimsAsync(result.Principal);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||
new Claim(ClaimTypes.Name, user.Name),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
new Claim("picture", user.ProfileImage ?? "")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||
|
||||
TempData["Success"] = $"Bem-vindo, {user.Name}!";
|
||||
return RedirectToLocal(returnUrl);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("MicrosoftCallback")]
|
||||
public async Task<IActionResult> MicrosoftCallback(string? returnUrl = null)
|
||||
{
|
||||
var result = await HttpContext.AuthenticateAsync(MicrosoftAccountDefaults.AuthenticationScheme);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
TempData["Error"] = "Falha na autenticação com Microsoft";
|
||||
return RedirectToAction("Login");
|
||||
}
|
||||
|
||||
var user = await _authService.CreateOrUpdateUserFromClaimsAsync(result.Principal);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||
new Claim(ClaimTypes.Name, user.Name),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
new Claim("picture", user.ProfileImage ?? "")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||
|
||||
TempData["Success"] = $"Bem-vindo, {user.Name}!";
|
||||
return RedirectToLocal(returnUrl);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("Logout")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
TempData["Success"] = "Logout realizado com sucesso";
|
||||
return RedirectToAction("Index", "Home");
|
||||
}
|
||||
|
||||
private IActionResult RedirectToLocal(string? returnUrl)
|
||||
{
|
||||
if (Url.IsLocalUrl(returnUrl))
|
||||
return Redirect(returnUrl);
|
||||
|
||||
return RedirectToAction("Dashboard", "Admin");
|
||||
}
|
||||
}
|
||||
56
src/BCards.Web/Controllers/HomeController.cs
Normal file
56
src/BCards.Web/Controllers/HomeController.cs
Normal file
@ -0,0 +1,56 @@
|
||||
using BCards.Web.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BCards.Web.Controllers;
|
||||
|
||||
public class HomeController : Controller
|
||||
{
|
||||
private readonly ICategoryService _categoryService;
|
||||
private readonly IUserPageService _userPageService;
|
||||
|
||||
public HomeController(ICategoryService categoryService, IUserPageService userPageService)
|
||||
{
|
||||
_categoryService = categoryService;
|
||||
_userPageService = userPageService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
ViewBag.Categories = await _categoryService.GetAllCategoriesAsync();
|
||||
ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6);
|
||||
return View();
|
||||
}
|
||||
|
||||
[Route("Privacy")]
|
||||
public IActionResult Privacy()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[Route("Pricing")]
|
||||
public IActionResult Pricing()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[Route("categoria/{categorySlug}")]
|
||||
public async Task<IActionResult> Category(string categorySlug)
|
||||
{
|
||||
var category = await _categoryService.GetCategoryBySlugAsync(categorySlug);
|
||||
if (category == null)
|
||||
return NotFound();
|
||||
|
||||
var pages = await _userPageService.GetPagesByCategoryAsync(categorySlug, 20);
|
||||
|
||||
ViewBag.Category = category;
|
||||
ViewBag.Pages = pages;
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
public IActionResult Error()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
}
|
||||
113
src/BCards.Web/Controllers/PaymentController.cs
Normal file
113
src/BCards.Web/Controllers/PaymentController.cs
Normal file
@ -0,0 +1,113 @@
|
||||
using BCards.Web.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BCards.Web.Controllers;
|
||||
|
||||
[Authorize]
|
||||
public class PaymentController : Controller
|
||||
{
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IAuthService _authService;
|
||||
|
||||
public PaymentController(IPaymentService paymentService, IAuthService authService)
|
||||
{
|
||||
_paymentService = paymentService;
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
public IActionResult Plans()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateCheckoutSession(string planType)
|
||||
{
|
||||
var user = await _authService.GetCurrentUserAsync(User);
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
var successUrl = Url.Action("Success", "Payment", null, Request.Scheme);
|
||||
var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme);
|
||||
|
||||
try
|
||||
{
|
||||
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
|
||||
user.Id,
|
||||
planType,
|
||||
successUrl!,
|
||||
cancelUrl!);
|
||||
|
||||
return Redirect(checkoutUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
|
||||
return RedirectToAction("Plans");
|
||||
}
|
||||
}
|
||||
|
||||
public IActionResult Success()
|
||||
{
|
||||
TempData["Success"] = "Assinatura ativada com sucesso! Agora você pode aproveitar todos os recursos do seu plano.";
|
||||
return RedirectToAction("Dashboard", "Admin");
|
||||
}
|
||||
|
||||
public IActionResult Cancel()
|
||||
{
|
||||
TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser.";
|
||||
return RedirectToAction("Plans");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("webhook/stripe")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> StripeWebhook()
|
||||
{
|
||||
var signature = Request.Headers["Stripe-Signature"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(signature))
|
||||
return BadRequest();
|
||||
|
||||
string requestBody;
|
||||
using (var reader = new StreamReader(Request.Body))
|
||||
{
|
||||
requestBody = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _paymentService.HandleWebhookAsync(requestBody, signature);
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest($"Webhook error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> ManageSubscription()
|
||||
{
|
||||
var user = await _authService.GetCurrentUserAsync(User);
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
return View(user);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CancelSubscription(string subscriptionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _paymentService.CancelSubscriptionAsync(subscriptionId);
|
||||
TempData["Success"] = "Sua assinatura será cancelada no final do período atual.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TempData["Error"] = $"Erro ao cancelar assinatura: {ex.Message}";
|
||||
}
|
||||
|
||||
return RedirectToAction("ManageSubscription");
|
||||
}
|
||||
}
|
||||
86
src/BCards.Web/Controllers/SitemapController.cs
Normal file
86
src/BCards.Web/Controllers/SitemapController.cs
Normal file
@ -0,0 +1,86 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using BCards.Web.Services;
|
||||
|
||||
namespace BCards.Web.Controllers;
|
||||
|
||||
public class SitemapController : Controller
|
||||
{
|
||||
private readonly IUserPageService _userPageService;
|
||||
private readonly ILogger<SitemapController> _logger;
|
||||
|
||||
public SitemapController(IUserPageService userPageService, ILogger<SitemapController> logger)
|
||||
{
|
||||
_userPageService = userPageService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Route("sitemap.xml")]
|
||||
[ResponseCache(Duration = 86400)] // Cache for 24 hours
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
try
|
||||
{
|
||||
var activePages = await _userPageService.GetActivePagesAsync();
|
||||
|
||||
var sitemap = new XDocument(
|
||||
new XDeclaration("1.0", "utf-8", "yes"),
|
||||
new XElement("urlset",
|
||||
new XAttribute("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9"),
|
||||
|
||||
// Add static pages
|
||||
new XElement("url",
|
||||
new XElement("loc", $"{Request.Scheme}://{Request.Host}/"),
|
||||
new XElement("lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
|
||||
new XElement("changefreq", "daily"),
|
||||
new XElement("priority", "1.0")
|
||||
),
|
||||
new XElement("url",
|
||||
new XElement("loc", $"{Request.Scheme}://{Request.Host}/Home/Pricing"),
|
||||
new XElement("lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
|
||||
new XElement("changefreq", "weekly"),
|
||||
new XElement("priority", "0.9")
|
||||
),
|
||||
|
||||
// Add user pages (only active ones)
|
||||
activePages.Select(page =>
|
||||
new XElement("url",
|
||||
new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"),
|
||||
new XElement("lastmod", page.UpdatedAt.ToString("yyyy-MM-dd")),
|
||||
new XElement("changefreq", "weekly"),
|
||||
new XElement("priority", "0.8")
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
_logger.LogInformation($"Generated sitemap with {activePages.Count} user pages");
|
||||
|
||||
return Content(sitemap.ToString(), "application/xml", Encoding.UTF8);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating sitemap");
|
||||
return StatusCode(500, "Error generating sitemap");
|
||||
}
|
||||
}
|
||||
|
||||
[Route("robots.txt")]
|
||||
[ResponseCache(Duration = 86400)] // Cache for 24 hours
|
||||
public IActionResult RobotsTxt()
|
||||
{
|
||||
var robotsTxt = $@"User-agent: *
|
||||
Allow: /
|
||||
Allow: /page/
|
||||
|
||||
Disallow: /Admin/
|
||||
Disallow: /Auth/
|
||||
Disallow: /Payment/
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: {Request.Scheme}://{Request.Host}/sitemap.xml";
|
||||
|
||||
return Content(robotsTxt, "text/plain", Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
219
src/BCards.Web/Controllers/StripeWebhookController.cs
Normal file
219
src/BCards.Web/Controllers/StripeWebhookController.cs
Normal file
@ -0,0 +1,219 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Stripe;
|
||||
using BCards.Web.Services;
|
||||
using BCards.Web.Repositories;
|
||||
using BCards.Web.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace BCards.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/stripe")]
|
||||
public class StripeWebhookController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StripeWebhookController> _logger;
|
||||
private readonly ISubscriptionRepository _subscriptionRepository;
|
||||
private readonly IUserPageService _userPageService;
|
||||
private readonly string _webhookSecret;
|
||||
|
||||
public StripeWebhookController(
|
||||
ILogger<StripeWebhookController> logger,
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
IUserPageService userPageService,
|
||||
IOptions<StripeSettings> stripeSettings)
|
||||
{
|
||||
_logger = logger;
|
||||
_subscriptionRepository = subscriptionRepository;
|
||||
_userPageService = userPageService;
|
||||
_webhookSecret = stripeSettings.Value.WebhookSecret ?? "";
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
public async Task<IActionResult> HandleWebhook()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
|
||||
|
||||
if (string.IsNullOrEmpty(_webhookSecret))
|
||||
{
|
||||
_logger.LogWarning("Webhook secret not configured");
|
||||
return BadRequest("Webhook secret not configured");
|
||||
}
|
||||
|
||||
var stripeSignature = Request.Headers["Stripe-Signature"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(stripeSignature))
|
||||
{
|
||||
_logger.LogWarning("Missing Stripe signature");
|
||||
return BadRequest("Missing Stripe signature");
|
||||
}
|
||||
|
||||
var stripeEvent = EventUtility.ConstructEvent(
|
||||
json,
|
||||
stripeSignature,
|
||||
_webhookSecret,
|
||||
throwOnApiVersionMismatch: false
|
||||
);
|
||||
|
||||
_logger.LogInformation($"Processing Stripe webhook: {stripeEvent.Type}");
|
||||
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
case Events.InvoicePaymentSucceeded:
|
||||
await HandlePaymentSucceeded(stripeEvent);
|
||||
break;
|
||||
|
||||
case Events.InvoicePaymentFailed:
|
||||
await HandlePaymentFailed(stripeEvent);
|
||||
break;
|
||||
|
||||
case Events.CustomerSubscriptionDeleted:
|
||||
await HandleSubscriptionDeleted(stripeEvent);
|
||||
break;
|
||||
|
||||
case Events.CustomerSubscriptionUpdated:
|
||||
await HandleSubscriptionUpdated(stripeEvent);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogInformation($"Unhandled webhook event type: {stripeEvent.Type}");
|
||||
break;
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Stripe webhook error");
|
||||
return BadRequest($"Stripe error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Webhook processing error");
|
||||
return StatusCode(500, "Internal server error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandlePaymentSucceeded(Event stripeEvent)
|
||||
{
|
||||
if (stripeEvent.Data.Object is Invoice invoice)
|
||||
{
|
||||
_logger.LogInformation($"Payment succeeded for customer: {invoice.CustomerId}");
|
||||
|
||||
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
|
||||
if (subscription != null)
|
||||
{
|
||||
subscription.Status = "active";
|
||||
subscription.UpdatedAt = DateTime.UtcNow;
|
||||
await _subscriptionRepository.UpdateAsync(subscription);
|
||||
|
||||
// Reactivate user pages
|
||||
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
|
||||
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.PendingPayment))
|
||||
{
|
||||
page.Status = ViewModels.PageStatus.Active;
|
||||
page.UpdatedAt = DateTime.UtcNow;
|
||||
await _userPageService.UpdatePageAsync(page);
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Reactivated {userPages.Count} pages for user {subscription.UserId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandlePaymentFailed(Event stripeEvent)
|
||||
{
|
||||
if (stripeEvent.Data.Object is Invoice invoice)
|
||||
{
|
||||
_logger.LogInformation($"Payment failed for customer: {invoice.CustomerId}");
|
||||
|
||||
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
|
||||
if (subscription != null)
|
||||
{
|
||||
subscription.Status = "past_due";
|
||||
subscription.UpdatedAt = DateTime.UtcNow;
|
||||
await _subscriptionRepository.UpdateAsync(subscription);
|
||||
|
||||
// Set pages to pending payment
|
||||
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
|
||||
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
|
||||
{
|
||||
page.Status = ViewModels.PageStatus.PendingPayment;
|
||||
page.UpdatedAt = DateTime.UtcNow;
|
||||
await _userPageService.UpdatePageAsync(page);
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Set {userPages.Count} pages to pending payment for user {subscription.UserId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubscriptionDeleted(Event stripeEvent)
|
||||
{
|
||||
if (stripeEvent.Data.Object is Subscription stripeSubscription)
|
||||
{
|
||||
_logger.LogInformation($"Subscription cancelled: {stripeSubscription.Id}");
|
||||
|
||||
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
|
||||
if (subscription != null)
|
||||
{
|
||||
subscription.Status = "cancelled";
|
||||
subscription.UpdatedAt = DateTime.UtcNow;
|
||||
await _subscriptionRepository.UpdateAsync(subscription);
|
||||
|
||||
// Downgrade to trial or deactivate pages
|
||||
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
|
||||
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
|
||||
{
|
||||
page.Status = ViewModels.PageStatus.Expired;
|
||||
page.UpdatedAt = DateTime.UtcNow;
|
||||
await _userPageService.UpdatePageAsync(page);
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Deactivated {userPages.Count} pages for cancelled subscription {subscription.UserId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubscriptionUpdated(Event stripeEvent)
|
||||
{
|
||||
if (stripeEvent.Data.Object is Subscription stripeSubscription)
|
||||
{
|
||||
_logger.LogInformation($"Subscription updated: {stripeSubscription.Id}");
|
||||
|
||||
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
|
||||
if (subscription != null)
|
||||
{
|
||||
subscription.Status = stripeSubscription.Status;
|
||||
subscription.CurrentPeriodStart = stripeSubscription.CurrentPeriodStart;
|
||||
subscription.CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd;
|
||||
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
|
||||
subscription.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Update plan type based on Stripe price ID
|
||||
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
|
||||
if (!string.IsNullOrEmpty(priceId))
|
||||
{
|
||||
subscription.PlanType = MapPriceIdToPlanType(priceId);
|
||||
}
|
||||
|
||||
await _subscriptionRepository.UpdateAsync(subscription);
|
||||
|
||||
_logger.LogInformation($"Updated subscription for user {subscription.UserId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string MapPriceIdToPlanType(string priceId)
|
||||
{
|
||||
// Map Stripe price IDs to plan types
|
||||
// This would be configured based on your actual Stripe price IDs
|
||||
return priceId switch
|
||||
{
|
||||
var id when id.Contains("basic") => "basic",
|
||||
var id when id.Contains("professional") => "professional",
|
||||
var id when id.Contains("premium") => "premium",
|
||||
_ => "trial"
|
||||
};
|
||||
}
|
||||
}
|
||||
73
src/BCards.Web/Controllers/UserPageController.cs
Normal file
73
src/BCards.Web/Controllers/UserPageController.cs
Normal file
@ -0,0 +1,73 @@
|
||||
using BCards.Web.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BCards.Web.Controllers;
|
||||
|
||||
//[Route("[controller]")]
|
||||
public class UserPageController : Controller
|
||||
{
|
||||
private readonly IUserPageService _userPageService;
|
||||
private readonly ICategoryService _categoryService;
|
||||
private readonly ISeoService _seoService;
|
||||
|
||||
public UserPageController(
|
||||
IUserPageService userPageService,
|
||||
ICategoryService categoryService,
|
||||
ISeoService seoService)
|
||||
{
|
||||
_userPageService = userPageService;
|
||||
_categoryService = categoryService;
|
||||
_seoService = seoService;
|
||||
}
|
||||
|
||||
//[Route("{category}/{slug}")]
|
||||
[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })]
|
||||
public async Task<IActionResult> Display(string category, string slug)
|
||||
{
|
||||
var userPage = await _userPageService.GetPageAsync(category, slug);
|
||||
if (userPage == null || !userPage.IsActive)
|
||||
return NotFound();
|
||||
|
||||
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
||||
if (categoryObj == null)
|
||||
return NotFound();
|
||||
|
||||
// Generate SEO settings
|
||||
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
|
||||
|
||||
// Record page view (async, don't wait)
|
||||
var referrer = Request.Headers["Referer"].FirstOrDefault();
|
||||
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
|
||||
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
|
||||
|
||||
ViewBag.SeoSettings = seoSettings;
|
||||
ViewBag.Category = categoryObj;
|
||||
|
||||
return View(userPage);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("click/{pageId}")]
|
||||
public async Task<IActionResult> RecordClick(string pageId, int linkIndex)
|
||||
{
|
||||
await _userPageService.RecordLinkClickAsync(pageId, linkIndex);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Route("preview/{category}/{slug}")]
|
||||
public async Task<IActionResult> Preview(string category, string slug)
|
||||
{
|
||||
var userPage = await _userPageService.GetPageAsync(category, slug);
|
||||
if (userPage == null)
|
||||
return NotFound();
|
||||
|
||||
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
||||
if (categoryObj == null)
|
||||
return NotFound();
|
||||
|
||||
ViewBag.Category = categoryObj;
|
||||
ViewBag.IsPreview = true;
|
||||
|
||||
return View("Display", userPage);
|
||||
}
|
||||
}
|
||||
29
src/BCards.Web/Dockerfile
Normal file
29
src/BCards.Web/Dockerfile
Normal file
@ -0,0 +1,29 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"]
|
||||
RUN dotnet restore "src/BCards.Web/BCards.Web.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/src/BCards.Web"
|
||||
RUN dotnet build "BCards.Web.csproj" -c Release -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "BCards.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p /app/uploads && chmod 755 /app/uploads
|
||||
|
||||
# Install dependencies for image processing
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libgdiplus \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENTRYPOINT ["dotnet", "BCards.Web.dll"]
|
||||
128
src/BCards.Web/Middleware/PageStatusMiddleware.cs
Normal file
128
src/BCards.Web/Middleware/PageStatusMiddleware.cs
Normal file
@ -0,0 +1,128 @@
|
||||
using BCards.Web.Services;
|
||||
using BCards.Web.ViewModels;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BCards.Web.Middleware;
|
||||
|
||||
public class PageStatusMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<PageStatusMiddleware> _logger;
|
||||
|
||||
public PageStatusMiddleware(RequestDelegate next, ILogger<PageStatusMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, IUserPageService userPageService)
|
||||
{
|
||||
// Check if this is a user page route (page/{category}/{slug})
|
||||
if (IsUserPageRoute(context.Request.Path))
|
||||
{
|
||||
try
|
||||
{
|
||||
var (category, slug) = ExtractRouteParameters(context.Request.Path);
|
||||
|
||||
if (!string.IsNullOrEmpty(category) && !string.IsNullOrEmpty(slug))
|
||||
{
|
||||
var page = await userPageService.GetPageAsync(category, slug);
|
||||
|
||||
if (page != null)
|
||||
{
|
||||
switch (page.Status)
|
||||
{
|
||||
case PageStatus.Expired:
|
||||
// 301 Redirect para não prejudicar SEO
|
||||
_logger.LogInformation($"Redirecting expired page {category}/{slug} to upgrade page");
|
||||
context.Response.Redirect($"/Home/Pricing?expired={slug}&category={category}", permanent: true);
|
||||
return;
|
||||
|
||||
case PageStatus.PendingPayment:
|
||||
// Mostrar página com aviso de pagamento
|
||||
_logger.LogInformation($"Showing payment warning for page {category}/{slug}");
|
||||
await ShowPaymentWarning(context, page);
|
||||
return;
|
||||
|
||||
case PageStatus.Inactive:
|
||||
// 404 temporário
|
||||
_logger.LogInformation($"Page {category}/{slug} is inactive, returning 404");
|
||||
context.Response.StatusCode = 404;
|
||||
await context.Response.WriteAsync("Página temporariamente indisponível.");
|
||||
return;
|
||||
|
||||
case PageStatus.Active:
|
||||
// Continuar processamento normal
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in PageStatusMiddleware");
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static bool IsUserPageRoute(PathString path)
|
||||
{
|
||||
// Check if path matches pattern: /page/{category}/{slug}
|
||||
return Regex.IsMatch(path.Value ?? "", @"^/page/[a-z-]+/[a-z0-9-]+/?$", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static (string category, string slug) ExtractRouteParameters(PathString path)
|
||||
{
|
||||
var match = Regex.Match(path.Value ?? "", @"^/page/([a-z-]+)/([a-z0-9-]+)/?$", RegexOptions.IgnoreCase);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
return (match.Groups[1].Value, match.Groups[2].Value);
|
||||
}
|
||||
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
private async Task ShowPaymentWarning(HttpContext context, BCards.Web.Models.UserPage page)
|
||||
{
|
||||
// Generate a simple HTML page with payment warning
|
||||
var html = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{page.DisplayName} - Pagamento Pendente</title>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||
<link href='https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css' rel='stylesheet'>
|
||||
</head>
|
||||
<body class='bg-light'>
|
||||
<div class='container mt-5'>
|
||||
<div class='row justify-content-center'>
|
||||
<div class='col-md-6'>
|
||||
<div class='card border-warning'>
|
||||
<div class='card-header bg-warning text-dark'>
|
||||
<h5 class='mb-0'>
|
||||
<i class='fas fa-exclamation-triangle me-2'></i>
|
||||
Pagamento Pendente
|
||||
</h5>
|
||||
</div>
|
||||
<div class='card-body text-center'>
|
||||
<h4>{page.DisplayName}</h4>
|
||||
<p class='text-muted mb-4'>Esta página está temporariamente indisponível devido a um pagamento pendente.</p>
|
||||
<p class='mb-4'>Para reativar esta página, o proprietário deve regularizar o pagamento.</p>
|
||||
<a href='/Home/Pricing' class='btn btn-primary'>Ver Planos</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
context.Response.ContentType = "text/html";
|
||||
context.Response.StatusCode = 200; // Keep as 200 for SEO, but show warning
|
||||
await context.Response.WriteAsync(html);
|
||||
}
|
||||
}
|
||||
127
src/BCards.Web/Middleware/PlanLimitationMiddleware.cs
Normal file
127
src/BCards.Web/Middleware/PlanLimitationMiddleware.cs
Normal file
@ -0,0 +1,127 @@
|
||||
using BCards.Web.Services;
|
||||
using BCards.Web.Repositories;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace BCards.Web.Middleware;
|
||||
|
||||
public class PlanLimitationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<PlanLimitationMiddleware> _logger;
|
||||
|
||||
public PlanLimitationMiddleware(RequestDelegate next, ILogger<PlanLimitationMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvider)
|
||||
{
|
||||
// Only check for authenticated users on specific endpoints
|
||||
if (context.User.Identity?.IsAuthenticated == true && ShouldCheckLimitations(context))
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var authService = scope.ServiceProvider.GetRequiredService<IAuthService>();
|
||||
var subscriptionRepository = scope.ServiceProvider.GetRequiredService<ISubscriptionRepository>();
|
||||
var userPageRepository = scope.ServiceProvider.GetRequiredService<IUserPageRepository>();
|
||||
|
||||
try
|
||||
{
|
||||
var user = await authService.GetCurrentUserAsync(context.User);
|
||||
if (user != null)
|
||||
{
|
||||
var subscription = await subscriptionRepository.GetByUserIdAsync(user.Id);
|
||||
var limitations = GetPlanLimitations(subscription?.PlanType ?? "free");
|
||||
|
||||
// Check specific limitations based on the request
|
||||
if (IsLinkCreationRequest(context))
|
||||
{
|
||||
var userPage = await userPageRepository.GetByUserIdAsync(user.Id);
|
||||
var currentLinksCount = userPage?.Links?.Count(l => l.IsActive) ?? 0;
|
||||
|
||||
if (limitations.MaxLinks != -1 && currentLinksCount >= limitations.MaxLinks)
|
||||
{
|
||||
context.Response.StatusCode = 403;
|
||||
await context.Response.WriteAsync("Limite de links atingido. Faça upgrade do seu plano.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add limitations to context for use in controllers
|
||||
context.Items["PlanLimitations"] = limitations;
|
||||
context.Items["CurrentSubscription"] = subscription;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking plan limitations for user");
|
||||
// Continue without blocking the request
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static bool ShouldCheckLimitations(HttpContext context)
|
||||
{
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
return path.StartsWith("/admin/") ||
|
||||
path.StartsWith("/api/") ||
|
||||
(context.Request.Method == "POST" && path.Contains("/editpage"));
|
||||
}
|
||||
|
||||
private static bool IsLinkCreationRequest(HttpContext context)
|
||||
{
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
return context.Request.Method == "POST" &&
|
||||
(path.Contains("/editpage") || path.Contains("/addlink"));
|
||||
}
|
||||
|
||||
private static Models.PlanLimitations GetPlanLimitations(string planType)
|
||||
{
|
||||
return planType.ToLower() switch
|
||||
{
|
||||
"basic" => new Models.PlanLimitations
|
||||
{
|
||||
MaxLinks = 5,
|
||||
AllowCustomThemes = false,
|
||||
AllowAnalytics = true,
|
||||
AllowCustomDomain = false,
|
||||
AllowMultipleDomains = false,
|
||||
PrioritySupport = false,
|
||||
PlanType = "basic"
|
||||
},
|
||||
"professional" => new Models.PlanLimitations
|
||||
{
|
||||
MaxLinks = 15,
|
||||
AllowCustomThemes = false,
|
||||
AllowAnalytics = true,
|
||||
AllowCustomDomain = true,
|
||||
AllowMultipleDomains = false,
|
||||
PrioritySupport = false,
|
||||
PlanType = "professional"
|
||||
},
|
||||
"premium" => new Models.PlanLimitations
|
||||
{
|
||||
MaxLinks = -1, // Unlimited
|
||||
AllowCustomThemes = true,
|
||||
AllowAnalytics = true,
|
||||
AllowCustomDomain = true,
|
||||
AllowMultipleDomains = true,
|
||||
PrioritySupport = true,
|
||||
PlanType = "premium"
|
||||
},
|
||||
_ => new Models.PlanLimitations
|
||||
{
|
||||
MaxLinks = 5,
|
||||
AllowCustomThemes = false,
|
||||
AllowAnalytics = false,
|
||||
AllowCustomDomain = false,
|
||||
AllowMultipleDomains = false,
|
||||
PrioritySupport = false,
|
||||
PlanType = "free"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
32
src/BCards.Web/Models/Category.cs
Normal file
32
src/BCards.Web/Models/Category.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public class Category
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("icon")]
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("seoKeywords")]
|
||||
public List<string> SeoKeywords { get; set; } = new();
|
||||
|
||||
[BsonElement("description")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("isActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
30
src/BCards.Web/Models/LinkItem.cs
Normal file
30
src/BCards.Web/Models/LinkItem.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public class LinkItem
|
||||
{
|
||||
[BsonElement("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("description")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("icon")]
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("isActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[BsonElement("order")]
|
||||
public int Order { get; set; }
|
||||
|
||||
[BsonElement("clicks")]
|
||||
public int Clicks { get; set; } = 0;
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
30
src/BCards.Web/Models/PageAnalytics.cs
Normal file
30
src/BCards.Web/Models/PageAnalytics.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public class PageAnalytics
|
||||
{
|
||||
[BsonElement("totalViews")]
|
||||
public int TotalViews { get; set; } = 0;
|
||||
|
||||
[BsonElement("totalClicks")]
|
||||
public int TotalClicks { get; set; } = 0;
|
||||
|
||||
[BsonElement("lastViewedAt")]
|
||||
public DateTime? LastViewedAt { get; set; }
|
||||
|
||||
[BsonElement("monthlyViews")]
|
||||
public Dictionary<string, int> MonthlyViews { get; set; } = new(); // "2024-01" -> count
|
||||
|
||||
[BsonElement("monthlyClicks")]
|
||||
public Dictionary<string, int> MonthlyClicks { get; set; } = new();
|
||||
|
||||
[BsonElement("topReferrers")]
|
||||
public Dictionary<string, int> TopReferrers { get; set; } = new();
|
||||
|
||||
[BsonElement("deviceStats")]
|
||||
public Dictionary<string, int> DeviceStats { get; set; } = new(); // mobile, desktop, tablet
|
||||
|
||||
[BsonElement("countryStats")]
|
||||
public Dictionary<string, int> CountryStats { get; set; } = new();
|
||||
}
|
||||
41
src/BCards.Web/Models/PageTheme.cs
Normal file
41
src/BCards.Web/Models/PageTheme.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public class PageTheme
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("primaryColor")]
|
||||
public string PrimaryColor { get; set; } = "#007bff";
|
||||
|
||||
[BsonElement("secondaryColor")]
|
||||
public string SecondaryColor { get; set; } = "#6c757d";
|
||||
|
||||
[BsonElement("backgroundColor")]
|
||||
public string BackgroundColor { get; set; } = "#ffffff";
|
||||
|
||||
[BsonElement("textColor")]
|
||||
public string TextColor { get; set; } = "#212529";
|
||||
|
||||
[BsonElement("backgroundImage")]
|
||||
public string BackgroundImage { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("isPremium")]
|
||||
public bool IsPremium { get; set; } = false;
|
||||
|
||||
[BsonElement("cssTemplate")]
|
||||
public string CssTemplate { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("isActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
27
src/BCards.Web/Models/PlanLimitations.cs
Normal file
27
src/BCards.Web/Models/PlanLimitations.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public class PlanLimitations
|
||||
{
|
||||
[BsonElement("maxLinks")]
|
||||
public int MaxLinks { get; set; } = 5;
|
||||
|
||||
[BsonElement("allowCustomThemes")]
|
||||
public bool AllowCustomThemes { get; set; } = false;
|
||||
|
||||
[BsonElement("allowAnalytics")]
|
||||
public bool AllowAnalytics { get; set; } = false;
|
||||
|
||||
[BsonElement("allowCustomDomain")]
|
||||
public bool AllowCustomDomain { get; set; } = false;
|
||||
|
||||
[BsonElement("allowMultipleDomains")]
|
||||
public bool AllowMultipleDomains { get; set; } = false;
|
||||
|
||||
[BsonElement("prioritySupport")]
|
||||
public bool PrioritySupport { get; set; } = false;
|
||||
|
||||
[BsonElement("planType")]
|
||||
public string PlanType { get; set; } = "free";
|
||||
}
|
||||
94
src/BCards.Web/Models/PlanType.cs
Normal file
94
src/BCards.Web/Models/PlanType.cs
Normal file
@ -0,0 +1,94 @@
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public enum PlanType
|
||||
{
|
||||
Trial = 0, // Gratuito por 7 dias
|
||||
Basic = 1, // R$ 9,90
|
||||
Professional = 2, // R$ 24,90 (Decoy)
|
||||
Premium = 3 // R$ 29,90
|
||||
}
|
||||
|
||||
public static class PlanTypeExtensions
|
||||
{
|
||||
public static string GetDisplayName(this PlanType planType)
|
||||
{
|
||||
return planType switch
|
||||
{
|
||||
PlanType.Trial => "Trial Gratuito",
|
||||
PlanType.Basic => "Básico",
|
||||
PlanType.Professional => "Profissional",
|
||||
PlanType.Premium => "Premium",
|
||||
_ => "Desconhecido"
|
||||
};
|
||||
}
|
||||
|
||||
public static decimal GetPrice(this PlanType planType)
|
||||
{
|
||||
return planType switch
|
||||
{
|
||||
PlanType.Trial => 0.00m,
|
||||
PlanType.Basic => 9.90m,
|
||||
PlanType.Professional => 24.90m,
|
||||
PlanType.Premium => 29.90m,
|
||||
_ => 0.00m
|
||||
};
|
||||
}
|
||||
|
||||
public static int GetMaxPages(this PlanType planType)
|
||||
{
|
||||
return planType switch
|
||||
{
|
||||
PlanType.Trial => 1,
|
||||
PlanType.Basic => 3,
|
||||
PlanType.Professional => 5, // DECOY - not attractive
|
||||
PlanType.Premium => 15,
|
||||
_ => 1
|
||||
};
|
||||
}
|
||||
|
||||
public static int GetMaxLinksPerPage(this PlanType planType)
|
||||
{
|
||||
return planType switch
|
||||
{
|
||||
PlanType.Trial => 3,
|
||||
PlanType.Basic => 8,
|
||||
PlanType.Professional => 20, // DECOY - too expensive for the benefit
|
||||
PlanType.Premium => int.MaxValue, // Unlimited
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
|
||||
public static int GetMaxLinks(this PlanType planType)
|
||||
{
|
||||
return GetMaxLinksPerPage(planType);
|
||||
}
|
||||
|
||||
public static bool AllowsAnalytics(this PlanType planType)
|
||||
{
|
||||
return planType switch
|
||||
{
|
||||
PlanType.Trial => false,
|
||||
PlanType.Basic => true,
|
||||
PlanType.Professional => true,
|
||||
PlanType.Premium => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public static bool AllowsCustomThemes(this PlanType planType)
|
||||
{
|
||||
return planType switch
|
||||
{
|
||||
PlanType.Trial => false,
|
||||
PlanType.Basic => false,
|
||||
PlanType.Professional => true,
|
||||
PlanType.Premium => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public static int GetTrialDays(this PlanType planType)
|
||||
{
|
||||
return planType == PlanType.Trial ? 7 : 0;
|
||||
}
|
||||
}
|
||||
30
src/BCards.Web/Models/SeoSettings.cs
Normal file
30
src/BCards.Web/Models/SeoSettings.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public class SeoSettings
|
||||
{
|
||||
[BsonElement("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("description")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("keywords")]
|
||||
public List<string> Keywords { get; set; } = new();
|
||||
|
||||
[BsonElement("ogImage")]
|
||||
public string OgImage { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("ogTitle")]
|
||||
public string OgTitle { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("ogDescription")]
|
||||
public string OgDescription { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("twitterCard")]
|
||||
public string TwitterCard { get; set; } = "summary_large_image";
|
||||
|
||||
[BsonElement("canonicalUrl")]
|
||||
public string CanonicalUrl { get; set; } = string.Empty;
|
||||
}
|
||||
57
src/BCards.Web/Models/Subscription.cs
Normal file
57
src/BCards.Web/Models/Subscription.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public class Subscription
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("userId")]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("stripeSubscriptionId")]
|
||||
public string StripeSubscriptionId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("planType")]
|
||||
public string PlanType { get; set; } = "free";
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
[BsonElement("currentPeriodStart")]
|
||||
public DateTime CurrentPeriodStart { get; set; }
|
||||
|
||||
[BsonElement("currentPeriodEnd")]
|
||||
public DateTime CurrentPeriodEnd { get; set; }
|
||||
|
||||
[BsonElement("cancelAtPeriodEnd")]
|
||||
public bool CancelAtPeriodEnd { get; set; } = false;
|
||||
|
||||
[BsonElement("maxLinks")]
|
||||
public int MaxLinks { get; set; } = 5;
|
||||
|
||||
[BsonElement("allowCustomThemes")]
|
||||
public bool AllowCustomThemes { get; set; } = false;
|
||||
|
||||
[BsonElement("allowAnalytics")]
|
||||
public bool AllowAnalytics { get; set; } = false;
|
||||
|
||||
[BsonElement("allowCustomDomain")]
|
||||
public bool AllowCustomDomain { get; set; } = false;
|
||||
|
||||
[BsonElement("allowMultipleDomains")]
|
||||
public bool AllowMultipleDomains { get; set; } = false;
|
||||
|
||||
[BsonElement("prioritySupport")]
|
||||
public bool PrioritySupport { get; set; } = false;
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
44
src/BCards.Web/Models/User.cs
Normal file
44
src/BCards.Web/Models/User.cs
Normal file
@ -0,0 +1,44 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public class User
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("email")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("profileImage")]
|
||||
public string ProfileImage { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("authProvider")]
|
||||
public string AuthProvider { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("stripeCustomerId")]
|
||||
public string StripeCustomerId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("subscriptionStatus")]
|
||||
public string SubscriptionStatus { get; set; } = "free";
|
||||
|
||||
[BsonElement("currentPlan")]
|
||||
public string CurrentPlan { get; set; } = "free";
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[BsonElement("isActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[BsonElement("notifiedOfExpiration")]
|
||||
public bool NotifiedOfExpiration { get; set; } = false;
|
||||
}
|
||||
69
src/BCards.Web/Models/UserPage.cs
Normal file
69
src/BCards.Web/Models/UserPage.cs
Normal file
@ -0,0 +1,69 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using BCards.Web.ViewModels;
|
||||
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public class UserPage
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("userId")]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("category")]
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("businessType")]
|
||||
public string BusinessType { get; set; } = "individual"; // individual, company
|
||||
|
||||
[BsonElement("displayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("bio")]
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("profileImage")]
|
||||
public string ProfileImage { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("theme")]
|
||||
public PageTheme Theme { get; set; } = new();
|
||||
|
||||
[BsonElement("links")]
|
||||
public List<LinkItem> Links { get; set; } = new();
|
||||
|
||||
[BsonElement("seoSettings")]
|
||||
public SeoSettings SeoSettings { get; set; } = new();
|
||||
|
||||
[BsonElement("language")]
|
||||
public string Language { get; set; } = "pt-BR";
|
||||
|
||||
[BsonElement("analytics")]
|
||||
public PageAnalytics Analytics { get; set; } = new();
|
||||
|
||||
[BsonElement("isActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[BsonElement("planLimitations")]
|
||||
public PlanLimitations PlanLimitations { get; set; } = new();
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[BsonElement("publishedAt")]
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
|
||||
[BsonElement("status")]
|
||||
public PageStatus Status { get; set; } = PageStatus.Active;
|
||||
|
||||
public string FullUrl => $"page/{Category}/{Slug}";
|
||||
}
|
||||
202
src/BCards.Web/Program.cs
Normal file
202
src/BCards.Web/Program.cs
Normal file
@ -0,0 +1,202 @@
|
||||
using BCards.Web.Configuration;
|
||||
using BCards.Web.Services;
|
||||
using BCards.Web.Repositories;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.Google;
|
||||
using Microsoft.AspNetCore.Authentication.MicrosoftAccount;
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using System.Globalization;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllersWithViews()
|
||||
.AddRazorRuntimeCompilation()
|
||||
.AddViewLocalization()
|
||||
.AddDataAnnotationsLocalization();
|
||||
|
||||
// MongoDB Configuration
|
||||
builder.Services.Configure<MongoDbSettings>(
|
||||
builder.Configuration.GetSection("MongoDb"));
|
||||
|
||||
builder.Services.AddSingleton<IMongoClient>(serviceProvider =>
|
||||
{
|
||||
var settings = serviceProvider.GetRequiredService<IOptions<MongoDbSettings>>().Value;
|
||||
return new MongoClient(settings.ConnectionString);
|
||||
});
|
||||
|
||||
builder.Services.AddScoped(serviceProvider =>
|
||||
{
|
||||
var client = serviceProvider.GetRequiredService<IMongoClient>();
|
||||
var settings = serviceProvider.GetRequiredService<IOptions<MongoDbSettings>>().Value;
|
||||
return client.GetDatabase(settings.DatabaseName);
|
||||
});
|
||||
|
||||
// Stripe Configuration
|
||||
builder.Services.Configure<StripeSettings>(
|
||||
builder.Configuration.GetSection("Stripe"));
|
||||
|
||||
// OAuth Configuration
|
||||
builder.Services.Configure<GoogleAuthSettings>(
|
||||
builder.Configuration.GetSection("Authentication:Google"));
|
||||
|
||||
builder.Services.Configure<MicrosoftAuthSettings>(
|
||||
builder.Configuration.GetSection("Authentication:Microsoft"));
|
||||
|
||||
// Authentication
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/Auth/Login";
|
||||
options.LogoutPath = "/Auth/Logout";
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.SlidingExpiration = true;
|
||||
})
|
||||
.AddGoogle(options =>
|
||||
{
|
||||
var googleAuth = builder.Configuration.GetSection("Authentication:Google");
|
||||
options.ClientId = googleAuth["ClientId"] ?? "";
|
||||
options.ClientSecret = googleAuth["ClientSecret"] ?? "";
|
||||
})
|
||||
.AddMicrosoftAccount(options =>
|
||||
{
|
||||
var msAuth = builder.Configuration.GetSection("Authentication:Microsoft");
|
||||
options.ClientId = msAuth["ClientId"] ?? "";
|
||||
options.ClientSecret = msAuth["ClientSecret"] ?? "";
|
||||
});
|
||||
|
||||
// Localization
|
||||
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
|
||||
|
||||
builder.Services.Configure<RequestLocalizationOptions>(options =>
|
||||
{
|
||||
var supportedCultures = new[]
|
||||
{
|
||||
new CultureInfo("pt-BR"),
|
||||
new CultureInfo("es-ES")
|
||||
};
|
||||
|
||||
options.DefaultRequestCulture = new RequestCulture("pt-BR");
|
||||
options.SupportedCultures = supportedCultures;
|
||||
options.SupportedUICultures = supportedCultures;
|
||||
});
|
||||
|
||||
// Register Services
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
builder.Services.AddScoped<IUserPageRepository, UserPageRepository>();
|
||||
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
||||
|
||||
builder.Services.AddScoped<IUserPageService, UserPageService>();
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<ISeoService, SeoService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||
|
||||
// Background Services
|
||||
builder.Services.AddHostedService<TrialExpirationService>();
|
||||
|
||||
// Response Caching
|
||||
builder.Services.AddResponseCaching();
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
builder.Services.AddRazorPages();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Home/Error");
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
app.UseRequestLocalization();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Add custom middleware
|
||||
app.UseMiddleware<BCards.Web.Middleware.PlanLimitationMiddleware>();
|
||||
app.UseMiddleware<BCards.Web.Middleware.PageStatusMiddleware>();
|
||||
|
||||
app.UseResponseCaching();
|
||||
|
||||
// Rota padr<64>o primeiro (mais espec<65>fica)
|
||||
app.MapControllerRoute(
|
||||
name: "default",
|
||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||
|
||||
//Rota customizada depois (mais gen<65>rica)
|
||||
//app.MapControllerRoute(
|
||||
// name: "userpage",
|
||||
// pattern: "page/{category}/{slug}",
|
||||
// defaults: new { controller = "UserPage", action = "Display" },
|
||||
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||
// Rota para preview
|
||||
app.MapControllerRoute(
|
||||
name: "userpage-preview",
|
||||
pattern: "page/preview/{category}/{slug}",
|
||||
defaults: new { controller = "UserPage", action = "Preview" },
|
||||
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||
|
||||
// Rota para click
|
||||
app.MapControllerRoute(
|
||||
name: "userpage-click",
|
||||
pattern: "page/click/{pageId}",
|
||||
defaults: new { controller = "UserPage", action = "RecordClick" });
|
||||
|
||||
// Rota principal (deve vir por último)
|
||||
app.MapControllerRoute(
|
||||
name: "userpage",
|
||||
pattern: "page/{category}/{slug}",
|
||||
defaults: new { controller = "UserPage", action = "Display" },
|
||||
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||
|
||||
// Rota padrão
|
||||
app.MapControllerRoute(
|
||||
name: "default",
|
||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||
|
||||
// Initialize default data
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var themeService = scope.ServiceProvider.GetRequiredService<IThemeService>();
|
||||
var categoryService = scope.ServiceProvider.GetRequiredService<ICategoryService>();
|
||||
|
||||
try
|
||||
{
|
||||
// Initialize themes
|
||||
var existingThemes = await themeService.GetAvailableThemesAsync();
|
||||
if (!existingThemes.Any())
|
||||
{
|
||||
await themeService.InitializeDefaultThemesAsync();
|
||||
}
|
||||
|
||||
// Initialize categories
|
||||
var existingCategories = await categoryService.GetAllCategoriesAsync();
|
||||
if (!existingCategories.Any())
|
||||
{
|
||||
await categoryService.InitializeDefaultCategoriesAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogError(ex, "Error initializing default data");
|
||||
}
|
||||
}
|
||||
|
||||
app.Run();
|
||||
12
src/BCards.Web/Properties/launchSettings.json
Normal file
12
src/BCards.Web/Properties/launchSettings.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"BCards.Web": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:49178;http://localhost:49179"
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/BCards.Web/Repositories/CategoryRepository.cs
Normal file
70
src/BCards.Web/Repositories/CategoryRepository.cs
Normal file
@ -0,0 +1,70 @@
|
||||
using BCards.Web.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public class CategoryRepository : ICategoryRepository
|
||||
{
|
||||
private readonly IMongoCollection<Category> _categories;
|
||||
|
||||
public CategoryRepository(IMongoDatabase database)
|
||||
{
|
||||
_categories = database.GetCollection<Category>("categories");
|
||||
|
||||
// Create indexes
|
||||
var slugIndex = Builders<Category>.IndexKeys.Ascending(x => x.Slug);
|
||||
_categories.Indexes.CreateOneAsync(new CreateIndexModel<Category>(slugIndex, new CreateIndexOptions { Unique = true }));
|
||||
}
|
||||
|
||||
public async Task<List<Category>> GetAllActiveAsync()
|
||||
{
|
||||
return await _categories.Find(x => x.IsActive).SortBy(x => x.Name).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Category?> GetBySlugAsync(string slug)
|
||||
{
|
||||
return await _categories.Find(x => x.Slug == slug && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Category?> GetByIdAsync(string id)
|
||||
{
|
||||
return await _categories.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Category> CreateAsync(Category category)
|
||||
{
|
||||
category.CreatedAt = DateTime.UtcNow;
|
||||
await _categories.InsertOneAsync(category);
|
||||
return category;
|
||||
}
|
||||
|
||||
public async Task<Category> UpdateAsync(Category category)
|
||||
{
|
||||
await _categories.ReplaceOneAsync(x => x.Id == category.Id, category);
|
||||
return category;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string id)
|
||||
{
|
||||
await _categories.UpdateOneAsync(
|
||||
x => x.Id == id,
|
||||
Builders<Category>.Update.Set(x => x.IsActive, false)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<bool> SlugExistsAsync(string slug, string? excludeId = null)
|
||||
{
|
||||
var filter = Builders<Category>.Filter.And(
|
||||
Builders<Category>.Filter.Eq(x => x.Slug, slug),
|
||||
Builders<Category>.Filter.Eq(x => x.IsActive, true)
|
||||
);
|
||||
|
||||
if (!string.IsNullOrEmpty(excludeId))
|
||||
{
|
||||
filter = Builders<Category>.Filter.And(filter,
|
||||
Builders<Category>.Filter.Ne(x => x.Id, excludeId));
|
||||
}
|
||||
|
||||
return await _categories.Find(filter).AnyAsync();
|
||||
}
|
||||
}
|
||||
14
src/BCards.Web/Repositories/ICategoryRepository.cs
Normal file
14
src/BCards.Web/Repositories/ICategoryRepository.cs
Normal file
@ -0,0 +1,14 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public interface ICategoryRepository
|
||||
{
|
||||
Task<List<Category>> GetAllActiveAsync();
|
||||
Task<Category?> GetBySlugAsync(string slug);
|
||||
Task<Category?> GetByIdAsync(string id);
|
||||
Task<Category> CreateAsync(Category category);
|
||||
Task<Category> UpdateAsync(Category category);
|
||||
Task DeleteAsync(string id);
|
||||
Task<bool> SlugExistsAsync(string slug, string? excludeId = null);
|
||||
}
|
||||
14
src/BCards.Web/Repositories/ISubscriptionRepository.cs
Normal file
14
src/BCards.Web/Repositories/ISubscriptionRepository.cs
Normal file
@ -0,0 +1,14 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public interface ISubscriptionRepository
|
||||
{
|
||||
Task<Subscription?> GetByUserIdAsync(string userId);
|
||||
Task<Subscription?> GetByStripeSubscriptionIdAsync(string stripeSubscriptionId);
|
||||
Task<Subscription> CreateAsync(Subscription subscription);
|
||||
Task<Subscription> UpdateAsync(Subscription subscription);
|
||||
Task DeleteAsync(string id);
|
||||
Task<List<Subscription>> GetExpiringSoonAsync(int days = 7);
|
||||
Task<List<Subscription>> GetTrialSubscriptionsAsync();
|
||||
}
|
||||
19
src/BCards.Web/Repositories/IUserPageRepository.cs
Normal file
19
src/BCards.Web/Repositories/IUserPageRepository.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public interface IUserPageRepository
|
||||
{
|
||||
Task<UserPage?> GetByIdAsync(string id);
|
||||
Task<UserPage?> GetBySlugAsync(string category, string slug);
|
||||
Task<UserPage?> GetByUserIdAsync(string userId);
|
||||
Task<List<UserPage>> GetByUserIdAllAsync(string userId);
|
||||
Task<List<UserPage>> GetActivePagesAsync();
|
||||
Task<UserPage> CreateAsync(UserPage userPage);
|
||||
Task<UserPage> UpdateAsync(UserPage userPage);
|
||||
Task DeleteAsync(string id);
|
||||
Task<bool> SlugExistsAsync(string category, string slug, string? excludeId = null);
|
||||
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
|
||||
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
|
||||
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
|
||||
}
|
||||
13
src/BCards.Web/Repositories/IUserRepository.cs
Normal file
13
src/BCards.Web/Repositories/IUserRepository.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetByIdAsync(string id);
|
||||
Task<User?> GetByEmailAsync(string email);
|
||||
Task<User> CreateAsync(User user);
|
||||
Task<User> UpdateAsync(User user);
|
||||
Task DeleteAsync(string id);
|
||||
Task<bool> ExistsAsync(string email);
|
||||
}
|
||||
64
src/BCards.Web/Repositories/SubscriptionRepository.cs
Normal file
64
src/BCards.Web/Repositories/SubscriptionRepository.cs
Normal file
@ -0,0 +1,64 @@
|
||||
using BCards.Web.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public class SubscriptionRepository : ISubscriptionRepository
|
||||
{
|
||||
private readonly IMongoCollection<Subscription> _subscriptions;
|
||||
|
||||
public SubscriptionRepository(IMongoDatabase database)
|
||||
{
|
||||
_subscriptions = database.GetCollection<Subscription>("subscriptions");
|
||||
|
||||
// Create indexes
|
||||
var userIndex = Builders<Subscription>.IndexKeys.Ascending(x => x.UserId);
|
||||
_subscriptions.Indexes.CreateOneAsync(new CreateIndexModel<Subscription>(userIndex));
|
||||
|
||||
var stripeIndex = Builders<Subscription>.IndexKeys.Ascending(x => x.StripeSubscriptionId);
|
||||
_subscriptions.Indexes.CreateOneAsync(new CreateIndexModel<Subscription>(stripeIndex));
|
||||
}
|
||||
|
||||
public async Task<Subscription?> GetByUserIdAsync(string userId)
|
||||
{
|
||||
return await _subscriptions.Find(x => x.UserId == userId).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Subscription?> GetByStripeSubscriptionIdAsync(string stripeSubscriptionId)
|
||||
{
|
||||
return await _subscriptions.Find(x => x.StripeSubscriptionId == stripeSubscriptionId).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Subscription> CreateAsync(Subscription subscription)
|
||||
{
|
||||
subscription.CreatedAt = DateTime.UtcNow;
|
||||
subscription.UpdatedAt = DateTime.UtcNow;
|
||||
await _subscriptions.InsertOneAsync(subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task<Subscription> UpdateAsync(Subscription subscription)
|
||||
{
|
||||
subscription.UpdatedAt = DateTime.UtcNow;
|
||||
await _subscriptions.ReplaceOneAsync(x => x.Id == subscription.Id, subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string id)
|
||||
{
|
||||
await _subscriptions.DeleteOneAsync(x => x.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<Subscription>> GetExpiringSoonAsync(int days = 7)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(days);
|
||||
return await _subscriptions.Find(x => x.CurrentPeriodEnd <= cutoffDate && x.Status == "active")
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Subscription>> GetTrialSubscriptionsAsync()
|
||||
{
|
||||
return await _subscriptions.Find(x => x.PlanType == "trial" && x.Status == "active")
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
119
src/BCards.Web/Repositories/UserPageRepository.cs
Normal file
119
src/BCards.Web/Repositories/UserPageRepository.cs
Normal file
@ -0,0 +1,119 @@
|
||||
using BCards.Web.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public class UserPageRepository : IUserPageRepository
|
||||
{
|
||||
private readonly IMongoCollection<UserPage> _pages;
|
||||
|
||||
public UserPageRepository(IMongoDatabase database)
|
||||
{
|
||||
_pages = database.GetCollection<UserPage>("userpages");
|
||||
|
||||
// Create indexes
|
||||
var slugIndex = Builders<UserPage>.IndexKeys
|
||||
.Ascending(x => x.Category)
|
||||
.Ascending(x => x.Slug);
|
||||
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(slugIndex, new CreateIndexOptions { Unique = true }));
|
||||
|
||||
var userIndex = Builders<UserPage>.IndexKeys.Ascending(x => x.UserId);
|
||||
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(userIndex));
|
||||
|
||||
var categoryIndex = Builders<UserPage>.IndexKeys.Ascending(x => x.Category);
|
||||
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(categoryIndex));
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetByIdAsync(string id)
|
||||
{
|
||||
return await _pages.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetBySlugAsync(string category, string slug)
|
||||
{
|
||||
return await _pages.Find(x => x.Category == category.ToLower() && x.Slug == slug && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetByUserIdAsync(string userId)
|
||||
{
|
||||
return await _pages.Find(x => x.UserId == userId && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetByUserIdAllAsync(string userId)
|
||||
{
|
||||
return await _pages.Find(x => x.UserId == userId && x.IsActive).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<UserPage> CreateAsync(UserPage userPage)
|
||||
{
|
||||
userPage.CreatedAt = DateTime.UtcNow;
|
||||
userPage.UpdatedAt = DateTime.UtcNow;
|
||||
await _pages.InsertOneAsync(userPage);
|
||||
return userPage;
|
||||
}
|
||||
|
||||
public async Task<UserPage> UpdateAsync(UserPage userPage)
|
||||
{
|
||||
userPage.UpdatedAt = DateTime.UtcNow;
|
||||
await _pages.ReplaceOneAsync(x => x.Id == userPage.Id, userPage);
|
||||
return userPage;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string id)
|
||||
{
|
||||
await _pages.UpdateOneAsync(
|
||||
x => x.Id == id,
|
||||
Builders<UserPage>.Update.Set(x => x.IsActive, false).Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<bool> SlugExistsAsync(string category, string slug, string? excludeId = null)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(x => x.Category, category),
|
||||
Builders<UserPage>.Filter.Eq(x => x.Slug, slug),
|
||||
Builders<UserPage>.Filter.Eq(x => x.IsActive, true)
|
||||
);
|
||||
|
||||
if (!string.IsNullOrEmpty(excludeId))
|
||||
{
|
||||
filter = Builders<UserPage>.Filter.And(filter,
|
||||
Builders<UserPage>.Filter.Ne(x => x.Id, excludeId));
|
||||
}
|
||||
|
||||
return await _pages.Find(filter).AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetRecentPagesAsync(int limit = 10)
|
||||
{
|
||||
return await _pages.Find(x => x.IsActive && x.PublishedAt != null)
|
||||
.SortByDescending(x => x.PublishedAt)
|
||||
.Limit(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20)
|
||||
{
|
||||
return await _pages.Find(x => x.Category == category && x.IsActive && x.PublishedAt != null)
|
||||
.SortByDescending(x => x.PublishedAt)
|
||||
.Limit(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetActivePagesAsync()
|
||||
{
|
||||
return await _pages.Find(x => x.IsActive && x.Status == BCards.Web.ViewModels.PageStatus.Active)
|
||||
.SortByDescending(x => x.UpdatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task UpdateAnalyticsAsync(string id, PageAnalytics analytics)
|
||||
{
|
||||
await _pages.UpdateOneAsync(
|
||||
x => x.Id == id,
|
||||
Builders<UserPage>.Update
|
||||
.Set(x => x.Analytics, analytics)
|
||||
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||
);
|
||||
}
|
||||
}
|
||||
57
src/BCards.Web/Repositories/UserRepository.cs
Normal file
57
src/BCards.Web/Repositories/UserRepository.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using BCards.Web.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
{
|
||||
private readonly IMongoCollection<User> _users;
|
||||
|
||||
public UserRepository(IMongoDatabase database)
|
||||
{
|
||||
_users = database.GetCollection<User>("users");
|
||||
|
||||
// Create indexes
|
||||
var indexKeys = Builders<User>.IndexKeys.Ascending(x => x.Email);
|
||||
var indexOptions = new CreateIndexOptions { Unique = true };
|
||||
_users.Indexes.CreateOneAsync(new CreateIndexModel<User>(indexKeys, indexOptions));
|
||||
}
|
||||
|
||||
public async Task<User?> GetByIdAsync(string id)
|
||||
{
|
||||
return await _users.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<User?> GetByEmailAsync(string email)
|
||||
{
|
||||
return await _users.Find(x => x.Email == email && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<User> CreateAsync(User user)
|
||||
{
|
||||
user.CreatedAt = DateTime.UtcNow;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _users.InsertOneAsync(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<User> UpdateAsync(User user)
|
||||
{
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _users.ReplaceOneAsync(x => x.Id == user.Id, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string id)
|
||||
{
|
||||
await _users.UpdateOneAsync(
|
||||
x => x.Id == id,
|
||||
Builders<User>.Update.Set(x => x.IsActive, false).Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string email)
|
||||
{
|
||||
return await _users.Find(x => x.Email == email && x.IsActive).AnyAsync();
|
||||
}
|
||||
}
|
||||
130
src/BCards.Web/Resources/pt-BR/SharedResource.resx
Normal file
130
src/BCards.Web/Resources/pt-BR/SharedResource.resx
Normal file
@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="Home" xml:space="preserve">
|
||||
<value>Início</value>
|
||||
</data>
|
||||
<data name="Plans" xml:space="preserve">
|
||||
<value>Planos</value>
|
||||
</data>
|
||||
<data name="Login" xml:space="preserve">
|
||||
<value>Entrar</value>
|
||||
</data>
|
||||
<data name="Dashboard" xml:space="preserve">
|
||||
<value>Dashboard</value>
|
||||
</data>
|
||||
<data name="Logout" xml:space="preserve">
|
||||
<value>Sair</value>
|
||||
</data>
|
||||
<data name="CreatePage" xml:space="preserve">
|
||||
<value>Criar Página</value>
|
||||
</data>
|
||||
<data name="EditPage" xml:space="preserve">
|
||||
<value>Editar Página</value>
|
||||
</data>
|
||||
<data name="Save" xml:space="preserve">
|
||||
<value>Salvar</value>
|
||||
</data>
|
||||
<data name="Cancel" xml:space="preserve">
|
||||
<value>Cancelar</value>
|
||||
</data>
|
||||
<data name="Delete" xml:space="preserve">
|
||||
<value>Excluir</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Nome</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-mail</value>
|
||||
</data>
|
||||
<data name="Bio" xml:space="preserve">
|
||||
<value>Biografia</value>
|
||||
</data>
|
||||
<data name="Category" xml:space="preserve">
|
||||
<value>Categoria</value>
|
||||
</data>
|
||||
<data name="Slug" xml:space="preserve">
|
||||
<value>URL</value>
|
||||
</data>
|
||||
<data name="Links" xml:space="preserve">
|
||||
<value>Links</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Título</value>
|
||||
</data>
|
||||
<data name="URL" xml:space="preserve">
|
||||
<value>URL</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Descrição</value>
|
||||
</data>
|
||||
<data name="Theme" xml:space="preserve">
|
||||
<value>Tema</value>
|
||||
</data>
|
||||
<data name="Analytics" xml:space="preserve">
|
||||
<value>Analytics</value>
|
||||
</data>
|
||||
<data name="Views" xml:space="preserve">
|
||||
<value>Visualizações</value>
|
||||
</data>
|
||||
<data name="Clicks" xml:space="preserve">
|
||||
<value>Cliques</value>
|
||||
</data>
|
||||
</root>
|
||||
66
src/BCards.Web/Services/AuthService.cs
Normal file
66
src/BCards.Web/Services/AuthService.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using BCards.Web.Models;
|
||||
using BCards.Web.Repositories;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public AuthService(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<User> CreateOrUpdateUserFromClaimsAsync(ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var email = claimsPrincipal.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var name = claimsPrincipal.FindFirst(ClaimTypes.Name)?.Value ?? "";
|
||||
var provider = claimsPrincipal.Identity?.AuthenticationType ?? "";
|
||||
var profileImage = claimsPrincipal.FindFirst("picture")?.Value ?? "";
|
||||
|
||||
var existingUser = await _userRepository.GetByEmailAsync(email);
|
||||
|
||||
if (existingUser != null)
|
||||
{
|
||||
// Update existing user
|
||||
existingUser.Name = name;
|
||||
existingUser.ProfileImage = profileImage;
|
||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
||||
return await _userRepository.UpdateAsync(existingUser);
|
||||
}
|
||||
|
||||
// Create new user
|
||||
var newUser = new User
|
||||
{
|
||||
Email = email,
|
||||
Name = name,
|
||||
ProfileImage = profileImage,
|
||||
AuthProvider = provider,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await _userRepository.CreateAsync(newUser);
|
||||
}
|
||||
|
||||
public async Task<User?> GetCurrentUserAsync(ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var email = claimsPrincipal.FindFirst(ClaimTypes.Email)?.Value;
|
||||
if (string.IsNullOrEmpty(email))
|
||||
return null;
|
||||
|
||||
return await _userRepository.GetByEmailAsync(email);
|
||||
}
|
||||
|
||||
public async Task<User?> GetUserByIdAsync(string userId)
|
||||
{
|
||||
return await _userRepository.GetByIdAsync(userId);
|
||||
}
|
||||
|
||||
public async Task<User> UpdateUserAsync(User user)
|
||||
{
|
||||
return await _userRepository.UpdateAsync(user);
|
||||
}
|
||||
}
|
||||
191
src/BCards.Web/Services/CategoryService.cs
Normal file
191
src/BCards.Web/Services/CategoryService.cs
Normal file
@ -0,0 +1,191 @@
|
||||
using BCards.Web.Models;
|
||||
using BCards.Web.Repositories;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public class CategoryService : ICategoryService
|
||||
{
|
||||
private readonly ICategoryRepository _categoryRepository;
|
||||
|
||||
public CategoryService(ICategoryRepository categoryRepository)
|
||||
{
|
||||
_categoryRepository = categoryRepository;
|
||||
}
|
||||
|
||||
public async Task<List<Category>> GetAllCategoriesAsync()
|
||||
{
|
||||
return await _categoryRepository.GetAllActiveAsync();
|
||||
}
|
||||
|
||||
public async Task<Category?> GetCategoryBySlugAsync(string slug)
|
||||
{
|
||||
return await _categoryRepository.GetBySlugAsync(slug);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateSlugAsync(string name)
|
||||
{
|
||||
var slug = GenerateSlug(name);
|
||||
var originalSlug = slug;
|
||||
var counter = 1;
|
||||
|
||||
while (await _categoryRepository.SlugExistsAsync(slug))
|
||||
{
|
||||
slug = $"{originalSlug}-{counter}";
|
||||
counter++;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateSlugAsync(string slug, string? excludeId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
return false;
|
||||
|
||||
if (!IsValidSlugFormat(slug))
|
||||
return false;
|
||||
|
||||
return !await _categoryRepository.SlugExistsAsync(slug, excludeId);
|
||||
}
|
||||
|
||||
public async Task InitializeDefaultCategoriesAsync()
|
||||
{
|
||||
var categories = await _categoryRepository.GetAllActiveAsync();
|
||||
if (categories.Any()) return;
|
||||
|
||||
var defaultCategories = new[]
|
||||
{
|
||||
new Category
|
||||
{
|
||||
Name = "Corretor de Imóveis",
|
||||
Slug = "corretor",
|
||||
Icon = "🏠",
|
||||
Description = "Profissionais especializados em compra, venda e locação de imóveis",
|
||||
SeoKeywords = new List<string> { "corretor", "imóveis", "casa", "apartamento", "venda", "locação" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Tecnologia",
|
||||
Slug = "tecnologia",
|
||||
Icon = "💻",
|
||||
Description = "Empresas e profissionais de tecnologia, desenvolvimento e TI",
|
||||
SeoKeywords = new List<string> { "desenvolvimento", "software", "programação", "tecnologia", "TI" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Saúde",
|
||||
Slug = "saude",
|
||||
Icon = "🏥",
|
||||
Description = "Profissionais da saúde, clínicas e consultórios médicos",
|
||||
SeoKeywords = new List<string> { "médico", "saúde", "clínica", "consulta", "tratamento" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Educação",
|
||||
Slug = "educacao",
|
||||
Icon = "📚",
|
||||
Description = "Professores, escolas, cursos e instituições de ensino",
|
||||
SeoKeywords = new List<string> { "educação", "ensino", "professor", "curso", "escola" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Comércio",
|
||||
Slug = "comercio",
|
||||
Icon = "🛍️",
|
||||
Description = "Lojas, e-commerce e estabelecimentos comerciais",
|
||||
SeoKeywords = new List<string> { "loja", "comércio", "venda", "produtos", "e-commerce" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Serviços",
|
||||
Slug = "servicos",
|
||||
Icon = "🔧",
|
||||
Description = "Prestadores de serviços gerais e especializados",
|
||||
SeoKeywords = new List<string> { "serviços", "prestador", "profissional", "especializado" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Alimentação",
|
||||
Slug = "alimentacao",
|
||||
Icon = "🍽️",
|
||||
Description = "Restaurantes, delivery, food trucks e estabelecimentos alimentícios",
|
||||
SeoKeywords = new List<string> { "restaurante", "comida", "delivery", "alimentação", "gastronomia" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Beleza",
|
||||
Slug = "beleza",
|
||||
Icon = "💄",
|
||||
Description = "Salões de beleza, barbearias, estética e cuidados pessoais",
|
||||
SeoKeywords = new List<string> { "beleza", "salão", "estética", "cabeleireiro", "manicure" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Advocacia",
|
||||
Slug = "advocacia",
|
||||
Icon = "⚖️",
|
||||
Description = "Advogados, escritórios jurídicos e consultoria legal",
|
||||
SeoKeywords = new List<string> { "advogado", "jurídico", "direito", "advocacia", "legal" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Arquitetura",
|
||||
Slug = "arquitetura",
|
||||
Icon = "🏗️",
|
||||
Description = "Arquitetos, engenheiros e profissionais da construção",
|
||||
SeoKeywords = new List<string> { "arquiteto", "engenheiro", "construção", "projeto", "reforma" }
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var category in defaultCategories)
|
||||
{
|
||||
await _categoryRepository.CreateAsync(category);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateSlug(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return string.Empty;
|
||||
|
||||
// Remove acentos
|
||||
text = RemoveDiacritics(text);
|
||||
|
||||
// Converter para minúsculas
|
||||
text = text.ToLowerInvariant();
|
||||
|
||||
// Substituir espaços e caracteres especiais por hífens
|
||||
text = Regex.Replace(text, @"[^a-z0-9\s-]", "");
|
||||
text = Regex.Replace(text, @"[\s-]+", "-");
|
||||
|
||||
// Remover hífens do início e fim
|
||||
text = text.Trim('-');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private static string RemoveDiacritics(string text)
|
||||
{
|
||||
var normalizedString = text.Normalize(NormalizationForm.FormD);
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
foreach (var c in normalizedString)
|
||||
{
|
||||
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
private static bool IsValidSlugFormat(string slug)
|
||||
{
|
||||
return Regex.IsMatch(slug, @"^[a-z0-9-]+$") && !slug.StartsWith('-') && !slug.EndsWith('-');
|
||||
}
|
||||
}
|
||||
12
src/BCards.Web/Services/IAuthService.cs
Normal file
12
src/BCards.Web/Services/IAuthService.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using BCards.Web.Models;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<User> CreateOrUpdateUserFromClaimsAsync(ClaimsPrincipal claimsPrincipal);
|
||||
Task<User?> GetCurrentUserAsync(ClaimsPrincipal claimsPrincipal);
|
||||
Task<User?> GetUserByIdAsync(string userId);
|
||||
Task<User> UpdateUserAsync(User user);
|
||||
}
|
||||
12
src/BCards.Web/Services/ICategoryService.cs
Normal file
12
src/BCards.Web/Services/ICategoryService.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface ICategoryService
|
||||
{
|
||||
Task<List<Category>> GetAllCategoriesAsync();
|
||||
Task<Category?> GetCategoryBySlugAsync(string slug);
|
||||
Task InitializeDefaultCategoriesAsync();
|
||||
Task<string> GenerateSlugAsync(string name);
|
||||
Task<bool> ValidateSlugAsync(string slug, string? excludeId = null);
|
||||
}
|
||||
15
src/BCards.Web/Services/IPaymentService.cs
Normal file
15
src/BCards.Web/Services/IPaymentService.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using BCards.Web.Models;
|
||||
using Stripe;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IPaymentService
|
||||
{
|
||||
Task<string> CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl);
|
||||
Task<Customer> CreateOrGetCustomerAsync(string userId, string email, string name);
|
||||
Task<Stripe.Subscription> HandleWebhookAsync(string requestBody, string signature);
|
||||
Task<List<Price>> GetPricesAsync();
|
||||
Task<bool> CancelSubscriptionAsync(string subscriptionId);
|
||||
Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId);
|
||||
Task<PlanLimitations> GetPlanLimitationsAsync(string planType);
|
||||
}
|
||||
12
src/BCards.Web/Services/ISeoService.cs
Normal file
12
src/BCards.Web/Services/ISeoService.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface ISeoService
|
||||
{
|
||||
SeoSettings GenerateSeoSettings(UserPage userPage, Category category);
|
||||
string GeneratePageTitle(UserPage userPage, Category category);
|
||||
string GeneratePageDescription(UserPage userPage, Category category);
|
||||
List<string> GenerateKeywords(UserPage userPage, Category category);
|
||||
string GenerateCanonicalUrl(UserPage userPage);
|
||||
}
|
||||
13
src/BCards.Web/Services/IThemeService.cs
Normal file
13
src/BCards.Web/Services/IThemeService.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IThemeService
|
||||
{
|
||||
Task<List<PageTheme>> GetAvailableThemesAsync();
|
||||
Task<PageTheme?> GetThemeByIdAsync(string themeId);
|
||||
Task<PageTheme?> GetThemeByNameAsync(string themeName);
|
||||
Task<string> GenerateCustomCssAsync(PageTheme theme);
|
||||
Task InitializeDefaultThemesAsync();
|
||||
PageTheme GetDefaultTheme();
|
||||
}
|
||||
22
src/BCards.Web/Services/IUserPageService.cs
Normal file
22
src/BCards.Web/Services/IUserPageService.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IUserPageService
|
||||
{
|
||||
Task<UserPage?> GetPageAsync(string category, string slug);
|
||||
Task<UserPage?> GetUserPageAsync(string userId);
|
||||
Task<UserPage?> GetPageByIdAsync(string id);
|
||||
Task<List<UserPage>> GetUserPagesAsync(string userId);
|
||||
Task<List<UserPage>> GetActivePagesAsync();
|
||||
Task<UserPage> CreatePageAsync(UserPage userPage);
|
||||
Task<UserPage> UpdatePageAsync(UserPage userPage);
|
||||
Task DeletePageAsync(string id);
|
||||
Task<bool> ValidateSlugAsync(string category, string slug, string? excludeId = null);
|
||||
Task<string> GenerateSlugAsync(string category, string name);
|
||||
Task<bool> CanCreateLinksAsync(string userId, int newLinksCount = 1);
|
||||
Task RecordPageViewAsync(string pageId, string? referrer = null, string? userAgent = null);
|
||||
Task RecordLinkClickAsync(string pageId, int linkIndex);
|
||||
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
|
||||
Task<List<UserPage>> GetPagesByCategoryAsync(string category, int limit = 20);
|
||||
}
|
||||
322
src/BCards.Web/Services/PaymentService.cs
Normal file
322
src/BCards.Web/Services/PaymentService.cs
Normal file
@ -0,0 +1,322 @@
|
||||
using BCards.Web.Configuration;
|
||||
using BCards.Web.Models;
|
||||
using BCards.Web.Repositories;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public class PaymentService : IPaymentService
|
||||
{
|
||||
private readonly StripeSettings _stripeSettings;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ISubscriptionRepository _subscriptionRepository;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public PaymentService(
|
||||
IOptions<StripeSettings> stripeSettings,
|
||||
IUserRepository userRepository,
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_stripeSettings = stripeSettings.Value;
|
||||
_userRepository = userRepository;
|
||||
_subscriptionRepository = subscriptionRepository;
|
||||
_configuration = configuration;
|
||||
|
||||
StripeConfiguration.ApiKey = _stripeSettings.SecretKey;
|
||||
}
|
||||
|
||||
public async Task<string> CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(userId);
|
||||
if (user == null) throw new InvalidOperationException("User not found");
|
||||
|
||||
var planConfig = _configuration.GetSection($"Plans:{planType}");
|
||||
var priceId = planConfig["PriceId"];
|
||||
|
||||
if (string.IsNullOrEmpty(priceId))
|
||||
throw new InvalidOperationException($"Price ID not found for plan: {planType}");
|
||||
|
||||
var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name);
|
||||
|
||||
var options = new SessionCreateOptions
|
||||
{
|
||||
PaymentMethodTypes = new List<string> { "card" },
|
||||
Mode = "subscription",
|
||||
Customer = customer.Id,
|
||||
LineItems = new List<SessionLineItemOptions>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Price = priceId,
|
||||
Quantity = 1
|
||||
}
|
||||
},
|
||||
SuccessUrl = returnUrl,
|
||||
CancelUrl = cancelUrl,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "user_id", userId },
|
||||
{ "plan_type", planType }
|
||||
}
|
||||
};
|
||||
|
||||
var service = new SessionService();
|
||||
var session = await service.CreateAsync(options);
|
||||
|
||||
return session.Url;
|
||||
}
|
||||
|
||||
public async Task<Customer> CreateOrGetCustomerAsync(string userId, string email, string name)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(userId);
|
||||
|
||||
if (!string.IsNullOrEmpty(user?.StripeCustomerId))
|
||||
{
|
||||
var customerService = new CustomerService();
|
||||
try
|
||||
{
|
||||
return await customerService.GetAsync(user.StripeCustomerId);
|
||||
}
|
||||
catch (StripeException)
|
||||
{
|
||||
// Customer doesn't exist, create new one
|
||||
}
|
||||
}
|
||||
|
||||
// Create new customer
|
||||
var options = new CustomerCreateOptions
|
||||
{
|
||||
Email = email,
|
||||
Name = name,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "user_id", userId }
|
||||
}
|
||||
};
|
||||
|
||||
var service = new CustomerService();
|
||||
var customer = await service.CreateAsync(options);
|
||||
|
||||
// Update user with customer ID
|
||||
if (user != null)
|
||||
{
|
||||
user.StripeCustomerId = customer.Id;
|
||||
await _userRepository.UpdateAsync(user);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
public async Task<Stripe.Subscription> HandleWebhookAsync(string requestBody, string signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stripeEvent = EventUtility.ConstructEvent(requestBody, signature, _stripeSettings.WebhookSecret);
|
||||
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
case Events.CheckoutSessionCompleted:
|
||||
var session = stripeEvent.Data.Object as Session;
|
||||
await HandleCheckoutSessionCompletedAsync(session!);
|
||||
break;
|
||||
|
||||
case Events.InvoicePaymentSucceeded:
|
||||
var invoice = stripeEvent.Data.Object as Invoice;
|
||||
await HandleInvoicePaymentSucceededAsync(invoice!);
|
||||
break;
|
||||
|
||||
case Events.CustomerSubscriptionUpdated:
|
||||
case Events.CustomerSubscriptionDeleted:
|
||||
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
|
||||
await HandleSubscriptionUpdatedAsync(subscription!);
|
||||
break;
|
||||
}
|
||||
|
||||
return stripeEvent.Data.Object as Stripe.Subscription ?? new Stripe.Subscription();
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Webhook signature verification failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Price>> GetPricesAsync()
|
||||
{
|
||||
var service = new PriceService();
|
||||
var options = new PriceListOptions
|
||||
{
|
||||
Active = true,
|
||||
Type = "recurring"
|
||||
};
|
||||
|
||||
var prices = await service.ListAsync(options);
|
||||
return prices.Data;
|
||||
}
|
||||
|
||||
public async Task<bool> CancelSubscriptionAsync(string subscriptionId)
|
||||
{
|
||||
var service = new SubscriptionService();
|
||||
var options = new SubscriptionUpdateOptions
|
||||
{
|
||||
CancelAtPeriodEnd = true
|
||||
};
|
||||
|
||||
var subscription = await service.UpdateAsync(subscriptionId, options);
|
||||
|
||||
// Update local subscription
|
||||
var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
|
||||
if (localSubscription != null)
|
||||
{
|
||||
localSubscription.CancelAtPeriodEnd = true;
|
||||
await _subscriptionRepository.UpdateAsync(localSubscription);
|
||||
}
|
||||
|
||||
return subscription.CancelAtPeriodEnd;
|
||||
}
|
||||
|
||||
public async Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId)
|
||||
{
|
||||
var service = new SubscriptionService();
|
||||
var subscription = await service.GetAsync(subscriptionId);
|
||||
|
||||
var options = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = new List<SubscriptionItemOptions>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = subscription.Items.Data[0].Id,
|
||||
Price = newPriceId
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return await service.UpdateAsync(subscriptionId, options);
|
||||
}
|
||||
|
||||
public Task<PlanLimitations> GetPlanLimitationsAsync(string planType)
|
||||
{
|
||||
var limitations = planType.ToLower() switch
|
||||
{
|
||||
"basic" => new PlanLimitations
|
||||
{
|
||||
MaxLinks = 5,
|
||||
AllowCustomThemes = false,
|
||||
AllowAnalytics = true,
|
||||
AllowCustomDomain = false,
|
||||
AllowMultipleDomains = false,
|
||||
PrioritySupport = false,
|
||||
PlanType = "basic"
|
||||
},
|
||||
"professional" => new PlanLimitations
|
||||
{
|
||||
MaxLinks = 15,
|
||||
AllowCustomThemes = false,
|
||||
AllowAnalytics = true,
|
||||
AllowCustomDomain = true,
|
||||
AllowMultipleDomains = false,
|
||||
PrioritySupport = false,
|
||||
PlanType = "professional"
|
||||
},
|
||||
"premium" => new PlanLimitations
|
||||
{
|
||||
MaxLinks = -1, // Unlimited
|
||||
AllowCustomThemes = true,
|
||||
AllowAnalytics = true,
|
||||
AllowCustomDomain = true,
|
||||
AllowMultipleDomains = true,
|
||||
PrioritySupport = true,
|
||||
PlanType = "premium"
|
||||
},
|
||||
_ => new PlanLimitations
|
||||
{
|
||||
MaxLinks = 5,
|
||||
AllowCustomThemes = false,
|
||||
AllowAnalytics = false,
|
||||
AllowCustomDomain = false,
|
||||
AllowMultipleDomains = false,
|
||||
PrioritySupport = false,
|
||||
PlanType = "free"
|
||||
}
|
||||
};
|
||||
|
||||
return Task.FromResult(limitations);
|
||||
}
|
||||
|
||||
private async Task HandleCheckoutSessionCompletedAsync(Session session)
|
||||
{
|
||||
var userId = session.Metadata["user_id"];
|
||||
var planType = session.Metadata["plan_type"];
|
||||
|
||||
var subscriptionService = new SubscriptionService();
|
||||
var stripeSubscription = await subscriptionService.GetAsync(session.SubscriptionId);
|
||||
|
||||
var limitations = await GetPlanLimitationsAsync(planType);
|
||||
|
||||
var subscription = new Models.Subscription
|
||||
{
|
||||
UserId = userId,
|
||||
StripeSubscriptionId = session.SubscriptionId,
|
||||
PlanType = planType,
|
||||
Status = stripeSubscription.Status,
|
||||
CurrentPeriodStart = stripeSubscription.CurrentPeriodStart,
|
||||
CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd,
|
||||
MaxLinks = limitations.MaxLinks,
|
||||
AllowCustomThemes = limitations.AllowCustomThemes,
|
||||
AllowAnalytics = limitations.AllowAnalytics,
|
||||
AllowCustomDomain = limitations.AllowCustomDomain,
|
||||
AllowMultipleDomains = limitations.AllowMultipleDomains,
|
||||
PrioritySupport = limitations.PrioritySupport
|
||||
};
|
||||
|
||||
await _subscriptionRepository.CreateAsync(subscription);
|
||||
|
||||
// Update user
|
||||
var user = await _userRepository.GetByIdAsync(userId);
|
||||
if (user != null)
|
||||
{
|
||||
user.CurrentPlan = planType;
|
||||
user.SubscriptionStatus = "active";
|
||||
await _userRepository.UpdateAsync(user);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleInvoicePaymentSucceededAsync(Invoice invoice)
|
||||
{
|
||||
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
|
||||
if (subscription != null)
|
||||
{
|
||||
subscription.Status = "active";
|
||||
await _subscriptionRepository.UpdateAsync(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubscriptionUpdatedAsync(Stripe.Subscription stripeSubscription)
|
||||
{
|
||||
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
|
||||
if (subscription != null)
|
||||
{
|
||||
subscription.Status = stripeSubscription.Status;
|
||||
subscription.CurrentPeriodStart = stripeSubscription.CurrentPeriodStart;
|
||||
subscription.CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd;
|
||||
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
|
||||
|
||||
await _subscriptionRepository.UpdateAsync(subscription);
|
||||
|
||||
// Update user status
|
||||
var user = await _userRepository.GetByIdAsync(subscription.UserId);
|
||||
if (user != null)
|
||||
{
|
||||
user.SubscriptionStatus = stripeSubscription.Status;
|
||||
if (stripeSubscription.Status != "active")
|
||||
{
|
||||
user.CurrentPlan = "free";
|
||||
}
|
||||
await _userRepository.UpdateAsync(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/BCards.Web/Services/SeoService.cs
Normal file
79
src/BCards.Web/Services/SeoService.cs
Normal file
@ -0,0 +1,79 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public class SeoService : ISeoService
|
||||
{
|
||||
private readonly string _baseUrl;
|
||||
|
||||
public SeoService(IConfiguration configuration)
|
||||
{
|
||||
_baseUrl = configuration["BaseUrl"] ?? "https://vcart.me";
|
||||
}
|
||||
|
||||
public SeoSettings GenerateSeoSettings(UserPage userPage, Category category)
|
||||
{
|
||||
return new SeoSettings
|
||||
{
|
||||
Title = GeneratePageTitle(userPage, category),
|
||||
Description = GeneratePageDescription(userPage, category),
|
||||
Keywords = GenerateKeywords(userPage, category),
|
||||
OgTitle = GeneratePageTitle(userPage, category),
|
||||
OgDescription = GeneratePageDescription(userPage, category),
|
||||
OgImage = !string.IsNullOrEmpty(userPage.ProfileImage) ? userPage.ProfileImage : $"{_baseUrl}/images/default-og.png",
|
||||
CanonicalUrl = GenerateCanonicalUrl(userPage),
|
||||
TwitterCard = "summary_large_image"
|
||||
};
|
||||
}
|
||||
|
||||
public string GeneratePageTitle(UserPage userPage, Category category)
|
||||
{
|
||||
var businessTypeText = userPage.BusinessType == "company" ? "Empresa" : "Profissional";
|
||||
return $"{userPage.DisplayName} - {businessTypeText} de {category.Name} | BCards";
|
||||
}
|
||||
|
||||
public string GeneratePageDescription(UserPage userPage, Category category)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(userPage.Bio))
|
||||
{
|
||||
var bio = userPage.Bio.Length > 150 ? userPage.Bio[..147] + "..." : userPage.Bio;
|
||||
return $"{bio} Conheça mais sobre {userPage.DisplayName}, {category.Name.ToLower()}.";
|
||||
}
|
||||
|
||||
var businessTypeText = userPage.BusinessType == "company" ? "empresa" : "profissional";
|
||||
return $"Conheça {userPage.DisplayName}, {businessTypeText} especializado em {category.Name.ToLower()}. Acesse os links e entre em contato.";
|
||||
}
|
||||
|
||||
public List<string> GenerateKeywords(UserPage userPage, Category category)
|
||||
{
|
||||
var keywords = new List<string>
|
||||
{
|
||||
userPage.DisplayName.ToLower(),
|
||||
category.Name.ToLower(),
|
||||
userPage.BusinessType == "company" ? "empresa" : "profissional"
|
||||
};
|
||||
|
||||
// Add category SEO keywords
|
||||
keywords.AddRange(category.SeoKeywords);
|
||||
|
||||
// Add business type specific keywords
|
||||
if (userPage.BusinessType == "company")
|
||||
{
|
||||
keywords.AddRange(new[] { "empresa", "negócio", "serviços", "contato" });
|
||||
}
|
||||
else
|
||||
{
|
||||
keywords.AddRange(new[] { "profissional", "especialista", "consultor", "contato" });
|
||||
}
|
||||
|
||||
// Add location-based keywords if available
|
||||
keywords.AddRange(new[] { "brasil", "br", "online", "digital" });
|
||||
|
||||
return keywords.Distinct().ToList();
|
||||
}
|
||||
|
||||
public string GenerateCanonicalUrl(UserPage userPage)
|
||||
{
|
||||
return $"{_baseUrl}/{userPage.Category}/{userPage.Slug}";
|
||||
}
|
||||
}
|
||||
215
src/BCards.Web/Services/ThemeService.cs
Normal file
215
src/BCards.Web/Services/ThemeService.cs
Normal file
@ -0,0 +1,215 @@
|
||||
using BCards.Web.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public class ThemeService : IThemeService
|
||||
{
|
||||
private readonly IMongoCollection<PageTheme> _themes;
|
||||
|
||||
public ThemeService(IMongoDatabase database)
|
||||
{
|
||||
_themes = database.GetCollection<PageTheme>("themes");
|
||||
}
|
||||
|
||||
public async Task<List<PageTheme>> GetAvailableThemesAsync()
|
||||
{
|
||||
return await _themes.Find(x => x.IsActive).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<PageTheme?> GetThemeByIdAsync(string themeId)
|
||||
{
|
||||
return await _themes.Find(x => x.Id == themeId && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<PageTheme?> GetThemeByNameAsync(string themeName)
|
||||
{
|
||||
var theme = await _themes.Find(x => x.Name.ToLower() == themeName.ToLower() && x.IsActive).FirstOrDefaultAsync();
|
||||
return theme ?? GetDefaultTheme();
|
||||
}
|
||||
|
||||
public Task<string> GenerateCustomCssAsync(PageTheme theme)
|
||||
{
|
||||
var css = $@"
|
||||
:root {{
|
||||
--primary-color: {theme.PrimaryColor};
|
||||
--secondary-color: {theme.SecondaryColor};
|
||||
--background-color: {theme.BackgroundColor};
|
||||
--text-color: {theme.TextColor};
|
||||
}}
|
||||
|
||||
.user-page {{
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
{(!string.IsNullOrEmpty(theme.BackgroundImage) ? $"background-image: url('{theme.BackgroundImage}');" : "")}
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-attachment: fixed;
|
||||
}}
|
||||
|
||||
.profile-card {{
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}}
|
||||
|
||||
.profile-image {{
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid var(--primary-color);
|
||||
object-fit: cover;
|
||||
}}
|
||||
|
||||
.profile-name {{
|
||||
color: var(--primary-color);
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}}
|
||||
|
||||
.profile-bio {{
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
margin-bottom: 2rem;
|
||||
}}
|
||||
|
||||
.link-button {{
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
}}
|
||||
|
||||
.link-button:hover {{
|
||||
background-color: var(--secondary-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}}
|
||||
|
||||
.link-title {{
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}}
|
||||
|
||||
.link-description {{
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}}
|
||||
|
||||
@media (max-width: 768px) {{
|
||||
.profile-card {{
|
||||
padding: 1.5rem;
|
||||
margin: 1rem;
|
||||
}}
|
||||
|
||||
.profile-image {{
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}}
|
||||
|
||||
.profile-name {{
|
||||
font-size: 1.75rem;
|
||||
}}
|
||||
|
||||
.link-button {{
|
||||
padding: 0.875rem 1.5rem;
|
||||
}}
|
||||
}}
|
||||
";
|
||||
|
||||
return Task.FromResult(css);
|
||||
}
|
||||
|
||||
public async Task InitializeDefaultThemesAsync()
|
||||
{
|
||||
var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync();
|
||||
if (existingThemes.Any()) return;
|
||||
|
||||
var defaultThemes = new[]
|
||||
{
|
||||
new PageTheme
|
||||
{
|
||||
Name = "Minimalista",
|
||||
PrimaryColor = "#2563eb",
|
||||
SecondaryColor = "#1d4ed8",
|
||||
BackgroundColor = "#ffffff",
|
||||
TextColor = "#1f2937",
|
||||
IsPremium = false,
|
||||
CssTemplate = "minimal"
|
||||
},
|
||||
new PageTheme
|
||||
{
|
||||
Name = "Dark Mode",
|
||||
PrimaryColor = "#10b981",
|
||||
SecondaryColor = "#059669",
|
||||
BackgroundColor = "#111827",
|
||||
TextColor = "#f9fafb",
|
||||
IsPremium = false,
|
||||
CssTemplate = "dark"
|
||||
},
|
||||
new PageTheme
|
||||
{
|
||||
Name = "Natureza",
|
||||
PrimaryColor = "#16a34a",
|
||||
SecondaryColor = "#15803d",
|
||||
BackgroundColor = "#f0fdf4",
|
||||
TextColor = "#166534",
|
||||
BackgroundImage = "/images/themes/nature-bg.jpg",
|
||||
IsPremium = false,
|
||||
CssTemplate = "nature"
|
||||
},
|
||||
new PageTheme
|
||||
{
|
||||
Name = "Corporativo",
|
||||
PrimaryColor = "#1e40af",
|
||||
SecondaryColor = "#1e3a8a",
|
||||
BackgroundColor = "#f8fafc",
|
||||
TextColor = "#0f172a",
|
||||
IsPremium = false,
|
||||
CssTemplate = "corporate"
|
||||
},
|
||||
new PageTheme
|
||||
{
|
||||
Name = "Vibrante",
|
||||
PrimaryColor = "#dc2626",
|
||||
SecondaryColor = "#b91c1c",
|
||||
BackgroundColor = "#fef2f2",
|
||||
TextColor = "#7f1d1d",
|
||||
IsPremium = true,
|
||||
CssTemplate = "vibrant"
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var theme in defaultThemes)
|
||||
{
|
||||
await _themes.InsertOneAsync(theme);
|
||||
}
|
||||
}
|
||||
|
||||
public PageTheme GetDefaultTheme()
|
||||
{
|
||||
return new PageTheme
|
||||
{
|
||||
Name = "Padrão",
|
||||
PrimaryColor = "#2563eb",
|
||||
SecondaryColor = "#1d4ed8",
|
||||
BackgroundColor = "#ffffff",
|
||||
TextColor = "#1f2937",
|
||||
IsPremium = false,
|
||||
CssTemplate = "default"
|
||||
};
|
||||
}
|
||||
}
|
||||
178
src/BCards.Web/Services/TrialExpirationService.cs
Normal file
178
src/BCards.Web/Services/TrialExpirationService.cs
Normal file
@ -0,0 +1,178 @@
|
||||
using BCards.Web.Models;
|
||||
using BCards.Web.Repositories;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public class TrialExpirationService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<TrialExpirationService> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromHours(1); // Check every hour
|
||||
|
||||
public TrialExpirationService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<TrialExpirationService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessTrialExpirationsAsync();
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing trial expirations");
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); // Wait 5 minutes on error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessTrialExpirationsAsync()
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var subscriptionRepository = scope.ServiceProvider.GetRequiredService<ISubscriptionRepository>();
|
||||
var userPageRepository = scope.ServiceProvider.GetRequiredService<IUserPageRepository>();
|
||||
var userRepository = scope.ServiceProvider.GetRequiredService<IUserRepository>();
|
||||
|
||||
_logger.LogInformation("Checking for expired trials...");
|
||||
|
||||
// Get all active trial subscriptions
|
||||
var trialSubscriptions = await subscriptionRepository.GetTrialSubscriptionsAsync();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var subscription in trialSubscriptions)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(subscription.UserId);
|
||||
if (user == null) continue;
|
||||
|
||||
var daysUntilExpiration = (subscription.CurrentPeriodEnd - now).TotalDays;
|
||||
|
||||
if (daysUntilExpiration <= 0)
|
||||
{
|
||||
// Trial expired - deactivate page
|
||||
_logger.LogInformation($"Trial expired for user {user.Email}");
|
||||
await HandleTrialExpiredAsync(user, subscription, userPageRepository);
|
||||
}
|
||||
else if (daysUntilExpiration <= 2 && !user.NotifiedOfExpiration)
|
||||
{
|
||||
// Trial expiring soon - send notification
|
||||
_logger.LogInformation($"Trial expiring in {daysUntilExpiration:F1} days for user {user.Email}");
|
||||
await SendExpirationWarningAsync(user, subscription, daysUntilExpiration);
|
||||
|
||||
// Mark as notified
|
||||
user.NotifiedOfExpiration = true;
|
||||
await userRepository.UpdateAsync(user);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error processing trial for subscription {subscription.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Finished checking trial expirations");
|
||||
}
|
||||
|
||||
private async Task HandleTrialExpiredAsync(
|
||||
User user,
|
||||
Subscription subscription,
|
||||
IUserPageRepository userPageRepository)
|
||||
{
|
||||
// Deactivate user page
|
||||
var userPage = await userPageRepository.GetByUserIdAsync(user.Id);
|
||||
if (userPage != null)
|
||||
{
|
||||
userPage.IsActive = false;
|
||||
userPage.UpdatedAt = DateTime.UtcNow;
|
||||
await userPageRepository.UpdateAsync(userPage);
|
||||
}
|
||||
|
||||
// Update subscription status
|
||||
subscription.Status = "expired";
|
||||
subscription.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var subscriptionRepository = scope.ServiceProvider.GetRequiredService<ISubscriptionRepository>();
|
||||
await subscriptionRepository.UpdateAsync(subscription);
|
||||
|
||||
// Send expiration email
|
||||
await SendTrialExpiredEmailAsync(user);
|
||||
|
||||
_logger.LogInformation($"Deactivated trial page for user {user.Email}");
|
||||
}
|
||||
|
||||
private async Task SendExpirationWarningAsync(
|
||||
User user,
|
||||
Subscription subscription,
|
||||
double daysRemaining)
|
||||
{
|
||||
// TODO: Implement email service
|
||||
// For now, just log
|
||||
_logger.LogInformation($"Should send expiration warning to {user.Email} - {daysRemaining:F1} days remaining");
|
||||
|
||||
// Example email content:
|
||||
var subject = "Seu trial do BCards expira em breve!";
|
||||
var message = $@"
|
||||
Olá {user.Name},
|
||||
|
||||
Seu trial gratuito do BCards expira em {Math.Ceiling(daysRemaining)} dia(s).
|
||||
|
||||
Para continuar usando sua página de links, escolha um de nossos planos:
|
||||
|
||||
• Básico - R$ 9,90/mês
|
||||
• Profissional - R$ 24,90/mês
|
||||
• Premium - R$ 29,90/mês
|
||||
|
||||
Acesse: {GetUpgradeUrl()}
|
||||
|
||||
Equipe BCards
|
||||
";
|
||||
|
||||
// TODO: Send actual email when email service is implemented
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SendTrialExpiredEmailAsync(User user)
|
||||
{
|
||||
// TODO: Implement email service
|
||||
_logger.LogInformation($"Should send trial expired email to {user.Email}");
|
||||
|
||||
var subject = "Seu trial do BCards expirou";
|
||||
var message = $@"
|
||||
Olá {user.Name},
|
||||
|
||||
Seu trial gratuito do BCards expirou e sua página foi temporariamente desativada.
|
||||
|
||||
Para reativar sua página, escolha um de nossos planos:
|
||||
|
||||
• Básico - R$ 9,90/mês - 5 links, analytics básicos
|
||||
• Profissional - R$ 24,90/mês - 15 links, todos os temas, analytics avançados
|
||||
• Premium - R$ 29,90/mês - Links ilimitados, temas customizáveis, analytics completos
|
||||
|
||||
Seus dados estão seguros e serão restaurados assim que você escolher um plano.
|
||||
|
||||
Acesse: {GetUpgradeUrl()}
|
||||
|
||||
Equipe BCards
|
||||
";
|
||||
|
||||
// TODO: Send actual email when email service is implemented
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string GetUpgradeUrl()
|
||||
{
|
||||
// TODO: Get from configuration
|
||||
return "https://bcards.com.br/pricing";
|
||||
}
|
||||
}
|
||||
249
src/BCards.Web/Services/UserPageService.cs
Normal file
249
src/BCards.Web/Services/UserPageService.cs
Normal file
@ -0,0 +1,249 @@
|
||||
using BCards.Web.Models;
|
||||
using BCards.Web.Repositories;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public class UserPageService : IUserPageService
|
||||
{
|
||||
private readonly IUserPageRepository _userPageRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ISubscriptionRepository _subscriptionRepository;
|
||||
|
||||
public UserPageService(
|
||||
IUserPageRepository userPageRepository,
|
||||
IUserRepository userRepository,
|
||||
ISubscriptionRepository subscriptionRepository)
|
||||
{
|
||||
_userPageRepository = userPageRepository;
|
||||
_userRepository = userRepository;
|
||||
_subscriptionRepository = subscriptionRepository;
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetPageAsync(string category, string slug)
|
||||
{
|
||||
return await _userPageRepository.GetBySlugAsync(category, slug);
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetUserPageAsync(string userId)
|
||||
{
|
||||
return await _userPageRepository.GetByUserIdAsync(userId);
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetPageByIdAsync(string id)
|
||||
{
|
||||
return await _userPageRepository.GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetUserPagesAsync(string userId)
|
||||
{
|
||||
return await _userPageRepository.GetByUserIdAllAsync(userId);
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetActivePagesAsync()
|
||||
{
|
||||
return await _userPageRepository.GetActivePagesAsync();
|
||||
}
|
||||
|
||||
public async Task<UserPage> CreatePageAsync(UserPage userPage)
|
||||
{
|
||||
userPage.Slug = await GenerateSlugAsync(userPage.Category, userPage.DisplayName);
|
||||
return await _userPageRepository.CreateAsync(userPage);
|
||||
}
|
||||
|
||||
public async Task<UserPage> UpdatePageAsync(UserPage userPage)
|
||||
{
|
||||
return await _userPageRepository.UpdateAsync(userPage);
|
||||
}
|
||||
|
||||
public async Task DeletePageAsync(string id)
|
||||
{
|
||||
await _userPageRepository.DeleteAsync(id);
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateSlugAsync(string category, string slug, string? excludeId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(slug) || string.IsNullOrWhiteSpace(category))
|
||||
return false;
|
||||
|
||||
if (!IsValidSlugFormat(slug))
|
||||
return false;
|
||||
|
||||
return !await _userPageRepository.SlugExistsAsync(category, slug, excludeId);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateSlugAsync(string category, string name)
|
||||
{
|
||||
var slug = GenerateSlug(name);
|
||||
var originalSlug = slug;
|
||||
var counter = 1;
|
||||
|
||||
while (await _userPageRepository.SlugExistsAsync(category, slug))
|
||||
{
|
||||
slug = $"{originalSlug}-{counter}";
|
||||
counter++;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
public async Task<bool> CanCreateLinksAsync(string userId, int newLinksCount = 1)
|
||||
{
|
||||
var userPage = await _userPageRepository.GetByUserIdAsync(userId);
|
||||
if (userPage == null) return true; // New page
|
||||
|
||||
var subscription = await _subscriptionRepository.GetByUserIdAsync(userId);
|
||||
var maxLinks = subscription?.MaxLinks ?? 5; // Default free plan
|
||||
|
||||
if (maxLinks == -1) return true; // Unlimited
|
||||
|
||||
var currentLinksCount = userPage.Links?.Count(l => l.IsActive) ?? 0;
|
||||
return (currentLinksCount + newLinksCount) <= maxLinks;
|
||||
}
|
||||
|
||||
public async Task RecordPageViewAsync(string pageId, string? referrer = null, string? userAgent = null)
|
||||
{
|
||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||
if (page?.PlanLimitations.AllowAnalytics != true) return;
|
||||
|
||||
var analytics = page.Analytics;
|
||||
analytics.TotalViews++;
|
||||
analytics.LastViewedAt = DateTime.UtcNow;
|
||||
|
||||
// Monthly stats
|
||||
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
if (analytics.MonthlyViews.ContainsKey(monthKey))
|
||||
analytics.MonthlyViews[monthKey]++;
|
||||
else
|
||||
analytics.MonthlyViews[monthKey] = 1;
|
||||
|
||||
// Referrer stats
|
||||
if (!string.IsNullOrEmpty(referrer))
|
||||
{
|
||||
var domain = ExtractDomain(referrer);
|
||||
if (!string.IsNullOrEmpty(domain))
|
||||
{
|
||||
if (analytics.TopReferrers.ContainsKey(domain))
|
||||
analytics.TopReferrers[domain]++;
|
||||
else
|
||||
analytics.TopReferrers[domain] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Device stats (simplified)
|
||||
if (!string.IsNullOrEmpty(userAgent))
|
||||
{
|
||||
var deviceType = GetDeviceType(userAgent);
|
||||
if (analytics.DeviceStats.ContainsKey(deviceType))
|
||||
analytics.DeviceStats[deviceType]++;
|
||||
else
|
||||
analytics.DeviceStats[deviceType] = 1;
|
||||
}
|
||||
|
||||
await _userPageRepository.UpdateAnalyticsAsync(pageId, analytics);
|
||||
}
|
||||
|
||||
public async Task RecordLinkClickAsync(string pageId, int linkIndex)
|
||||
{
|
||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||
if (page?.PlanLimitations.AllowAnalytics != true) return;
|
||||
|
||||
if (linkIndex >= 0 && linkIndex < page.Links.Count)
|
||||
{
|
||||
page.Links[linkIndex].Clicks++;
|
||||
}
|
||||
|
||||
var analytics = page.Analytics;
|
||||
analytics.TotalClicks++;
|
||||
|
||||
// Monthly clicks
|
||||
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
if (analytics.MonthlyClicks.ContainsKey(monthKey))
|
||||
analytics.MonthlyClicks[monthKey]++;
|
||||
else
|
||||
analytics.MonthlyClicks[monthKey] = 1;
|
||||
|
||||
await _userPageRepository.UpdateAsync(page);
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetRecentPagesAsync(int limit = 10)
|
||||
{
|
||||
return await _userPageRepository.GetRecentPagesAsync(limit);
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetPagesByCategoryAsync(string category, int limit = 20)
|
||||
{
|
||||
return await _userPageRepository.GetByCategoryAsync(category, limit);
|
||||
}
|
||||
|
||||
private static string GenerateSlug(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return string.Empty;
|
||||
|
||||
// Remove acentos
|
||||
text = RemoveDiacritics(text);
|
||||
|
||||
// Converter para minúsculas
|
||||
text = text.ToLowerInvariant();
|
||||
|
||||
// Substituir espaços e caracteres especiais por hífens
|
||||
text = Regex.Replace(text, @"[^a-z0-9\s-]", "");
|
||||
text = Regex.Replace(text, @"[\s-]+", "-");
|
||||
|
||||
// Remover hífens do início e fim
|
||||
text = text.Trim('-');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private static string RemoveDiacritics(string text)
|
||||
{
|
||||
var normalizedString = text.Normalize(NormalizationForm.FormD);
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
foreach (var c in normalizedString)
|
||||
{
|
||||
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
private static bool IsValidSlugFormat(string slug)
|
||||
{
|
||||
return Regex.IsMatch(slug, @"^[a-z0-9-]+$") && !slug.StartsWith('-') && !slug.EndsWith('-');
|
||||
}
|
||||
|
||||
private static string ExtractDomain(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
return uri.Host;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDeviceType(string userAgent)
|
||||
{
|
||||
userAgent = userAgent.ToLowerInvariant();
|
||||
|
||||
if (userAgent.Contains("mobile") || userAgent.Contains("android") || userAgent.Contains("iphone"))
|
||||
return "mobile";
|
||||
|
||||
if (userAgent.Contains("tablet") || userAgent.Contains("ipad"))
|
||||
return "tablet";
|
||||
|
||||
return "desktop";
|
||||
}
|
||||
}
|
||||
54
src/BCards.Web/ViewModels/CreatePageViewModel.cs
Normal file
54
src/BCards.Web/ViewModels/CreatePageViewModel.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace BCards.Web.ViewModels;
|
||||
|
||||
public class CreatePageViewModel
|
||||
{
|
||||
[Required(ErrorMessage = "Nome é obrigatório")]
|
||||
[StringLength(50, ErrorMessage = "Nome deve ter no máximo 50 caracteres")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Categoria é obrigatória")]
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Tipo de negócio é obrigatório")]
|
||||
public string BusinessType { get; set; } = "individual";
|
||||
|
||||
[StringLength(200, ErrorMessage = "Bio deve ter no máximo 200 caracteres")]
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Tema é obrigatório")]
|
||||
public string SelectedTheme { get; set; } = "minimalist";
|
||||
|
||||
[Phone(ErrorMessage = "Número de WhatsApp inválido")]
|
||||
public string WhatsAppNumber { get; set; } = string.Empty;
|
||||
|
||||
[Url(ErrorMessage = "URL do Facebook inválida")]
|
||||
public string FacebookUrl { get; set; } = string.Empty;
|
||||
|
||||
[Url(ErrorMessage = "URL do X/Twitter inválida")]
|
||||
public string TwitterUrl { get; set; } = string.Empty;
|
||||
|
||||
[Url(ErrorMessage = "URL do Instagram inválida")]
|
||||
public string InstagramUrl { get; set; } = string.Empty;
|
||||
|
||||
public List<CreateLinkViewModel> Links { get; set; } = new();
|
||||
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class CreateLinkViewModel
|
||||
{
|
||||
[Required(ErrorMessage = "Título é obrigatório")]
|
||||
[StringLength(50, ErrorMessage = "Título deve ter no máximo 50 caracteres")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "URL é obrigatória")]
|
||||
[Url(ErrorMessage = "URL inválida")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
}
|
||||
108
src/BCards.Web/ViewModels/ManagePageViewModel.cs
Normal file
108
src/BCards.Web/ViewModels/ManagePageViewModel.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.ViewModels;
|
||||
|
||||
public class ManagePageViewModel
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public bool IsNewPage { get; set; } = true;
|
||||
|
||||
[Required(ErrorMessage = "Nome é obrigatório")]
|
||||
[StringLength(50, ErrorMessage = "Nome deve ter no máximo 50 caracteres")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Categoria é obrigatória")]
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Tipo de negócio é obrigatório")]
|
||||
public string BusinessType { get; set; } = "individual";
|
||||
|
||||
[StringLength(200, ErrorMessage = "Bio deve ter no máximo 200 caracteres")]
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Tema é obrigatório")]
|
||||
public string SelectedTheme { get; set; } = "minimalist";
|
||||
|
||||
public string WhatsAppNumber { get; set; } = string.Empty;
|
||||
|
||||
public string FacebookUrl { get; set; } = string.Empty;
|
||||
|
||||
public string TwitterUrl { get; set; } = string.Empty;
|
||||
|
||||
public string InstagramUrl { get; set; } = string.Empty;
|
||||
|
||||
public List<ManageLinkViewModel> Links { get; set; } = new();
|
||||
|
||||
// Data for dropdowns and selections
|
||||
public List<Category> AvailableCategories { get; set; } = new();
|
||||
public List<PageTheme> AvailableThemes { get; set; } = new();
|
||||
|
||||
// Plan limitations
|
||||
public int MaxLinksAllowed { get; set; } = 3;
|
||||
public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower());
|
||||
}
|
||||
|
||||
public class ManageLinkViewModel
|
||||
{
|
||||
public string Id { get; set; } = "new";
|
||||
|
||||
[Required(ErrorMessage = "Título é obrigatório")]
|
||||
[StringLength(50, ErrorMessage = "Título deve ter no máximo 50 caracteres")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "URL é obrigatória")]
|
||||
[Url(ErrorMessage = "URL inválida")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public int Order { get; set; } = 0;
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public class DashboardViewModel
|
||||
{
|
||||
public User CurrentUser { get; set; } = new();
|
||||
public List<UserPageSummary> UserPages { get; set; } = new();
|
||||
public PlanInfo CurrentPlan { get; set; } = new();
|
||||
public bool CanCreateNewPage { get; set; } = false;
|
||||
public int DaysRemaining { get; set; } = 0;
|
||||
}
|
||||
|
||||
public class UserPageSummary
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public PageStatus Status { get; set; } = PageStatus.Active;
|
||||
public int TotalClicks { get; set; } = 0;
|
||||
public int TotalViews { get; set; } = 0;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}";
|
||||
}
|
||||
|
||||
public class PlanInfo
|
||||
{
|
||||
public PlanType Type { get; set; } = PlanType.Trial;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int MaxPages { get; set; } = 1;
|
||||
public int MaxLinksPerPage { get; set; } = 3;
|
||||
public int DurationDays { get; set; } = 7;
|
||||
public decimal Price { get; set; } = 0;
|
||||
public bool AllowsAnalytics { get; set; } = false;
|
||||
public bool AllowsCustomThemes { get; set; } = false;
|
||||
}
|
||||
|
||||
public enum PageStatus
|
||||
{
|
||||
Active, // Funcionando normalmente
|
||||
Expired, // Trial vencido -> 301 redirect
|
||||
PendingPayment, // Pagamento atrasado -> aviso na página
|
||||
Inactive // Pausada pelo usuário
|
||||
}
|
||||
610
src/BCards.Web/Views/Admin/CreatePage.cshtml
Normal file
610
src/BCards.Web/Views/Admin/CreatePage.cshtml
Normal file
@ -0,0 +1,610 @@
|
||||
@model BCards.Web.ViewModels.CreatePageViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Criar Página";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 mx-auto">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-magic"></i>
|
||||
Criar Sua Página de Links
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress mb-4" style="height: 8px;">
|
||||
<div class="progress-bar" role="progressbar" style="width: 20%" id="wizardProgress"></div>
|
||||
</div>
|
||||
|
||||
<form asp-action="CreatePage" method="post" id="createPageForm">
|
||||
|
||||
<!-- Step 1: Informações Básicas -->
|
||||
<div class="wizard-step" id="step1">
|
||||
<h5 class="step-title">
|
||||
<span class="step-number">1</span>
|
||||
Informações Básicas
|
||||
</h5>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="DisplayName" class="form-label">Nome da Página</label>
|
||||
<input asp-for="DisplayName" class="form-control" placeholder="Ex: João Silva">
|
||||
<span asp-validation-for="DisplayName" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="Category" class="form-label">Categoria</label>
|
||||
<select asp-for="Category" class="form-select">
|
||||
<option value="">Selecione uma categoria</option>
|
||||
@foreach (var category in ViewBag.Categories as List<BCards.Web.Models.Category> ?? new List<BCards.Web.Models.Category>())
|
||||
{
|
||||
<option value="@category.Name">@category.Name</option>
|
||||
}
|
||||
</select>
|
||||
<span asp-validation-for="Category" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="BusinessType" class="form-label">Tipo</label>
|
||||
<select asp-for="BusinessType" class="form-select">
|
||||
<option value="individual">Pessoa Física</option>
|
||||
<option value="company">Empresa</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="slugPreview" class="form-label">URL da Página</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">page/</span>
|
||||
<span class="input-group-text" id="categorySlug">categoria</span>
|
||||
<span class="input-group-text">/</span>
|
||||
<input type="text" class="form-control" id="slugPreview" readonly>
|
||||
<input asp-for="Slug" type="hidden">
|
||||
</div>
|
||||
<small class="form-text text-muted">URL gerada automaticamente</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Bio" class="form-label">Bio/Descrição</label>
|
||||
<textarea asp-for="Bio" class="form-control" rows="3" placeholder="Uma breve descrição sobre você ou sua empresa..."></textarea>
|
||||
<span asp-validation-for="Bio" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Seleção de Tema -->
|
||||
<div class="wizard-step d-none" id="step2">
|
||||
<h5 class="step-title">
|
||||
<span class="step-number">2</span>
|
||||
Escolha Seu Tema Visual
|
||||
</h5>
|
||||
|
||||
<div class="row">
|
||||
@foreach (var theme in ViewBag.Themes as List<BCards.Web.Models.PageTheme> ?? new List<BCards.Web.Models.PageTheme>())
|
||||
{
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="theme-card" data-theme="@theme.Name.ToLower()">
|
||||
<div class="theme-preview" style="background: @theme.BackgroundColor; color: @theme.TextColor;">
|
||||
<div class="theme-header" style="background-color: @theme.PrimaryColor;">
|
||||
<div class="theme-avatar"></div>
|
||||
<h6>@theme.Name</h6>
|
||||
</div>
|
||||
<div class="theme-links">
|
||||
<div class="theme-link" style="background-color: @theme.PrimaryColor;"></div>
|
||||
<div class="theme-link" style="background-color: @theme.SecondaryColor;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-name">
|
||||
@theme.Name
|
||||
@if (theme.IsPremium)
|
||||
{
|
||||
<span class="badge bg-warning">Premium</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<input asp-for="SelectedTheme" type="hidden">
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Links Principais -->
|
||||
<div class="wizard-step d-none" id="step3">
|
||||
<h5 class="step-title">
|
||||
<span class="step-number">3</span>
|
||||
Links Principais
|
||||
</h5>
|
||||
|
||||
<div id="linksContainer">
|
||||
<!-- Links will be added dynamically -->
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-outline-primary" id="addLinkBtn">
|
||||
<i class="fas fa-plus"></i> Adicionar Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Redes Sociais -->
|
||||
<div class="wizard-step d-none" id="step4">
|
||||
<h5 class="step-title">
|
||||
<span class="step-number">4</span>
|
||||
Redes Sociais
|
||||
</h5>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="WhatsAppNumber" class="form-label">
|
||||
<i class="fab fa-whatsapp text-success"></i>
|
||||
WhatsApp
|
||||
</label>
|
||||
<input asp-for="WhatsAppNumber" class="form-control" placeholder="+55 11 99999-9999">
|
||||
<span asp-validation-for="WhatsAppNumber" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="FacebookUrl" class="form-label">
|
||||
<i class="fab fa-facebook text-primary"></i>
|
||||
Facebook
|
||||
</label>
|
||||
<input asp-for="FacebookUrl" class="form-control" placeholder="https://facebook.com/seu-perfil">
|
||||
<span asp-validation-for="FacebookUrl" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="TwitterUrl" class="form-label">
|
||||
<i class="fab fa-x-twitter"></i>
|
||||
X / Twitter
|
||||
</label>
|
||||
<input asp-for="TwitterUrl" class="form-control" placeholder="https://x.com/seu-perfil">
|
||||
<span asp-validation-for="TwitterUrl" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="InstagramUrl" class="form-label">
|
||||
<i class="fab fa-instagram text-danger"></i>
|
||||
Instagram
|
||||
</label>
|
||||
<input asp-for="InstagramUrl" class="form-control" placeholder="https://instagram.com/seu-perfil">
|
||||
<span asp-validation-for="InstagramUrl" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Preview e Finalização -->
|
||||
<div class="wizard-step d-none" id="step5">
|
||||
<h5 class="step-title">
|
||||
<span class="step-number">5</span>
|
||||
Preview e Finalização
|
||||
</h5>
|
||||
|
||||
<div class="preview-container">
|
||||
<div class="preview-phone">
|
||||
<div class="preview-screen" id="previewScreen">
|
||||
<!-- Preview will be generated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<p class="text-muted">Sua página estará disponível em:</p>
|
||||
<strong id="finalUrl">page/categoria/seu-slug</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<div class="wizard-navigation mt-4">
|
||||
<button type="button" class="btn btn-secondary" id="prevBtn" style="display: none;">
|
||||
<i class="fas fa-arrow-left"></i> Anterior
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary float-end" id="nextBtn">
|
||||
Próximo <i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
|
||||
<button type="submit" class="btn btn-success float-end d-none" id="submitBtn">
|
||||
<i class="fas fa-check"></i> Criar Página
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wizard-step {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
color: #495057;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
margin-right: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-card:hover,
|
||||
.theme-card.selected {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
height: 120px;
|
||||
position: relative;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.theme-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.theme-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.theme-header h6 {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.theme-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-link {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.link-input-group {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.preview-phone {
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
border: 8px solid #333;
|
||||
border-radius: 20px;
|
||||
background-color: #000;
|
||||
padding: 20px 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 12px;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.wizard-navigation {
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let currentStep = 1;
|
||||
const totalSteps = 5;
|
||||
let linkCount = 0;
|
||||
|
||||
$(document).ready(function() {
|
||||
initializeWizard();
|
||||
|
||||
// Generate slug when name or category changes
|
||||
$('#DisplayName, #Category').on('input change', function() {
|
||||
generateSlug();
|
||||
});
|
||||
|
||||
// Theme selection
|
||||
$('.theme-card').on('click', function() {
|
||||
$('.theme-card').removeClass('selected');
|
||||
$(this).addClass('selected');
|
||||
const themeName = $(this).data('theme');
|
||||
$('#SelectedTheme').val(themeName);
|
||||
});
|
||||
|
||||
// Navigation
|
||||
$('#nextBtn').on('click', function() {
|
||||
if (validateCurrentStep()) {
|
||||
nextStep();
|
||||
}
|
||||
});
|
||||
|
||||
$('#prevBtn').on('click', function() {
|
||||
prevStep();
|
||||
});
|
||||
|
||||
// Add link functionality
|
||||
$('#addLinkBtn').on('click', function() {
|
||||
addLinkInput();
|
||||
});
|
||||
|
||||
// Form submission
|
||||
$('#createPageForm').on('submit', function(e) {
|
||||
generateLinksData();
|
||||
});
|
||||
});
|
||||
|
||||
function initializeWizard() {
|
||||
updateProgressBar();
|
||||
updateNavigationButtons();
|
||||
addLinkInput(); // Add first link input
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (currentStep < totalSteps) {
|
||||
$(`#step${currentStep}`).addClass('d-none');
|
||||
currentStep++;
|
||||
$(`#step${currentStep}`).removeClass('d-none');
|
||||
|
||||
if (currentStep === 5) {
|
||||
generatePreview();
|
||||
}
|
||||
|
||||
updateProgressBar();
|
||||
updateNavigationButtons();
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep > 1) {
|
||||
$(`#step${currentStep}`).addClass('d-none');
|
||||
currentStep--;
|
||||
$(`#step${currentStep}`).removeClass('d-none');
|
||||
updateProgressBar();
|
||||
updateNavigationButtons();
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgressBar() {
|
||||
const progress = (currentStep / totalSteps) * 100;
|
||||
$('#wizardProgress').css('width', progress + '%');
|
||||
}
|
||||
|
||||
function updateNavigationButtons() {
|
||||
$('#prevBtn').toggle(currentStep > 1);
|
||||
|
||||
if (currentStep === totalSteps) {
|
||||
$('#nextBtn').addClass('d-none');
|
||||
$('#submitBtn').removeClass('d-none');
|
||||
} else {
|
||||
$('#nextBtn').removeClass('d-none');
|
||||
$('#submitBtn').addClass('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function validateCurrentStep() {
|
||||
let isValid = true;
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
if (!$('#DisplayName').val() || !$('#Category').val()) {
|
||||
alert('Por favor, preencha o nome e a categoria.');
|
||||
isValid = false;
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
if (!$('#SelectedTheme').val()) {
|
||||
alert('Por favor, selecione um tema.');
|
||||
isValid = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function generateSlug() {
|
||||
const name = $('#DisplayName').val();
|
||||
const category = $('#Category').val();
|
||||
|
||||
if (name && category) {
|
||||
$.post('/Admin/GenerateSlug', { category: category, name: name })
|
||||
.done(function(data) {
|
||||
$('#Slug').val(data.slug);
|
||||
$('#slugPreview').val(data.slug);
|
||||
$('#categorySlug').text(category);
|
||||
$('#finalUrl').text(`page/${category}/${data.slug}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addLinkInput() {
|
||||
linkCount++;
|
||||
const linkHtml = `
|
||||
<div class="link-input-group" data-link="${linkCount}">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Link ${linkCount}</h6>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Título</label>
|
||||
<input type="text" class="form-control link-title" placeholder="Ex: Meu Site">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">URL</label>
|
||||
<input type="url" class="form-control link-url" placeholder="https://exemplo.com">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Descrição (opcional)</label>
|
||||
<input type="text" class="form-control link-description" placeholder="Breve descrição do link">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$('#linksContainer').append(linkHtml);
|
||||
|
||||
// Add remove functionality
|
||||
$('.remove-link-btn').off('click').on('click', function() {
|
||||
$(this).closest('.link-input-group').remove();
|
||||
});
|
||||
}
|
||||
|
||||
function generateLinksData() {
|
||||
const links = [];
|
||||
$('.link-input-group').each(function() {
|
||||
const title = $(this).find('.link-title').val();
|
||||
const url = $(this).find('.link-url').val();
|
||||
const description = $(this).find('.link-description').val();
|
||||
|
||||
if (title && url) {
|
||||
links.push({
|
||||
Title: title,
|
||||
Url: url,
|
||||
Description: description,
|
||||
Icon: ''
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Create hidden inputs for links
|
||||
$('#linksContainer').append('<div id="linksData"></div>');
|
||||
$('#linksData').empty();
|
||||
|
||||
links.forEach((link, index) => {
|
||||
$('#linksData').append(`
|
||||
<input type="hidden" name="Links[${index}].Title" value="${link.Title}" />
|
||||
<input type="hidden" name="Links[${index}].Url" value="${link.Url}" />
|
||||
<input type="hidden" name="Links[${index}].Description" value="${link.Description}" />
|
||||
<input type="hidden" name="Links[${index}].Icon" value="${link.Icon}" />
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
function generatePreview() {
|
||||
const name = $('#DisplayName').val();
|
||||
const bio = $('#Bio').val();
|
||||
const selectedTheme = $('#SelectedTheme').val();
|
||||
|
||||
let previewHtml = `
|
||||
<div class="text-center">
|
||||
<div class="mb-3">
|
||||
<div style="width: 60px; height: 60px; background-color: #ddd; border-radius: 50%; margin: 0 auto;"></div>
|
||||
</div>
|
||||
<h5 class="mb-2">${name}</h5>
|
||||
<p class="text-muted small mb-3">${bio}</p>
|
||||
<div class="d-grid gap-2">
|
||||
`;
|
||||
|
||||
// Add links preview
|
||||
$('.link-input-group').each(function() {
|
||||
const title = $(this).find('.link-title').val();
|
||||
if (title) {
|
||||
previewHtml += `<div class="btn btn-primary btn-sm">${title}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Add social media preview
|
||||
if ($('#WhatsAppNumber').val()) {
|
||||
previewHtml += `<div class="btn btn-success btn-sm"><i class="fab fa-whatsapp"></i> WhatsApp</div>`;
|
||||
}
|
||||
if ($('#FacebookUrl').val()) {
|
||||
previewHtml += `<div class="btn btn-primary btn-sm"><i class="fab fa-facebook"></i> Facebook</div>`;
|
||||
}
|
||||
if ($('#TwitterUrl').val()) {
|
||||
previewHtml += `<div class="btn btn-dark btn-sm"><i class="fab fa-x-twitter"></i> X / Twitter</div>`;
|
||||
}
|
||||
if ($('#InstagramUrl').val()) {
|
||||
previewHtml += `<div class="btn btn-danger btn-sm"><i class="fab fa-instagram"></i> Instagram</div>`;
|
||||
}
|
||||
|
||||
previewHtml += `</div></div>`;
|
||||
|
||||
$('#previewScreen').html(previewHtml);
|
||||
}
|
||||
</script>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
}
|
||||
304
src/BCards.Web/Views/Admin/Dashboard.cshtml
Normal file
304
src/BCards.Web/Views/Admin/Dashboard.cshtml
Normal file
@ -0,0 +1,304 @@
|
||||
@model BCards.Web.ViewModels.DashboardViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Dashboard - BCards";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="container py-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1>Olá, @Model.CurrentUser.Name!</h1>
|
||||
<p class="text-muted mb-0">Gerencie suas páginas profissionais</p>
|
||||
</div>
|
||||
<div>
|
||||
@if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage))
|
||||
{
|
||||
<img src="@Model.CurrentUser.ProfileImage" alt="@Model.CurrentUser.Name"
|
||||
class="rounded-circle" style="width: 60px; height: 60px; object-fit: cover;">
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Páginas -->
|
||||
<div class="row">
|
||||
@foreach (var page in Model.UserPages)
|
||||
{
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card h-100 @(page.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(page.DisplayName)</h6>
|
||||
<p class="text-muted small mb-2">@(page.Category)/@(page.Slug)</p>
|
||||
|
||||
<div class="mb-2">
|
||||
@switch (page.Status)
|
||||
{
|
||||
case BCards.Web.ViewModels.PageStatus.Active:
|
||||
<span class="badge bg-success">Ativa</span>
|
||||
break;
|
||||
case BCards.Web.ViewModels.PageStatus.Expired:
|
||||
<span class="badge bg-danger">Expirada</span>
|
||||
break;
|
||||
case BCards.Web.ViewModels.PageStatus.PendingPayment:
|
||||
<span class="badge bg-warning">Pagamento Pendente</span>
|
||||
break;
|
||||
case BCards.Web.ViewModels.PageStatus.Inactive:
|
||||
<span class="badge bg-secondary">Inativa</span>
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Model.CurrentPlan.AllowsAnalytics)
|
||||
{
|
||||
<div class="row text-center small mb-3">
|
||||
<div class="col-6">
|
||||
<div class="text-primary fw-bold">@(page.TotalViews)</div>
|
||||
<div class="text-muted">Visualizações</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-success fw-bold">@(page.TotalClicks)</div>
|
||||
<div class="text-muted">Cliques</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<a href="@Url.Action("ManagePage", new { id = page.Id })"
|
||||
class="btn btn-sm btn-outline-primary flex-fill">Editar</a>
|
||||
<a href="@(page.PublicUrl)" target="_blank"
|
||||
class="btn btn-sm btn-outline-success flex-fill">Ver</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<small class="text-muted">Criada em @(page.CreatedAt.ToString("dd/MM/yyyy"))</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Card para Criar Nova Página -->
|
||||
@if (Model.CanCreateNewPage)
|
||||
{
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card h-100 border-dashed text-center" style="border: 2px dashed #dee2e6;">
|
||||
<div class="card-body d-flex align-items-center justify-content-center">
|
||||
<div>
|
||||
<i class="fas fa-plus fa-2x text-muted mb-3"></i>
|
||||
<h6 class="text-muted">Criar Nova Página</h6>
|
||||
<a href="@Url.Action("ManagePage", new { id = "new" })"
|
||||
class="btn btn-primary">Começar</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (!Model.UserPages.Any())
|
||||
{
|
||||
<!-- Primeira Página -->
|
||||
<div class="col-12">
|
||||
<div class="card border-primary">
|
||||
<div class="card-body text-center p-5">
|
||||
<div class="mb-4">
|
||||
<i class="display-1 text-primary">🚀</i>
|
||||
</div>
|
||||
<h3>Crie sua primeira página!</h3>
|
||||
<p class="text-muted mb-4">
|
||||
Comece criando sua página profissional personalizada com seus links organizados.
|
||||
</p>
|
||||
<a href="@Url.Action("ManagePage", new { id = "new" })" class="btn btn-primary btn-lg">
|
||||
Criar Minha Página
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Limite atingido -->
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning d-flex align-items-center">
|
||||
<i class="fas fa-exclamation-triangle me-3"></i>
|
||||
<div>
|
||||
<strong>Limite atingido!</strong>
|
||||
Você já criou o máximo de @Model.CurrentPlan.MaxPages página(s) para seu plano atual.
|
||||
<a href="@Url.Action("Pricing", "Home")" class="alert-link ms-2">Fazer upgrade</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<!-- Plano Atual -->
|
||||
<div class="card mb-4 @(Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial ? "border-warning" : "")">
|
||||
<div class="card-header @(Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial ? "bg-warning" : "bg-primary") text-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-crown me-2"></i>
|
||||
Plano Atual
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="text-capitalize mb-1">@Model.CurrentPlan.Name</h5>
|
||||
|
||||
@if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial)
|
||||
{
|
||||
<p class="text-warning mb-2">
|
||||
<i class="fas fa-clock me-1"></i>
|
||||
@Model.DaysRemaining dia(s) restante(s)
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small mb-2">R$ @Model.CurrentPlan.Price.ToString("F2")/mês</p>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span>Páginas</span>
|
||||
<span>@Model.UserPages.Count/@Model.CurrentPlan.MaxPages</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
@{
|
||||
var pagesPercentage = Model.CurrentPlan.MaxPages > 0 ?
|
||||
(double)Model.UserPages.Count / Model.CurrentPlan.MaxPages * 100 : 0;
|
||||
}
|
||||
<div class="progress-bar @(pagesPercentage >= 80 ? "bg-warning" : "bg-primary")"
|
||||
style="width: @pagesPercentage%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="small mb-2">
|
||||
<i class="fas fa-link me-2"></i>
|
||||
Links por página: @(Model.CurrentPlan.MaxLinksPerPage == int.MaxValue ? "Ilimitado" : Model.CurrentPlan.MaxLinksPerPage.ToString())
|
||||
</div>
|
||||
<div class="small mb-2">
|
||||
<i class="fas fa-chart-bar me-2"></i>
|
||||
Analytics: @(Model.CurrentPlan.AllowsAnalytics ? "✅" : "❌")
|
||||
</div>
|
||||
<div class="small mb-3">
|
||||
<i class="fas fa-palette me-2"></i>
|
||||
Temas customizáveis: @(Model.CurrentPlan.AllowsCustomThemes ? "✅" : "❌")
|
||||
</div>
|
||||
|
||||
@if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial)
|
||||
{
|
||||
<a href="@Url.Action("Pricing", "Home")" class="btn btn-warning w-100">
|
||||
<i class="fas fa-rocket me-2"></i>
|
||||
Fazer Upgrade
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="@Url.Action("ManageSubscription", "Payment")" class="btn btn-outline-secondary w-100">
|
||||
<i class="fas fa-cog me-2"></i>
|
||||
Gerenciar Assinatura
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estatísticas Rápidas -->
|
||||
@if (Model.CurrentPlan.AllowsAnalytics && Model.UserPages.Any())
|
||||
{
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-chart-line me-2"></i>
|
||||
Estatísticas Gerais
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<div class="h4 text-primary mb-0">@Model.UserPages.Sum(p => p.TotalViews)</div>
|
||||
<small class="text-muted">Total de Visualizações</small>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="h4 text-success mb-0">@Model.UserPages.Sum(p => p.TotalClicks)</div>
|
||||
<small class="text-muted">Total de Cliques</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (Model.UserPages.Sum(p => p.TotalViews) > 0)
|
||||
{
|
||||
<hr class="my-3">
|
||||
<div class="text-center">
|
||||
<div class="h5 text-info mb-0">
|
||||
@((Model.UserPages.Sum(p => p.TotalClicks) * 100.0 / Model.UserPages.Sum(p => p.TotalViews)).ToString("F1"))%
|
||||
</div>
|
||||
<small class="text-muted">Taxa de Cliques</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Dicas -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-lightbulb me-2"></i>
|
||||
💡 Dicas
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check text-success me-2"></i>
|
||||
Use uma bio clara e objetiva
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check text-success me-2"></i>
|
||||
Organize seus links por importância
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check text-success me-2"></i>
|
||||
Escolha URLs fáceis de lembrar
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="fas fa-check text-success me-2"></i>
|
||||
Atualize regularmente seus links
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<i class="fas fa-check text-success me-2"></i>
|
||||
Monitore suas estatísticas
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||
<div class="toast show" role="alert">
|
||||
<div class="toast-header">
|
||||
<i class="fas fa-check-circle text-success me-2"></i>
|
||||
<strong class="me-auto">Sucesso</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
@TempData["Success"]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||
<div class="toast show" role="alert">
|
||||
<div class="toast-header">
|
||||
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
||||
<strong class="me-auto">Atenção</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
@TempData["Error"]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
716
src/BCards.Web/Views/Admin/ManagePage.cshtml
Normal file
716
src/BCards.Web/Views/Admin/ManagePage.cshtml
Normal file
@ -0,0 +1,716 @@
|
||||
@model BCards.Web.ViewModels.ManagePageViewModel
|
||||
@{
|
||||
ViewData["Title"] = Model.IsNewPage ? "Criar Página" : "Editar Página";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 mx-auto">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-@(Model.IsNewPage ? "plus" : "edit")"></i>
|
||||
@(Model.IsNewPage ? "Assistente de Criação de Página" : "Editar Página")
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form asp-action="ManagePage" method="post" id="managePageForm" novalidate>
|
||||
<input asp-for="Id" type="hidden">
|
||||
<input asp-for="IsNewPage" type="hidden">
|
||||
|
||||
<!-- Progress Bar -->
|
||||
@if (Model.IsNewPage)
|
||||
{
|
||||
<div class="mb-4">
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar" role="progressbar" style="width: 25%" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-2">
|
||||
<small class="text-muted">Passo 1 de 4</small>
|
||||
<small class="text-muted">Informações Básicas</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Accordion -->
|
||||
<div class="accordion" id="pageWizard">
|
||||
|
||||
<!-- Passo 1: Informações Básicas -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="headingBasic">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseBasic" aria-expanded="true" aria-controls="collapseBasic">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Passo 1: Informações Básicas
|
||||
<span class="badge bg-success ms-auto me-3" id="step1Status" style="display: none;">✓</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseBasic" class="accordion-collapse collapse show" aria-labelledby="headingBasic" data-bs-parent="#pageWizard">
|
||||
<div class="accordion-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="DisplayName" class="form-label">Nome da Página <span class="text-danger">*</span></label>
|
||||
<input asp-for="DisplayName" class="form-control" placeholder="Ex: João Silva" required>
|
||||
<span asp-validation-for="DisplayName" class="text-danger"></span>
|
||||
<div class="form-text">Nome que aparecerá no topo da sua página</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="Category" class="form-label">Categoria <span class="text-danger">*</span></label>
|
||||
<select asp-for="Category" class="form-select" required>
|
||||
<option value="">Selecione uma categoria</option>
|
||||
@foreach (var category in Model.AvailableCategories)
|
||||
{
|
||||
<option value="@category.Name">@category.Name</option>
|
||||
}
|
||||
</select>
|
||||
<span asp-validation-for="Category" class="text-danger"></span>
|
||||
<div class="form-text">Categoria define o tipo da sua página</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="BusinessType" class="form-label">Tipo</label>
|
||||
<select asp-for="BusinessType" class="form-select">
|
||||
<option value="individual" selected>Pessoa Física</option>
|
||||
<option value="company">Empresa</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="slugPreview" class="form-label">URL da Página</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">page/</span>
|
||||
<span class="input-group-text" id="categorySlug">categoria</span>
|
||||
<span class="input-group-text">/</span>
|
||||
<input type="text" class="form-control" id="slugPreview" value="@Model.Slug" readonly>
|
||||
<input asp-for="Slug" type="hidden">
|
||||
</div>
|
||||
<small class="form-text text-muted">URL gerada automaticamente</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Bio" class="form-label">Bio/Descrição</label>
|
||||
<textarea asp-for="Bio" class="form-control" rows="3" placeholder="Uma breve descrição sobre você ou sua empresa..."></textarea>
|
||||
<span asp-validation-for="Bio" class="text-danger"></span>
|
||||
<div class="form-text">Máximo 200 caracteres</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(2)">
|
||||
Próximo <i class="fas fa-arrow-right ms-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Passo 2: Tema Visual -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="headingTheme">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTheme" aria-expanded="false" aria-controls="collapseTheme">
|
||||
<i class="fas fa-palette me-2"></i>
|
||||
Passo 2: Tema Visual
|
||||
<span class="badge bg-success ms-auto me-3" id="step2Status" style="display: none;">✓</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseTheme" class="accordion-collapse collapse" aria-labelledby="headingTheme" data-bs-parent="#pageWizard">
|
||||
<div class="accordion-body">
|
||||
<p class="text-muted mb-4">Escolha um tema que combine com sua personalidade ou marca:</p>
|
||||
|
||||
<div class="row">
|
||||
@foreach (var theme in Model.AvailableThemes)
|
||||
{
|
||||
<div class="col-md-4 col-lg-3 mb-3">
|
||||
<div class="theme-card @(Model.SelectedTheme == theme.Name.ToLower() ? "selected" : "")" data-theme="@theme.Name.ToLower()">
|
||||
<div class="theme-preview" style="background: @theme.BackgroundColor; color: @theme.TextColor;">
|
||||
<div class="theme-header" style="background-color: @theme.PrimaryColor;">
|
||||
<div class="theme-avatar"></div>
|
||||
<h6>@theme.Name</h6>
|
||||
</div>
|
||||
<div class="theme-links">
|
||||
<div class="theme-link" style="background-color: @theme.PrimaryColor;"></div>
|
||||
<div class="theme-link" style="background-color: @theme.SecondaryColor;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-name">
|
||||
@theme.Name
|
||||
@if (theme.IsPremium)
|
||||
{
|
||||
<span class="badge bg-warning">Premium</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<input asp-for="SelectedTheme" type="hidden">
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(1)">
|
||||
<i class="fas fa-arrow-left me-1"></i> Anterior
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(3)">
|
||||
Próximo <i class="fas fa-arrow-right ms-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Passo 3: Links Principais -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="headingLinks">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLinks" aria-expanded="false" aria-controls="collapseLinks">
|
||||
<i class="fas fa-link me-2"></i>
|
||||
Passo 3: Links Principais
|
||||
<span class="badge bg-success ms-auto me-3" id="step3Status" style="display: none;">✓</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseLinks" class="accordion-collapse collapse" aria-labelledby="headingLinks" data-bs-parent="#pageWizard">
|
||||
<div class="accordion-body">
|
||||
<p class="text-muted mb-4">Adicione os links mais importantes (Máximo: @Model.MaxLinksAllowed):</p>
|
||||
|
||||
<div id="linksContainer">
|
||||
@for (int i = 0; i < Model.Links.Count; i++)
|
||||
{
|
||||
var myList = new List<string>()
|
||||
{
|
||||
"facebook",
|
||||
"whatsapp",
|
||||
"twitter",
|
||||
"instagram"
|
||||
};
|
||||
var match = myList.FirstOrDefault(stringToCheck => Model.Links[i].Icon.Contains(stringToCheck));
|
||||
if (match==null) {
|
||||
<div class="link-input-group" data-link="@i">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Link @(i + 1)</h6>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Título</label>
|
||||
<input asp-for="Links[i].Title" class="form-control link-title" placeholder="Ex: Meu Site">
|
||||
<span asp-validation-for="Links[i].Title" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">URL</label>
|
||||
<input asp-for="Links[i].Url" class="form-control link-url" placeholder="https://exemplo.com">
|
||||
<span asp-validation-for="Links[i].Url" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Descrição (opcional)</label>
|
||||
<input asp-for="Links[i].Description" class="form-control link-description" placeholder="Breve descrição do link">
|
||||
</div>
|
||||
<input asp-for="Links[i].Id" type="hidden">
|
||||
<input asp-for="Links[i].Icon" type="hidden">
|
||||
<input asp-for="Links[i].Order" type="hidden">
|
||||
<input asp-for="Links[i].IsActive" type="hidden" value="true">
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-outline-primary mb-4" id="addLinkBtn" data-bs-toggle="modal" data-bs-target="#addLinkModal">
|
||||
<i class="fas fa-plus"></i> Adicionar Link
|
||||
</button>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(2)">
|
||||
<i class="fas fa-arrow-left me-1"></i> Anterior
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(4)">
|
||||
Próximo <i class="fas fa-arrow-right ms-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@{
|
||||
var facebook = Model.Links.Where(x => x.Icon.Contains("facebook")).FirstOrDefault();
|
||||
var twitter = Model.Links.Where(x => x.Icon.Contains("twitter")).FirstOrDefault();
|
||||
var whatsapp = Model.Links.Where(x => x.Icon.Contains("whatsapp")).FirstOrDefault();
|
||||
var instagram = Model.Links.Where(x => x.Icon.Contains("instagram")).FirstOrDefault();
|
||||
var facebookUrl = facebook !=null ? facebook.Url : "";
|
||||
var twitterUrl = twitter !=null ? twitter.Url : "";
|
||||
var whatsappUrl = whatsapp !=null ? whatsapp.Url : "";
|
||||
var instagramUrl = instagram !=null ? instagram.Url : "";
|
||||
}
|
||||
<!-- Passo 4: Redes Sociais (Opcional) -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="headingSocial">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseSocial" aria-expanded="false" aria-controls="collapseSocial">
|
||||
<i class="fab fa-twitter me-2"></i>
|
||||
Passo 4: Redes Sociais (Opcional)
|
||||
<span class="badge bg-success ms-auto me-3" id="step4Status" style="display: none;">✓</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseSocial" class="accordion-collapse collapse" aria-labelledby="headingSocial" data-bs-parent="#pageWizard">
|
||||
<div class="accordion-body">
|
||||
<p class="text-muted mb-4">Conecte suas redes sociais (todos os campos são opcionais):</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="WhatsAppNumber" class="form-label">
|
||||
<i class="fab fa-whatsapp text-success"></i>
|
||||
WhatsApp
|
||||
</label>
|
||||
<input asp-for="WhatsAppNumber" class="form-control" placeholder="+55 11 99999-9999" value="@whatsappUrl">
|
||||
<span asp-validation-for="WhatsAppNumber" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="FacebookUrl" class="form-label">
|
||||
<i class="fab fa-facebook text-primary"></i>
|
||||
Facebook
|
||||
</label>
|
||||
<input asp-for="FacebookUrl" class="form-control" placeholder="https://facebook.com/seu-perfil" value="@facebookUrl">
|
||||
<span asp-validation-for="FacebookUrl" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="TwitterUrl" class="form-label">
|
||||
<i class="fab fa-x-twitter"></i>
|
||||
X / Twitter
|
||||
</label>
|
||||
<input asp-for="TwitterUrl" class="form-control" placeholder="https://x.com/seu-perfil" value="@twitterUrl">
|
||||
<span asp-validation-for="TwitterUrl" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label asp-for="InstagramUrl" class="form-label">
|
||||
<i class="fab fa-instagram text-danger"></i>
|
||||
Instagram
|
||||
</label>
|
||||
<input asp-for="InstagramUrl" class="form-control" placeholder="https://instagram.com/seu-perfil" value="@instagramUrl">
|
||||
<span asp-validation-for="InstagramUrl" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(3)">
|
||||
<i class="fas fa-arrow-left me-1"></i> Anterior
|
||||
</button>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline-success me-2" onclick="skipStep(4)">
|
||||
Pular Etapa
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-@(Model.IsNewPage ? "rocket" : "save") me-2"></i>
|
||||
@(Model.IsNewPage ? "Criar Página" : "Salvar Alterações")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para Adicionar Link -->
|
||||
<div class="modal fade" id="addLinkModal" tabindex="-1" aria-labelledby="addLinkModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addLinkModalLabel">
|
||||
<i class="fas fa-link me-2"></i>
|
||||
Adicionar Novo Link
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="addLinkForm">
|
||||
<div class="mb-3">
|
||||
<label for="linkTitle" class="form-label">Título do Link</label>
|
||||
<input type="text" class="form-control" id="linkTitle" placeholder="Ex: Meu Site, Portfólio, Instagram..." required>
|
||||
<div class="form-text">Nome que aparecerá no botão</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="linkUrl" class="form-label">URL</label>
|
||||
<input type="url" class="form-control" id="linkUrl" placeholder="https://exemplo.com" required>
|
||||
<div class="form-text">Link completo incluindo https://</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="linkDescription" class="form-label">Descrição (opcional)</label>
|
||||
<input type="text" class="form-control" id="linkDescription" placeholder="Breve descrição do link">
|
||||
<div class="form-text">Texto adicional que aparece abaixo do título</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="linkIcon" class="form-label">Ícone (opcional)</label>
|
||||
<select class="form-select" id="linkIcon">
|
||||
<option value="">Sem ícone</option>
|
||||
<option value="fas fa-globe">🌐 Site</option>
|
||||
<option value="fas fa-shopping-cart">🛒 Loja</option>
|
||||
<option value="fas fa-briefcase">💼 Portfólio</option>
|
||||
<option value="fas fa-envelope">✉️ Email</option>
|
||||
<option value="fas fa-phone">📞 Telefone</option>
|
||||
<option value="fas fa-map-marker-alt">📍 Localização</option>
|
||||
<option value="fab fa-youtube">📺 YouTube</option>
|
||||
<option value="fab fa-linkedin">💼 LinkedIn</option>
|
||||
<option value="fab fa-github">💻 GitHub</option>
|
||||
<option value="fas fa-download">⬇️ Download</option>
|
||||
<option value="fas fa-calendar">📅 Agenda</option>
|
||||
<option value="fas fa-heart">❤️ Favorito</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancelar
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="saveLinkBtn">
|
||||
<i class="fas fa-plus"></i> Adicionar Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.theme-card {
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-card:hover,
|
||||
.theme-card.selected {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
height: 100px;
|
||||
position: relative;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.theme-header h6 {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.theme-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.theme-link {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
background-color: #f8f9fa;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.link-input-group {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.link-input-group:hover {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.accordion-button:not(.collapsed) {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
border-color: rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
|
||||
<script>
|
||||
let linkCount = @Model.Links.Count;
|
||||
let currentStep = 1;
|
||||
|
||||
$(document).ready(function() {
|
||||
// Generate slug when name or category changes
|
||||
$('#DisplayName, #Category').on('input change', function() {
|
||||
generateSlug();
|
||||
updateProgress();
|
||||
});
|
||||
|
||||
// Theme selection
|
||||
$('.theme-card').on('click', function() {
|
||||
$('.theme-card').removeClass('selected');
|
||||
$(this).addClass('selected');
|
||||
const themeName = $(this).data('theme');
|
||||
$('#SelectedTheme').val(themeName);
|
||||
markStepComplete(2);
|
||||
});
|
||||
|
||||
// Add link functionality via modal
|
||||
$('#addLinkBtn').on('click', function() {
|
||||
if (linkCount >= @Model.MaxLinksAllowed) {
|
||||
alert('Você atingiu o limite de links para seu plano atual.');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Save link from modal
|
||||
$(document).on('click', '#saveLinkBtn', function() {
|
||||
console.log('Save button clicked');
|
||||
|
||||
const title = $('#linkTitle').val().trim();
|
||||
const url = $('#linkUrl').val().trim();
|
||||
const description = $('#linkDescription').val().trim();
|
||||
const icon = $('#linkIcon').val();
|
||||
|
||||
console.log('Values:', { title, url, description, icon });
|
||||
|
||||
if (!title || !url) {
|
||||
alert('Por favor, preencha pelo menos o título e a URL do link.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
|
||||
return;
|
||||
}
|
||||
|
||||
addLinkInput(title, url, description, icon);
|
||||
|
||||
// Clear modal form
|
||||
$('#addLinkForm')[0].reset();
|
||||
|
||||
// Close modal using Bootstrap 5 syntax
|
||||
var modal = bootstrap.Modal.getInstance(document.getElementById('addLinkModal'));
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
|
||||
markStepComplete(3);
|
||||
});
|
||||
|
||||
// Remove link functionality
|
||||
$(document).on('click', '.remove-link-btn', function() {
|
||||
$(this).closest('.link-input-group').remove();
|
||||
linkCount--;
|
||||
updateLinkNumbers();
|
||||
});
|
||||
|
||||
// Form validation
|
||||
$('#managePageForm').on('submit', function(e) {
|
||||
console.log('Form submitted');
|
||||
// Allow submission but add loading state
|
||||
$(this).find('button[type="submit"]').prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-2"></i>Criando...');
|
||||
});
|
||||
});
|
||||
|
||||
function nextStep(step) {
|
||||
if (validateCurrentStep()) {
|
||||
markStepComplete(currentStep);
|
||||
currentStep = step;
|
||||
updateProgress();
|
||||
|
||||
// Close current accordion and open next
|
||||
$('.accordion-collapse.show').collapse('hide');
|
||||
setTimeout(() => {
|
||||
$(`#collapse${getStepName(step)}`).collapse('show');
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
function previousStep(step) {
|
||||
currentStep = step;
|
||||
updateProgress();
|
||||
|
||||
// Close current accordion and open previous
|
||||
$('.accordion-collapse.show').collapse('hide');
|
||||
setTimeout(() => {
|
||||
$(`#collapse${getStepName(step)}`).collapse('show');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function skipStep(step) {
|
||||
markStepComplete(step);
|
||||
// Show create button or next step
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function getStepName(step) {
|
||||
const names = ['', 'Basic', 'Theme', 'Links', 'Social'];
|
||||
return names[step];
|
||||
}
|
||||
|
||||
function validateCurrentStep() {
|
||||
if (currentStep === 1) {
|
||||
const name = $('#DisplayName').val().trim();
|
||||
const category = $('#Category').val().trim();
|
||||
if (!name || !category) {
|
||||
alert('Por favor, preencha o nome da página e selecione uma categoria.');
|
||||
return false;
|
||||
}
|
||||
} else if (currentStep === 2) {
|
||||
const theme = $('#SelectedTheme').val();
|
||||
if (!theme) {
|
||||
alert('Por favor, selecione um tema visual.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function markStepComplete(step) {
|
||||
$(`#step${step}Status`).show();
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
const progress = (currentStep / 4) * 100;
|
||||
$('.progress-bar').css('width', `${progress}%`).attr('aria-valuenow', progress);
|
||||
$('.progress').next().find('small').first().text(`Passo ${currentStep} de 4`);
|
||||
}
|
||||
|
||||
function generateSlug() {
|
||||
const name = $('#DisplayName').val();
|
||||
const category = $('#Category').val();
|
||||
|
||||
if (name && category) {
|
||||
$.post('@Url.Action("GenerateSlug", "Admin")', { category: category, name: name })
|
||||
.done(function(data) {
|
||||
$('#Slug').val(data.slug);
|
||||
$('#slugPreview').val(data.slug);
|
||||
$('#categorySlug').text(category);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addLinkInput(title = '', url = '', description = '', icon = '') {
|
||||
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
|
||||
|
||||
const linkHtml = `
|
||||
<div class="link-input-group" data-link="${linkCount}">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">
|
||||
${iconHtml}Link ${linkCount + 1}: ${title || 'Novo Link'}
|
||||
</h6>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-2">
|
||||
<input type="hidden" name="Links[${linkCount}].Id" value="new">
|
||||
<label class="form-label">Título</label>
|
||||
<input type="text" name="Links[${linkCount}].Title" class="form-control link-title" value="${title}" placeholder="Ex: Meu Site" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">URL</label>
|
||||
<input type="url" name="Links[${linkCount}].Url" class="form-control link-url" value="${url}" placeholder="https://exemplo.com" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Descrição (opcional)</label>
|
||||
<input type="text" name="Links[${linkCount}].Description" class="form-control link-description" value="${description}" placeholder="Breve descrição do link" readonly>
|
||||
</div>
|
||||
<input type="hidden" name="Links[${linkCount}].Id" value="">
|
||||
<input type="hidden" name="Links[${linkCount}].Icon" value="${icon}">
|
||||
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
|
||||
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
|
||||
</div>
|
||||
`;
|
||||
|
||||
$('#linksContainer').append(linkHtml);
|
||||
linkCount++;
|
||||
}
|
||||
|
||||
function updateLinkNumbers() {
|
||||
$('.link-input-group').each(function(index) {
|
||||
$(this).find('h6').text('Link ' + (index + 1));
|
||||
$(this).attr('data-link', index);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
}
|
||||
89
src/BCards.Web/Views/Auth/Login.cshtml
Normal file
89
src/BCards.Web/Views/Auth/Login.cshtml
Normal file
@ -0,0 +1,89 @@
|
||||
@{
|
||||
ViewData["Title"] = "Login - BCards";
|
||||
var returnUrl = ViewBag.ReturnUrl as string;
|
||||
var isPreview = ViewBag.IsPreview as bool? ?? false;
|
||||
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
||||
}
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<h2 class="text-primary fw-bold">BCards</h2>
|
||||
<p class="text-muted">Entre na sua conta</p>
|
||||
</div>
|
||||
|
||||
<form asp-controller="Auth" asp-action="LoginWithGoogle" method="post" class="mb-3">
|
||||
@if (!string.IsNullOrEmpty(returnUrl))
|
||||
{
|
||||
<input type="hidden" name="returnUrl" value="@returnUrl" />
|
||||
}
|
||||
<button type="submit" class="btn btn-danger w-100 mb-2 d-flex align-items-center justify-content-center">
|
||||
<i class="me-2">🔴</i>
|
||||
Entrar com Google
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form asp-controller="Auth" asp-action="LoginWithMicrosoft" method="post" class="mb-3">
|
||||
@if (!string.IsNullOrEmpty(returnUrl))
|
||||
{
|
||||
<input type="hidden" name="returnUrl" value="@returnUrl" />
|
||||
}
|
||||
<button type="submit" class="btn btn-primary w-100 d-flex align-items-center justify-content-center">
|
||||
<i class="me-2">Ⓜ️</i>
|
||||
Entrar com Microsoft
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center">
|
||||
<small class="text-muted">
|
||||
Não temos acesso à sua senha. <br>
|
||||
Usamos apenas autenticação segura via OAuth.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="text-center">
|
||||
<small class="text-muted">
|
||||
Não tem uma conta? É grátis para começar!
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<a asp-controller="Home" asp-action="Index" class="text-decoration-none">
|
||||
← Voltar ao início
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
.card {
|
||||
border-radius: 15px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #db4437;
|
||||
border-color: #db4437;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c23321;
|
||||
border-color: #c23321;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
180
src/BCards.Web/Views/Home/Index.cshtml
Normal file
180
src/BCards.Web/Views/Home/Index.cshtml
Normal file
@ -0,0 +1,180 @@
|
||||
@{
|
||||
var isPreview = ViewBag.IsPreview as bool? ?? false;
|
||||
ViewData["Title"] = "BCards - Crie seu LinkTree Profissional";
|
||||
var categories = ViewBag.Categories as List<BCards.Web.Models.Category> ?? new List<BCards.Web.Models.Category>();
|
||||
var recentPages = ViewBag.RecentPages as List<BCards.Web.Models.UserPage> ?? new List<BCards.Web.Models.UserPage>();
|
||||
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
||||
}
|
||||
|
||||
<div class="hero-section bg-primary bg-gradient text-white py-5 mb-5">
|
||||
<div class="container">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-6">
|
||||
<h1 class="display-4 fw-bold mb-4">
|
||||
Crie sua página profissional em minutos
|
||||
</h1>
|
||||
<p class="lead mb-4">
|
||||
A melhor alternativa ao LinkTree para profissionais e empresas no Brasil.
|
||||
Organize todos os seus links em uma página única e profissional.
|
||||
</p>
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<a asp-controller="Admin" asp-action="Dashboard" class="btn btn-light btn-lg px-4">
|
||||
Acessar Dashboard
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Auth" asp-action="Login" class="btn btn-light btn-lg px-4">
|
||||
Começar Grátis
|
||||
</a>
|
||||
}
|
||||
<a asp-controller="Home" asp-action="Pricing" class="btn btn-outline-light btn-lg px-4">
|
||||
Ver Planos
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 text-center">
|
||||
<img src="~/images/hero-mockup.svg" alt="Exemplo de página BCards" class="img-fluid rounded shadow-lg" style="max-height: 400px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Categorias Populares -->
|
||||
@if (categories.Any())
|
||||
{
|
||||
<section class="mb-5">
|
||||
<h2 class="text-center mb-4">Categorias Populares</h2>
|
||||
<div class="row g-3">
|
||||
@foreach (var category in categories.Take(8))
|
||||
{
|
||||
<div class="col-6 col-md-3">
|
||||
<a href="@Url.Action("Category", "Home", new { categorySlug = category.Slug })"
|
||||
class="text-decoration-none">
|
||||
<div class="card h-100 border-0 shadow-sm hover-card">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-1 mb-2">@category.Icon</div>
|
||||
<h6 class="card-title mb-0 text-dark">@category.Name</h6>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Funcionalidades -->
|
||||
<section class="mb-5">
|
||||
<h2 class="text-center mb-4">Por que escolher o BCards?</h2>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
|
||||
<i class="fs-2 text-primary">🎨</i>
|
||||
</div>
|
||||
<h5>Temas Profissionais</h5>
|
||||
<p class="text-muted">Escolha entre diversos temas profissionais ou personalize as cores da sua página.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
|
||||
<i class="fs-2 text-primary">📊</i>
|
||||
</div>
|
||||
<h5>Analytics Avançado</h5>
|
||||
<p class="text-muted">Acompanhe quantas pessoas visitaram sua página e clicaram nos seus links.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
|
||||
<i class="fs-2 text-primary">🔗</i>
|
||||
</div>
|
||||
<h5>URLs Organizadas</h5>
|
||||
<p class="text-muted">Suas URLs são organizadas por categoria: vcart.me/corretor/seu-nome</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Páginas Recentes -->
|
||||
@if (recentPages.Any())
|
||||
{
|
||||
<section class="mb-5">
|
||||
<h2 class="text-center mb-4">Profissionais que confiam no BCards</h2>
|
||||
<div class="row g-3">
|
||||
@foreach (var page in recentPages)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
@if (!string.IsNullOrEmpty(page.ProfileImage))
|
||||
{
|
||||
<img src="@(page.ProfileImage)" alt="@(page.DisplayName)"
|
||||
class="rounded-circle mb-3" style="width: 60px; height: 60px; object-fit: cover;">
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3"
|
||||
style="width: 60px; height: 60px;">
|
||||
<i class="fs-4 text-primary">👤</i>
|
||||
</div>
|
||||
}
|
||||
<h6 class="card-title">@(page.DisplayName)</h6>
|
||||
<small class="text-muted text-capitalize">@(page.Category)</small>
|
||||
<div class="mt-2">
|
||||
<a href="~/@(page.Category)/@(page.Slug)" target="_blank"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
Ver Página
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- CTA Final -->
|
||||
<section class="text-center py-5 bg-light rounded-3 mb-5">
|
||||
<div class="container">
|
||||
<h2 class="mb-3">Pronto para começar?</h2>
|
||||
<p class="lead mb-4 text-muted">
|
||||
Crie sua página profissional agora mesmo e comece a organizar seus links.
|
||||
</p>
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<a asp-controller="Admin" asp-action="Dashboard" class="btn btn-primary btn-lg">
|
||||
Criar Minha Página
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Auth" asp-action="Login" class="btn btn-primary btn-lg">
|
||||
Começar Grátis
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.hover-card {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.hover-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
</style>
|
||||
}
|
||||
345
src/BCards.Web/Views/Home/Pricing.cshtml
Normal file
345
src/BCards.Web/Views/Home/Pricing.cshtml
Normal file
@ -0,0 +1,345 @@
|
||||
@{
|
||||
ViewData["Title"] = "Planos e Preços - BCards";
|
||||
var isPreview = ViewBag.IsPreview as bool? ?? false;
|
||||
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
||||
}
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="text-center mb-5">
|
||||
<h1 class="display-5 fw-bold mb-3">Escolha o plano ideal para você</h1>
|
||||
<p class="lead text-muted">Comece grátis e faça upgrade quando precisar de mais recursos</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 justify-content-center">
|
||||
<!-- Plano Trial -->
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-success bg-opacity-10 text-center py-4">
|
||||
<h4 class="mb-0">Trial Gratuito</h4>
|
||||
<div class="mt-3">
|
||||
<span class="display-4 fw-bold text-success">R$ 0</span>
|
||||
<span class="text-muted">/7 dias</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Até <strong>3 links</strong>
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
1 tema básico
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
7 dias grátis
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-muted me-2">✗</i>
|
||||
<span class="text-muted">Analytics</span>
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-muted me-2">✗</i>
|
||||
<span class="text-muted">Domínio personalizado</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent p-4">
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<a asp-controller="Admin" asp-action="CreatePage" class="btn btn-success w-100">Começar Grátis</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Auth" asp-action="Login" class="btn btn-success w-100">Começar Grátis</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plano Básico -->
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-light text-center py-4">
|
||||
<h4 class="mb-0">Básico</h4>
|
||||
<div class="mt-3">
|
||||
<span class="display-4 fw-bold text-primary">R$ 9,90</span>
|
||||
<span class="text-muted">/mês</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
<strong>3 páginas</strong>, 8 links cada
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Temas básicos
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Analytics simples
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
URL personalizada
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-muted me-2">✗</i>
|
||||
<span class="text-muted">Domínio personalizado</span>
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-muted me-2">✗</i>
|
||||
<span class="text-muted">Temas customizáveis</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent p-4">
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
||||
<input type="hidden" name="planType" value="basic" />
|
||||
<button type="submit" class="btn btn-outline-primary w-100">Escolher Básico</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Auth" asp-action="Login" class="btn btn-outline-primary w-100">Escolher Básico</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plano Profissional (Decoy) -->
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-warning bg-opacity-10 text-center py-4">
|
||||
<h4 class="mb-0">Profissional</h4>
|
||||
<div class="mt-3">
|
||||
<span class="display-4 fw-bold text-warning">R$ 24,90</span>
|
||||
<span class="text-muted">/mês</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
<strong>5 páginas</strong>, 20 links cada
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Todos os temas
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Analytics avançado
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Domínio personalizado
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-muted me-2">✗</i>
|
||||
<span class="text-muted">Links ilimitados</span>
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-muted me-2">✗</i>
|
||||
<span class="text-muted">Suporte prioritário</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent p-4">
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
||||
<input type="hidden" name="planType" value="professional" />
|
||||
<button type="submit" class="btn btn-warning w-100">Escolher Profissional</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Auth" asp-action="Login" class="btn btn-warning w-100">Escolher Profissional</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plano Premium (Mais Popular) -->
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card h-100 border-primary shadow position-relative">
|
||||
<div class="position-absolute top-0 start-50 translate-middle">
|
||||
<span class="badge bg-primary px-3 py-2">Mais Popular</span>
|
||||
</div>
|
||||
<div class="card-header bg-primary text-white text-center py-4">
|
||||
<h4 class="mb-0">Premium</h4>
|
||||
<div class="mt-3">
|
||||
<span class="display-4 fw-bold">R$ 29,90</span>
|
||||
<span class="opacity-75">/mês</span>
|
||||
</div>
|
||||
<small class="opacity-75">Melhor custo-benefício!</small>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
<strong>15 páginas</strong>, links ilimitados
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Temas customizáveis
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Analytics completo
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Múltiplos domínios
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Suporte prioritário
|
||||
</li>
|
||||
<li class="mb-3">
|
||||
<i class="text-success me-2">✓</i>
|
||||
Recursos exclusivos
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent p-4">
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
||||
<input type="hidden" name="planType" value="premium" />
|
||||
<button type="submit" class="btn btn-primary w-100 fw-bold">Escolher Premium</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Auth" asp-action="Login" class="btn btn-primary w-100 fw-bold">Escolher Premium</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comparação de recursos -->
|
||||
<div class="mt-5 pt-5">
|
||||
<h2 class="text-center mb-4">Compare todos os recursos</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Recursos</th>
|
||||
<th class="text-center">Trial</th>
|
||||
<th class="text-center">Básico</th>
|
||||
<th class="text-center">Profissional</th>
|
||||
<th class="text-center">Premium</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Páginas</td>
|
||||
<td class="text-center">1</td>
|
||||
<td class="text-center">3</td>
|
||||
<td class="text-center">5</td>
|
||||
<td class="text-center"><strong>15</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Links por página</td>
|
||||
<td class="text-center">3</td>
|
||||
<td class="text-center">8</td>
|
||||
<td class="text-center">20</td>
|
||||
<td class="text-center"><strong>Ilimitado</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Temas disponíveis</td>
|
||||
<td class="text-center">1 básico</td>
|
||||
<td class="text-center">Básicos</td>
|
||||
<td class="text-center">Todos</td>
|
||||
<td class="text-center"><strong>Customizáveis</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Analytics</td>
|
||||
<td class="text-center">❌</td>
|
||||
<td class="text-center">Simples</td>
|
||||
<td class="text-center">Avançado</td>
|
||||
<td class="text-center"><strong>Completo</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Domínio personalizado</td>
|
||||
<td class="text-center">❌</td>
|
||||
<td class="text-center">❌</td>
|
||||
<td class="text-center">✅</td>
|
||||
<td class="text-center">✅</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Múltiplos domínios</td>
|
||||
<td class="text-center">❌</td>
|
||||
<td class="text-center">❌</td>
|
||||
<td class="text-center">❌</td>
|
||||
<td class="text-center"><strong>✅</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Suporte prioritário</td>
|
||||
<td class="text-center">❌</td>
|
||||
<td class="text-center">❌</td>
|
||||
<td class="text-center">❌</td>
|
||||
<td class="text-center"><strong>✅</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ -->
|
||||
<div class="mt-5 pt-5">
|
||||
<h2 class="text-center mb-4">Perguntas Frequentes</h2>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="accordion" id="faqAccordion">
|
||||
<div class="accordion-item">
|
||||
<h3 class="accordion-header" id="faq1">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapse1">
|
||||
Posso cancelar minha assinatura a qualquer momento?
|
||||
</button>
|
||||
</h3>
|
||||
<div id="collapse1" class="accordion-collapse collapse show" data-bs-parent="#faqAccordion">
|
||||
<div class="accordion-body">
|
||||
Sim! Você pode cancelar sua assinatura a qualquer momento. O cancelamento será aplicado no final do período de cobrança atual.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h3 class="accordion-header" id="faq2">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse2">
|
||||
Como funciona o domínio personalizado?
|
||||
</button>
|
||||
</h3>
|
||||
<div id="collapse2" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||
<div class="accordion-body">
|
||||
Com os planos Profissional e Premium, você pode conectar seu próprio domínio (ex: meusite.com) à sua página BCards.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h3 class="accordion-header" id="faq3">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse3">
|
||||
O que acontece se eu exceder o limite de links?
|
||||
</button>
|
||||
</h3>
|
||||
<div id="collapse3" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||
<div class="accordion-body">
|
||||
Se você atingir o limite de links do seu plano, será sugerido fazer upgrade. Seus links existentes continuarão funcionando normalmente.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
130
src/BCards.Web/Views/Shared/_Layout.cshtml
Normal file
130
src/BCards.Web/Views/Shared/_Layout.cshtml
Normal file
@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@(ViewData["Title"] ?? "BCards - Crie seu LinkTree Profissional")</title>
|
||||
|
||||
@if (ViewBag.SeoSettings != null)
|
||||
{
|
||||
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
|
||||
<meta name="description" content="@seo?.Description" />
|
||||
<meta name="keywords" content="@string.Join(", ", seo?.Keywords ?? new List<string>())" />
|
||||
<link rel="canonical" href="@seo?.CanonicalUrl" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="@seo?.OgTitle" />
|
||||
<meta property="og:description" content="@seo?.OgDescription" />
|
||||
<meta property="og:image" content="@seo?.OgImage" />
|
||||
<meta property="og:url" content="@seo?.CanonicalUrl" />
|
||||
<meta property="og:type" content="profile" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="@seo?.TwitterCard" />
|
||||
<meta name="twitter:title" content="@seo?.OgTitle" />
|
||||
<meta name="twitter:description" content="@seo?.OgDescription" />
|
||||
<meta name="twitter:image" content="@seo?.OgImage" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<meta name="description" content="Crie sua página profissional com links organizados. A melhor alternativa ao LinkTree para profissionais e empresas no Brasil." />
|
||||
<meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital" />
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
||||
|
||||
@await RenderSectionAsync("Styles", required: false)
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold text-primary" asp-area="" asp-controller="Home" asp-action="Index">
|
||||
BCards
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
|
||||
aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
|
||||
<ul class="navbar-nav flex-grow-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Início</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Pricing">Planos</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Dashboard">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Logout">Sair</a>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Login">Entrar</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid">
|
||||
<main role="main">
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (TempData["Info"] != null)
|
||||
{
|
||||
<div class="alert alert-info alert-dismissible fade show" role="alert">
|
||||
@TempData["Info"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@RenderBody()
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="border-top footer text-muted">
|
||||
<div class="container">
|
||||
<div class="row py-4">
|
||||
<div class="col-md-6">
|
||||
© 2024 - BCards - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacidade</a>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<small>Crie sua página profissional em minutos</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="~/lib/jquery/jquery.min.js"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
215
src/BCards.Web/Views/Shared/_ThemeStyles.cshtml
Normal file
215
src/BCards.Web/Views/Shared/_ThemeStyles.cshtml
Normal file
@ -0,0 +1,215 @@
|
||||
@model BCards.Web.Models.PageTheme
|
||||
@{
|
||||
var theme = Model ?? new BCards.Web.Models.PageTheme
|
||||
{
|
||||
PrimaryColor = "#2563eb",
|
||||
SecondaryColor = "#1d4ed8",
|
||||
BackgroundColor = "#ffffff",
|
||||
TextColor = "#1f2937"
|
||||
};
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: @theme.PrimaryColor;
|
||||
--secondary-color: @theme.SecondaryColor;
|
||||
--background-color: @theme.BackgroundColor;
|
||||
--text-color: @theme.TextColor;
|
||||
}
|
||||
|
||||
.user-page {
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
@if (!string.IsNullOrEmpty(theme.BackgroundImage))
|
||||
{
|
||||
@:background-image: url('@theme.BackgroundImage');
|
||||
@:background-size: cover;
|
||||
@:background-position: center;
|
||||
@:background-attachment: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.profile-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid var(--primary-color);
|
||||
object-fit: cover;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-image-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid var(--primary-color);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
color: var(--primary-color);
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-bio {
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.links-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background-color: var(--primary-color);
|
||||
color: white !important;
|
||||
border: none;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background-color: var(--secondary-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.link-description {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
font-size: 1.2rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-footer {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.profile-footer a {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-footer a:hover {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@@media (max-width: 768px) {
|
||||
.profile-card {
|
||||
padding: 1.5rem;
|
||||
margin: 1rem;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.profile-image,
|
||||
.profile-image-placeholder {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.profile-bio {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
padding: 0.875rem 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.link-description {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@media (max-width: 480px) {
|
||||
.profile-card {
|
||||
padding: 1rem;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-image,
|
||||
.profile-image-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
@@media (prefers-color-scheme: dark) {
|
||||
.user-page[data-theme="dark"] .profile-card {
|
||||
background-color: rgba(17, 24, 39, 0.95);
|
||||
color: #f9fafb;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for link buttons */
|
||||
.link-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.link-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
57
src/BCards.Web/Views/Shared/_UserPageLayout.cshtml
Normal file
57
src/BCards.Web/Views/Shared/_UserPageLayout.cshtml
Normal file
@ -0,0 +1,57 @@
|
||||
@{
|
||||
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@(seo?.Title ?? ViewData["Title"])</title>
|
||||
|
||||
@if (seo != null)
|
||||
{
|
||||
<meta name="description" content="@seo.Description" />
|
||||
<meta name="keywords" content="@string.Join(", ", seo.Keywords)" />
|
||||
<link rel="canonical" href="@seo.CanonicalUrl" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="@seo.OgTitle" />
|
||||
<meta property="og:description" content="@seo.OgDescription" />
|
||||
<meta property="og:image" content="@seo.OgImage" />
|
||||
<meta property="og:url" content="@seo.CanonicalUrl" />
|
||||
<meta property="og:type" content="profile" />
|
||||
<meta property="og:site_name" content="BCards" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="@seo.TwitterCard" />
|
||||
<meta name="twitter:title" content="@seo.OgTitle" />
|
||||
<meta name="twitter:description" content="@seo.OgDescription" />
|
||||
<meta name="twitter:image" content="@seo.OgImage" />
|
||||
|
||||
<!-- Schema.org markup -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "Person",
|
||||
"name": "@seo.OgTitle?.Split('-').FirstOrDefault()?.Trim()",
|
||||
"description": "@seo.Description",
|
||||
"image": "@seo.OgImage",
|
||||
"url": "@seo.CanonicalUrl"
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" />
|
||||
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
||||
|
||||
@await RenderSectionAsync("Styles", required: false)
|
||||
</head>
|
||||
<body>
|
||||
@RenderBody()
|
||||
|
||||
<script src="~/lib/jquery/jquery.min.js"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,8 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.5/jquery.validate.min.js"
|
||||
crossorigin="anonymous"
|
||||
integrity="sha512-rstIgDs0xPgmG6RX1Aba4KV5cWJbAMcvRCVmglpam9SoHZiUCyQVDdH2LPlxoHtrv17XWblE/V/PP+Tr04hbtA==">
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.12/jquery.validate.unobtrusive.min.js"
|
||||
crossorigin="anonymous"
|
||||
integrity="sha512-o6XqxgrUsKmchwy9G5VRNWSSxTS4Urr4loO6/0hYdpWmFUfHqGzawGxeQGMDqYzxjY9sbktPbNlkIQJWagVZQg==">
|
||||
</script>
|
||||
117
src/BCards.Web/Views/UserPage/Display.cshtml
Normal file
117
src/BCards.Web/Views/UserPage/Display.cshtml
Normal file
@ -0,0 +1,117 @@
|
||||
@model BCards.Web.Models.UserPage
|
||||
@{
|
||||
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
|
||||
var category = ViewBag.Category as BCards.Web.Models.Category;
|
||||
var isPreview = ViewBag.IsPreview as bool? ?? false;
|
||||
|
||||
ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}";
|
||||
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
||||
}
|
||||
|
||||
@if (!isPreview)
|
||||
{
|
||||
@section Styles {
|
||||
<style>
|
||||
@Html.Raw(await Html.PartialAsync("_ThemeStyles", Model.Theme))
|
||||
</style>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="user-page min-vh-100 d-flex align-items-center py-4">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 col-md-8">
|
||||
<div class="profile-card text-center mx-auto">
|
||||
<!-- Profile Image -->
|
||||
@if (!string.IsNullOrEmpty(Model.ProfileImage))
|
||||
{
|
||||
<img src="@Model.ProfileImage" alt="@Model.DisplayName" class="profile-image mb-3">
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="profile-image-placeholder mb-3 mx-auto d-flex align-items-center justify-content-center">
|
||||
<i class="fs-1">👤</i>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Profile Info -->
|
||||
<h1 class="profile-name">@Model.DisplayName</h1>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Bio))
|
||||
{
|
||||
<p class="profile-bio">@Model.Bio</p>
|
||||
}
|
||||
|
||||
<!-- Links -->
|
||||
<div class="links-container">
|
||||
@if (Model.Links?.Any(l => l.IsActive) == true)
|
||||
{
|
||||
@for (int i = 0; i < Model.Links.Count; i++)
|
||||
{
|
||||
var link = Model.Links[i];
|
||||
if (link.IsActive)
|
||||
{
|
||||
<a href="@link.Url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link-button"
|
||||
data-link-index="@i"
|
||||
onclick="recordClick('@Model.Id', @i)">
|
||||
@if (!string.IsNullOrEmpty(link.Icon))
|
||||
{
|
||||
<span class="link-icon me-2">@link.Icon</span>
|
||||
}
|
||||
<div>
|
||||
<div class="link-title">@link.Title</div>
|
||||
@if (!string.IsNullOrEmpty(link.Description))
|
||||
{
|
||||
<div class="link-description">@link.Description</div>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted">
|
||||
<p>Nenhum link disponível no momento.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="profile-footer mt-4 pt-3 border-top">
|
||||
<small class="text-muted">
|
||||
Criado com <a href="@Url.Action("Index", "Home")" class="text-decoration-none">BCards</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isPreview)
|
||||
{
|
||||
<div class="position-fixed top-0 start-0 w-100 bg-warning text-dark text-center py-2" style="z-index: 9999;">
|
||||
<strong>MODO PREVIEW</strong> - Esta é uma prévia da sua página
|
||||
</div>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function recordClick(pageId, linkIndex) {
|
||||
// Record click asynchronously
|
||||
fetch('/click/' + pageId, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ linkIndex: linkIndex })
|
||||
}).catch(function(error) {
|
||||
console.log('Error recording click:', error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
}
|
||||
3
src/BCards.Web/Views/_ViewImports.cshtml
Normal file
3
src/BCards.Web/Views/_ViewImports.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@using BCards.Web
|
||||
@using BCards.Web.Models
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
48
src/BCards.Web/appsettings.json
Normal file
48
src/BCards.Web/appsettings.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"MongoDb": {
|
||||
"ConnectionString": "mongodb://localhost:27017",
|
||||
"DatabaseName": "BCardsDB"
|
||||
},
|
||||
"Stripe": {
|
||||
"PublishableKey": "pk_test_your_publishable_key_here",
|
||||
"SecretKey": "sk_test_your_secret_key_here",
|
||||
"WebhookSecret": "whsec_your_webhook_secret_here"
|
||||
},
|
||||
"Authentication": {
|
||||
"Google": {
|
||||
"ClientId": "your_google_client_id",
|
||||
"ClientSecret": "your_google_client_secret"
|
||||
},
|
||||
"Microsoft": {
|
||||
"ClientId": "b411606a-e574-4f59-b7cd-10dd941b9fa3",
|
||||
"ClientSecret": "T0.8Q~an.51iW1H0DVjL2i1bmSK_qTgVQOuEmapK"
|
||||
}
|
||||
},
|
||||
"Plans": {
|
||||
"Basic": {
|
||||
"PriceId": "price_basic_monthly",
|
||||
"Price": 9.90,
|
||||
"MaxLinks": 5,
|
||||
"Features": ["basic_themes", "simple_analytics"]
|
||||
},
|
||||
"Professional": {
|
||||
"PriceId": "price_professional_monthly",
|
||||
"Price": 24.90,
|
||||
"MaxLinks": 15,
|
||||
"Features": ["all_themes", "advanced_analytics", "custom_domain"]
|
||||
},
|
||||
"Premium": {
|
||||
"PriceId": "price_premium_monthly",
|
||||
"Price": 29.90,
|
||||
"MaxLinks": -1,
|
||||
"Features": ["custom_themes", "full_analytics", "multiple_domains", "priority_support"]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/BCards.Web/libman.json
Normal file
24
src/BCards.Web/libman.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"defaultProvider": "cdnjs",
|
||||
"libraries": [
|
||||
{
|
||||
"library": "bootstrap@5.3.2",
|
||||
"destination": "wwwroot/lib/bootstrap/",
|
||||
"files": [
|
||||
"css/bootstrap.min.css",
|
||||
"css/bootstrap.css",
|
||||
"js/bootstrap.bundle.min.js",
|
||||
"js/bootstrap.bundle.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"library": "jquery@3.7.1",
|
||||
"destination": "wwwroot/lib/jquery/",
|
||||
"files": [
|
||||
"jquery.min.js",
|
||||
"jquery.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
219
src/BCards.Web/wwwroot/css/site.css
Normal file
219
src/BCards.Web/wwwroot/css/site.css
Normal file
@ -0,0 +1,219 @@
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
html {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
/* Custom Styles */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.hover-card {
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.hover-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-image-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background-color: #f8f9fa;
|
||||
border: 4px solid #007bff;
|
||||
border-radius: 50%;
|
||||
font-size: 3rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Link Animation */
|
||||
.link-button {
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.link-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.link-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Responsive improvements */
|
||||
@media (max-width: 768px) {
|
||||
.hero-section h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-section .lead {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card improvements */
|
||||
.card {
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.8rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* Button improvements */
|
||||
.btn {
|
||||
border-radius: 50px;
|
||||
font-weight: 500;
|
||||
padding: 0.6rem 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.8rem 2rem;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(37, 140, 251, 0.3);
|
||||
}
|
||||
|
||||
/* Alert improvements */
|
||||
.alert {
|
||||
border-radius: 15px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Form improvements */
|
||||
.form-control {
|
||||
border-radius: 10px;
|
||||
border: 2px solid #e9ecef;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
|
||||
}
|
||||
|
||||
/* Navbar improvements */
|
||||
.navbar {
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
border-radius: 20px;
|
||||
margin: 0 0.2rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: rgba(0,123,255,0.1);
|
||||
}
|
||||
|
||||
/* Table improvements */
|
||||
.table {
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
border-bottom: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Accordion improvements */
|
||||
.accordion-item {
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.accordion-button {
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.accordion-button:not(.collapsed) {
|
||||
background-color: #f8f9fa;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255,255,255,.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.shadow-primary {
|
||||
box-shadow: 0 4px 15px rgba(0,123,255,0.3);
|
||||
}
|
||||
|
||||
.border-gradient {
|
||||
border: 2px solid;
|
||||
border-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%) 1;
|
||||
}
|
||||
282
src/BCards.Web/wwwroot/css/userpage.css
Normal file
282
src/BCards.Web/wwwroot/css/userpage.css
Normal file
@ -0,0 +1,282 @@
|
||||
/* User Page Specific Styles */
|
||||
.user-page {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #007bff;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-image-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #007bff;
|
||||
background-color: #f8f9fa;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
color: #007bff;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-bio {
|
||||
color: #6c757d;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.links-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background-color: #0056b3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.link-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
font-size: 1.2rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.link-description {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.profile-footer {
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(108, 117, 125, 0.2);
|
||||
padding-top: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.profile-footer a {
|
||||
color: #007bff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Theme Variables (will be overridden by dynamic CSS) */
|
||||
:root {
|
||||
--primary-color: #007bff;
|
||||
--secondary-color: #0056b3;
|
||||
--background-color: #ffffff;
|
||||
--text-color: #212529;
|
||||
--card-background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.profile-card {
|
||||
padding: 1.5rem;
|
||||
margin: 1rem;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.profile-image,
|
||||
.profile-image-placeholder {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
padding: 0.875rem 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.link-description {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.profile-card {
|
||||
padding: 1rem;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.user-page {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background-color: rgba(33, 37, 41, 0.95);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.profile-bio {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.profile-footer {
|
||||
border-top-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.user-page {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
border: 1px solid #ccc !important;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.link-button.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.link-button.loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: -10px 0 0 -10px;
|
||||
border: 2px solid rgba(255,255,255,.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Accessibility improvements */
|
||||
.link-button:focus {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.link-button,
|
||||
.link-button::before {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.link-button.loading::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.profile-card {
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
border: 2px solid;
|
||||
}
|
||||
}
|
||||
1
src/BCards.Web/wwwroot/favicon.ico
Normal file
1
src/BCards.Web/wwwroot/favicon.ico
Normal file
@ -0,0 +1 @@
|
||||
data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A
|
||||
1
src/BCards.Web/wwwroot/images/hero-mockup.png
Normal file
1
src/BCards.Web/wwwroot/images/hero-mockup.png
Normal file
@ -0,0 +1 @@
|
||||
data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgdmlld0JveD0iMCAwIDQwMCA0MDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIiBmaWxsPSIjRkJGQkZCIi8+CjxyZWN0IHg9IjUwIiB5PSI1MCIgd2lkdGg9IjMwMCIgaGVpZ2h0PSIzMDAiIHJ4PSIyMCIgZmlsbD0id2hpdGUiIHN0cm9rZT0iI0U1RTdFQiIgc3Ryb2tlLXdpZHRoPSIyIi8+CjxjaXJjbGUgY3g9IjIwMCIgY3k9IjEyMCIgcj0iMzAiIGZpbGw9IiMzQjgyRjYiLz4KPHN2ZyB4PSIxODAiIHk9IjEwMCIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiBmaWxsPSJ3aGl0ZSI+CjxyZWN0IHdpZHRoPSI0IiBoZWlnaHQ9IjI0IiB4PSI0IiB5PSI4Ii8+CjxyZWN0IHdpZHRoPSI0IiBoZWlnaHQ9IjI0IiB4PSIzMiIgeT0iOCIvPgo8cmVjdCB3aWR0aD0iMTIiIGhlaWdodD0iNCIgeD0iMTQiIHk9IjgiLz4KPHJlY3Qgd2lkdGg9IjEyIiBoZWlnaHQ9IjQiIHg9IjE0IiB5PSIyOCIvPgo8L3N2Zz4KPHN2ZyB4PSIxMDAiIHk9IjE2MCIgd2lkdGg9IjIwMCIgaGVpZ2h0PSI0MCIgZmlsbD0iIzM5ODNGNiI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iNDAiIHJ4PSIyMCIvPgo8dGV4dCB4PSIxMDAiIHk9IjI2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSJ3aGl0ZSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE0Ij5NZXUgUG9ydGbDs2xpbzwvdGV4dD4KPHN2ZyB4PSIxMDAiIHk9IjIyMCIgd2lkdGg9IjIwMCIgaGVpZ2h0PSI0MCIgZmlsbD0iIzEwQjk4MSI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iNDAiIHJ4PSIyMCIvPgo8dGV4dCB4PSIxMDAiIHk9IjI2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSJ3aGl0ZSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE0Ij5NZXVzIFNlcnZpw6dvczwvdGV4dD4KPHN2ZyB4PSIxMDAiIHk9IjI4MCIgd2lkdGg9IjIwMCIgaGVpZ2h0PSI0MCIgZmlsbD0iI0Y1OTUwRiI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iNDAiIHJ4PSIyMCIvPgo8dGV4dCB4PSIxMDAiIHk9IjI2IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSJ3aGl0ZSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE0Ij5Db250YXRvPC90ZXh0Pgo8L3N2Zz4KPC9zdmc+CjwvdGV4dD4KPC9zdmc+CjwvdGV4dD4KPC9zdmc+CjwvdGV4dD4KPC9zdmc+
|
||||
22
src/BCards.Web/wwwroot/images/hero-mockup.svg
Normal file
22
src/BCards.Web/wwwroot/images/hero-mockup.svg
Normal file
@ -0,0 +1,22 @@
|
||||
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Phone mockup -->
|
||||
<rect width="400" height="400" fill="#F8FAFC"/>
|
||||
<rect x="50" y="50" width="300" height="300" rx="20" fill="white" stroke="#E5E7EB" stroke-width="2"/>
|
||||
|
||||
<!-- Profile picture -->
|
||||
<circle cx="200" cy="120" r="30" fill="#3B82F6"/>
|
||||
<text x="200" y="127" text-anchor="middle" fill="white" font-family="Arial" font-size="24">👤</text>
|
||||
|
||||
<!-- Name -->
|
||||
<rect x="150" y="160" width="100" height="20" rx="4" fill="#1F2937"/>
|
||||
|
||||
<!-- Links -->
|
||||
<rect x="100" y="200" width="200" height="40" rx="20" fill="#3983F6"/>
|
||||
<text x="200" y="226" text-anchor="middle" fill="white" font-family="Arial" font-size="14">Meu Portfólio</text>
|
||||
|
||||
<rect x="100" y="250" width="200" height="40" rx="20" fill="#10B981"/>
|
||||
<text x="200" y="276" text-anchor="middle" fill="white" font-family="Arial" font-size="14">Meus Serviços</text>
|
||||
|
||||
<rect x="100" y="300" width="200" height="40" rx="20" fill="#F59E0B"/>
|
||||
<text x="200" y="326" text-anchor="middle" fill="white" font-family="Arial" font-size="14">Contato</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
274
src/BCards.Web/wwwroot/js/site.js
Normal file
274
src/BCards.Web/wwwroot/js/site.js
Normal file
@ -0,0 +1,274 @@
|
||||
// Site-wide JavaScript functionality
|
||||
|
||||
// Utility functions
|
||||
function showLoading(element) {
|
||||
element.classList.add('loading');
|
||||
element.disabled = true;
|
||||
}
|
||||
|
||||
function hideLoading(element) {
|
||||
element.classList.remove('loading');
|
||||
element.disabled = false;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
||||
toast.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
toast.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Auto remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Slug generation and validation
|
||||
function generateSlug(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
|
||||
.replace(/[\s-]+/g, '-') // Replace spaces and multiple hyphens
|
||||
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
||||
}
|
||||
|
||||
// Form validation helpers
|
||||
function validateEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
function validateUrl(url) {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Bootstrap tooltips and popovers
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize tooltips
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
|
||||
// Initialize popovers
|
||||
const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
|
||||
popoverTriggerList.map(function (popoverTriggerEl) {
|
||||
return new bootstrap.Popover(popoverTriggerEl);
|
||||
});
|
||||
|
||||
// Auto-hide alerts after 5 seconds
|
||||
const alerts = document.querySelectorAll('.alert:not(.alert-permanent)');
|
||||
alerts.forEach(alert => {
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
|
||||
// Smooth scrolling for anchor links
|
||||
document.addEventListener('click', function(e) {
|
||||
const link = e.target.closest('a[href^="#"]');
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
const targetId = link.getAttribute('href');
|
||||
const targetElement = document.querySelector(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Form submission helpers
|
||||
function submitFormWithLoading(form, button) {
|
||||
showLoading(button);
|
||||
|
||||
// Enable form submission
|
||||
form.submit();
|
||||
|
||||
// Keep loading state until page reload
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy to clipboard helper
|
||||
function copyToClipboard(text, successMessage = 'Copiado!') {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(successMessage, 'success');
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showToast(successMessage, 'success');
|
||||
} catch (error) {
|
||||
showToast('Erro ao copiar', 'error');
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
|
||||
// Analytics tracking (if enabled)
|
||||
function trackEvent(category, action, label = null, value = null) {
|
||||
if (typeof gtag !== 'undefined') {
|
||||
gtag('event', action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Image lazy loading for older browsers
|
||||
if ('IntersectionObserver' in window) {
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
if (img.dataset.src) {
|
||||
img.src = img.dataset.src;
|
||||
img.removeAttribute('data-src');
|
||||
observer.unobserve(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const lazyImages = document.querySelectorAll('img[data-src]');
|
||||
lazyImages.forEach(img => imageObserver.observe(img));
|
||||
});
|
||||
}
|
||||
|
||||
// Service Worker registration (for PWA support) - Commented out until sw.js is created
|
||||
/*
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('SW registered: ', registration);
|
||||
})
|
||||
.catch(registrationError => {
|
||||
console.log('SW registration failed: ', registrationError);
|
||||
});
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
// Dark mode toggle (if implemented)
|
||||
function toggleDarkMode() {
|
||||
const body = document.body;
|
||||
const isDark = body.classList.contains('dark-mode');
|
||||
|
||||
if (isDark) {
|
||||
body.classList.remove('dark-mode');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
body.classList.add('dark-mode');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme from localStorage
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent form double submission
|
||||
document.addEventListener('submit', function(e) {
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"], input[type="submit"]');
|
||||
|
||||
if (submitButton && !submitButton.disabled) {
|
||||
setTimeout(() => {
|
||||
showLoading(submitButton);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-resize textareas
|
||||
document.addEventListener('input', function(e) {
|
||||
if (e.target.tagName === 'TEXTAREA' && e.target.classList.contains('auto-resize')) {
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = (e.target.scrollHeight) + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced form validation
|
||||
function validateForm(formElement) {
|
||||
const inputs = formElement.querySelectorAll('input[required], textarea[required], select[required]');
|
||||
let isValid = true;
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!input.value.trim()) {
|
||||
input.classList.add('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
input.classList.remove('is-invalid');
|
||||
input.classList.add('is-valid');
|
||||
}
|
||||
|
||||
// Specific validations
|
||||
if (input.type === 'email' && input.value && !validateEmail(input.value)) {
|
||||
input.classList.add('is-invalid');
|
||||
input.classList.remove('is-valid');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (input.type === 'url' && input.value && !validateUrl(input.value)) {
|
||||
input.classList.add('is-invalid');
|
||||
input.classList.remove('is-valid');
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// Export functions for use in other scripts
|
||||
window.BCards = {
|
||||
showLoading,
|
||||
hideLoading,
|
||||
showToast,
|
||||
generateSlug,
|
||||
validateEmail,
|
||||
validateUrl,
|
||||
copyToClipboard,
|
||||
trackEvent,
|
||||
validateForm,
|
||||
submitFormWithLoading
|
||||
};
|
||||
12068
src/BCards.Web/wwwroot/lib/bootstrap/css/bootstrap.css
vendored
Normal file
12068
src/BCards.Web/wwwroot/lib/bootstrap/css/bootstrap.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
6
src/BCards.Web/wwwroot/lib/bootstrap/css/bootstrap.min.css
vendored
Normal file
6
src/BCards.Web/wwwroot/lib/bootstrap/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
6314
src/BCards.Web/wwwroot/lib/bootstrap/js/bootstrap.bundle.js
vendored
Normal file
6314
src/BCards.Web/wwwroot/lib/bootstrap/js/bootstrap.bundle.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
src/BCards.Web/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js
vendored
Normal file
7
src/BCards.Web/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10716
src/BCards.Web/wwwroot/lib/jquery/jquery.js
vendored
Normal file
10716
src/BCards.Web/wwwroot/lib/jquery/jquery.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2
src/BCards.Web/wwwroot/lib/jquery/jquery.min.js
vendored
Normal file
2
src/BCards.Web/wwwroot/lib/jquery/jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
30
tests/BCards.Tests/BCards.Tests.csproj
Normal file
30
tests/BCards.Tests/BCards.Tests.csproj
Normal file
@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.6" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\BCards.Web\BCards.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Loading…
Reference in New Issue
Block a user