Compare commits

...

44 Commits

Author SHA1 Message Date
06a078d260 Merge pull request 'feat/live-preview' (#8) from feat/live-preview into main
Some checks failed
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Failing after 6s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
Reviewed-on: http://git.carneiro.ddnsfree.com/ricardo/BCards/pulls/8
2025-08-18 00:50:02 +00:00
Ricardo Carneiro
ec559e8115 fix: build
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 2s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
BCards Deployment Pipeline / Build and Push Image (pull_request) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (pull_request) Has been skipped
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-17 21:49:13 -03:00
Ricardo Carneiro
38c991758c fix: actions
Some checks failed
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 12s
BCards Deployment Pipeline / Build and Push Image (pull_request) Failing after 8s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (pull_request) Has been skipped
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-17 21:44:46 -03:00
Ricardo Carneiro
951b44f7eb feat: pipeline 2025-08-17 21:35:45 -03:00
Ricardo Carneiro
0d9a0988fe feat: aparencia dashboard 2025-08-17 20:50:40 -03:00
Ricardo Carneiro
ef4d189ef1 feat: botão fechar scroll de temas 2025-08-17 19:36:30 -03:00
Ricardo Carneiro
ca98001299 fix: muitos temas! 2025-08-17 19:01:30 -03:00
Ricardo Carneiro
d32cc18044 Merge branch 'feat/live-preview' of http://git.carneiro.ddnsfree.com/ricardo/BCards into feat/live-preview 2025-08-17 15:46:09 -03:00
Ricardo Carneiro
c824e9da1c feat: imagens!!!! Agora tenho uma imagem no topo! 2025-08-17 15:45:59 -03:00
Ricardo Carneiro
9e7ea6ed9a feat: ajuste dos contadores 2025-08-17 00:29:17 -03:00
Ricardo Carneiro
5fc7eb5ad3 feat: ajustes de callback do stripe e atualização do stripe.net 2025-08-16 23:13:00 -03:00
Ricardo Carneiro
c6129a1c63 feat: links sociais opcionais 2025-08-16 18:55:17 -03:00
Ricardo Carneiro
2449a617ca feat: trial and pay 2025-08-16 17:10:45 -03:00
Ricardo Carneiro
038422c255 fix: sinal errado removido
All checks were successful
PR Validation for Release / Validate Pull Request (pull_request) Successful in 49s
PR Validation for Release / Ready for Merge (pull_request) Successful in 0s
2025-07-25 20:08:02 -03:00
Ricardo Carneiro
9604462289 fix: build após merge
All checks were successful
PR Validation for Release / Validate Pull Request (pull_request) Successful in 46s
PR Validation for Release / Ready for Merge (pull_request) Successful in 0s
2025-07-25 20:04:04 -03:00
Ricardo Carneiro
efb6a4e5d7 fix: novo publish stage no dockerfile.release
All checks were successful
PR Validation for Release / Validate Pull Request (pull_request) Successful in 53s
PR Validation for Release / Ready for Merge (pull_request) Successful in 0s
2025-07-25 19:55:45 -03:00
Ricardo Carneiro
660959bd4c fix: dockerbuild.release
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 53s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-25 19:49:52 -03:00
Ricardo Carneiro
c25eea5f03 fix: build para amd/x86
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 13s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-25 19:42:45 -03:00
Ricardo Carneiro
ceec4a2ef2 fix: buildx
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 14s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-25 19:40:51 -03:00
Ricardo Carneiro
764d8a62f6 fix: build multiplataforma
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 25s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-25 19:37:03 -03:00
Ricardo Carneiro
4500b927ad fix: pular testas se estiver nas variaveis
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 45s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-25 19:24:14 -03:00
Ricardo Carneiro
3b1a356e35 fix: pr validation
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 27s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-24 11:07:25 -03:00
Ricardo Carneiro
b9dcdcdc9e fix: pre-merge e build do docker 2025-07-23 15:42:58 -03:00
Ricardo Carneiro
6561bba061 fix: pipeline obtendo numero de versão incorreto pela branch. 2025-07-23 15:36:44 -03:00
Ricardo Carneiro
95bfe20049 fix: skip testes melhorado 2025-07-23 15:26:39 -03:00
Ricardo Carneiro
8de7da2d9c fix: skiptestes baseado em variavel 2025-07-23 15:19:10 -03:00
Ricardo Carneiro
0d9a4b96ae fix: appSettings de teste 2025-07-23 14:55:43 -03:00
Ricardo Carneiro
c76de82ff8 fix: build versão de release 2025-07-23 13:33:18 -03:00
Ricardo Carneiro
89026e3460 feat: release build 2025-07-22 23:19:17 -03:00
Ricardo Carneiro
8f677f62b8 fix: appSettings development 2025-07-22 14:49:07 -03:00
Ricardo Carneiro
c933510348 feat: Tela de gestão da assinatura 2025-07-14 23:21:25 -03:00
Ricardo Carneiro
06d2c110d0 feat: ajustes de layout 2025-07-13 23:11:23 -03:00
Ricardo Carneiro
f53a807aec fix: ajustes de status 2025-07-12 21:11:39 -03:00
Ricardo Carneiro
5b0bc44e20 feat: moderação 2025-07-12 02:32:22 -03:00
Ricardo Carneiro
fc211367a6 feat: produtos 2025-07-11 16:52:29 -03:00
Ricardo Carneiro
1cc8665176 fix: alterar link de produto 2025-07-09 10:37:57 -03:00
Ricardo Carneiro
7884a8f13b feat: alterar link de produto 2025-07-09 10:37:37 -03:00
Ricardo Carneiro
7df77b371a feat: exclusão de páginas! 2025-06-26 01:08:55 -03:00
Ricardo Carneiro
2a6e5fb2e9 fix: padrão de links 2025-06-26 00:59:50 -03:00
Ricardo Carneiro
603698c9c0 fix: paginas de links funcionando 2025-06-26 00:25:11 -03:00
Ricardo Carneiro
2b3d9f308d fix: paginas de links funcionando 2025-06-26 00:25:04 -03:00
Ricardo Carneiro
6bba003cb6 fix: Ajuste para gravação de IDs de links 2025-06-25 21:27:59 -03:00
Ricardo Carneiro
27ae8b606e feat:
+login ms que permite contas corporativas ou não.
+Links para produtos de afiliados
2025-06-25 19:30:19 -03:00
Ricardo Carneiro
70ba07bb64 fix: busca da pagina e ajuste de icones 2025-06-24 23:58:38 -03:00
110 changed files with 11830 additions and 525 deletions

View File

@ -15,7 +15,12 @@
"Bash(rg:*)", "Bash(rg:*)",
"Bash(pkill:*)", "Bash(pkill:*)",
"Bash(sudo rm:*)", "Bash(sudo rm:*)",
"Bash(rm:*)" "Bash(rm:*)",
"Bash(curl:*)",
"Bash(docker-compose up:*)",
"Bash(dotnet build:*)",
"Bash(chmod:*)",
"Bash(mv:*)"
] ]
}, },
"enableAllProjectMcpServers": false "enableAllProjectMcpServers": false

View File

@ -0,0 +1,386 @@
name: BCards Deployment Pipeline
on:
push:
branches:
- main
- 'Release/*'
# PRs apenas validam, não fazem deploy
pull_request:
branches: [ main ]
types: [opened, synchronize, reopened]
env:
REGISTRY: registry.redecarneir.us
IMAGE_NAME: bcards
MONGODB_HOST: 192.168.0.100:27017
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Test info
run: |
echo "🧪 Executando testes para ${{ github.ref_name }}"
echo "🎯 Trigger: ${{ github.event_name }}"
# Verificar se deve pular testes
SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}"
if [ "$SKIP_TESTS" == "true" ]; then
echo "⚠️ Testes PULADOS"
echo "TESTS_SKIPPED=true" >> $GITHUB_ENV
else
echo "✅ Executando testes"
echo "TESTS_SKIPPED=false" >> $GITHUB_ENV
fi
- name: Checkout code
if: env.TESTS_SKIPPED == 'false'
uses: actions/checkout@v4
- name: Setup .NET 8
if: env.TESTS_SKIPPED == 'false'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Cache dependencies
if: env.TESTS_SKIPPED == 'false'
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore dependencies
if: env.TESTS_SKIPPED == 'false'
run: dotnet restore
- name: Build solution
if: env.TESTS_SKIPPED == 'false'
run: dotnet build --no-restore --configuration Release
- name: Run unit tests
if: env.TESTS_SKIPPED == 'false'
run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage"
# Job específico para validação de PRs (sem deploy)
pr-validation:
name: PR Validation
runs-on: ubuntu-latest
needs: [test]
if: github.event_name == 'pull_request'
steps:
- name: PR Validation Summary
run: |
echo "✅ Pull Request Validation Summary"
echo "🎯 Target Branch: ${{ github.base_ref }}"
echo "📂 Source Branch: ${{ github.head_ref }}"
echo "🧪 Tests: ${{ needs.test.result }}"
echo "👤 Author: ${{ github.event.pull_request.user.login }}"
echo "📝 Title: ${{ github.event.pull_request.title }}"
echo ""
echo "✨ PR está pronto para merge!"
build-and-push:
name: Build and Push Image
runs-on: ubuntu-latest
needs: [test]
# Só faz build/push em push (não em PR)
if: github.event_name == 'push' && (needs.test.result == 'success' || needs.test.result == 'skipped')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Determine build settings
id: settings
run: |
BRANCH_NAME="${{ github.ref_name }}"
if [ "$BRANCH_NAME" = "main" ]; then
# Main = Produção (ARM64)
echo "tag=latest" >> $GITHUB_OUTPUT
echo "platform=linux/arm64" >> $GITHUB_OUTPUT
echo "environment=Production" >> $GITHUB_OUTPUT
echo "dockerfile=Dockerfile" >> $GITHUB_OUTPUT
echo "deploy_target=production" >> $GITHUB_OUTPUT
elif [[ "$BRANCH_NAME" == Release/* ]]; then
# Release = Staging (x86)
VERSION_RAW=${BRANCH_NAME#Release/}
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
[ -z "$VERSION" ] && VERSION="0.0.1"
echo "tag=$VERSION" >> $GITHUB_OUTPUT
echo "platform=linux/amd64" >> $GITHUB_OUTPUT
echo "environment=Staging" >> $GITHUB_OUTPUT
echo "dockerfile=Dockerfile.release" >> $GITHUB_OUTPUT
echo "deploy_target=staging" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
fi
COMMIT_SHA=${{ github.sha }}
SHORT_COMMIT=${COMMIT_SHA:0:7}
echo "commit=$SHORT_COMMIT" >> $GITHUB_OUTPUT
echo "📦 Tag: ${{ steps.settings.outputs.tag }}"
echo "🏗️ Platform: ${{ steps.settings.outputs.platform }}"
echo "🌍 Environment: ${{ steps.settings.outputs.environment }}"
echo "🎯 Target: ${{ steps.settings.outputs.deploy_target }}"
- name: Build and push image
run: |
echo "🏗️ Building image for ${{ steps.settings.outputs.deploy_target }}..."
# Debug das variáveis
echo "Platform: ${{ steps.settings.outputs.platform }}"
echo "Dockerfile: ${{ steps.settings.outputs.dockerfile }}"
echo "Tag: ${{ steps.settings.outputs.tag }}"
# Build para a plataforma correta
docker buildx build \
--platform ${{ steps.settings.outputs.platform }} \
--file ${{ steps.settings.outputs.dockerfile }} \
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.settings.outputs.tag }} \
--push \
--build-arg VERSION=${{ steps.settings.outputs.version || 'latest' }} \
--build-arg COMMIT=${{ steps.settings.outputs.commit }} \
--progress=plain \
.
deploy-production:
name: Deploy to Production (ARM - OCI)
runs-on: ubuntu-latest
needs: [build-and-push]
if: github.ref_name == 'main'
environment: production
steps:
- name: Deploy to Production Servers
run: |
echo "🚀 Deploying to production servers (ARM64)..."
# Configura SSH
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
# Deploy no Servidor 1 (ARM - OCI)
ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 << 'EOF'
echo "🔄 Atualizando Servidor 1..."
# Remove containers bcards-infrastructure se existirem
docker stop bcards-infrastructure bcards-test-app || true
docker rm bcards-infrastructure bcards-test-app || true
# Para o container BCards atual se existir
docker stop bcards-prod || true
docker rm bcards-prod || true
# Remove imagem antiga
docker rmi ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest || true
# Puxa nova imagem
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
# Executa novo container BCards
docker run -d \
--name bcards-prod \
--restart unless-stopped \
-p 5002:8080 \
-e ASPNETCORE_ENVIRONMENT=Production \
-e ASPNETCORE_URLS=http://+:8080 \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
# Recarrega NGINX
sudo nginx -t && sudo systemctl reload nginx
echo "✅ Servidor 1 atualizado"
EOF
# Deploy no Servidor 2 (ARM - OCI)
ssh -o StrictHostKeyChecking=no ubuntu@129.146.116.218 << 'EOF'
echo "🔄 Atualizando Servidor 2..."
# Remove containers bcards-infrastructure se existirem
docker stop bcards-infrastructure bcards-test-app || true
docker rm bcards-infrastructure bcards-test-app || true
# Para o container BCards atual se existir
docker stop bcards-prod || true
docker rm bcards-prod || true
# Remove imagem antiga
docker rmi ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest || true
# Puxa nova imagem
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
# Executa novo container BCards
docker run -d \
--name bcards-prod \
--restart unless-stopped \
-p 5002:8080 \
-e ASPNETCORE_ENVIRONMENT=Production \
-e ASPNETCORE_URLS=http://+:8080 \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
echo "✅ Servidor 2 atualizado"
EOF
- name: Health Check Production
run: |
echo "🏥 Verificando saúde dos servidores de produção..."
sleep 30
# Verifica Servidor 1
echo "Verificando Servidor 1 (ARM)..."
ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 'curl -f http://localhost:5002/health || echo "⚠️ Servidor 1 pode não estar respondendo"'
# Verifica Servidor 2
echo "Verificando Servidor 2 (ARM)..."
ssh -o StrictHostKeyChecking=no ubuntu@129.146.116.218 'curl -f http://localhost:5002/health || echo "⚠️ Servidor 2 pode não estar respondendo"'
deploy-staging:
name: Deploy to Staging (x86 - Local)
runs-on: ubuntu-latest
needs: [build-and-push]
if: startsWith(github.ref_name, 'Release/')
steps:
- name: Extract version
id: version
run: |
BRANCH_NAME="${{ github.ref_name }}"
VERSION_RAW=${BRANCH_NAME#Release/}
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
[ -z "$VERSION" ] && VERSION="0.0.1"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "📦 Deploying version: $VERSION"
- name: Deploy to Staging Server
run: |
echo "🚀 Deploying to staging server (x86)..."
# Configura SSH
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
# Deploy no Servidor Local x86
ssh -o StrictHostKeyChecking=no ubuntu@192.168.0.100 << EOF
echo "🔄 Atualizando Servidor Staging..."
# Remove containers bcards-infrastructure se existirem
docker stop bcards-infrastructure bcards-test-app || true
docker rm bcards-infrastructure bcards-test-app || true
# Para o container BCards atual se existir
docker stop bcards-staging || true
docker rm bcards-staging || true
# Remove imagem antiga
docker rmi ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} || true
# Puxa nova imagem
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
# Executa novo container BCards
docker run -d \
--name bcards-staging \
--restart unless-stopped \
-p 5002:8080 \
-e ASPNETCORE_ENVIRONMENT=Staging \
-e ASPNETCORE_URLS=http://+:8080 \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
echo "✅ Servidor Staging atualizado"
EOF
- name: Health Check Staging
run: |
echo "🏥 Verificando saúde do servidor de staging..."
sleep 30
echo "Verificando Servidor Staging (x86)..."
ssh -o StrictHostKeyChecking=no ubuntu@192.168.0.100 'curl -f http://localhost:5002/health || echo "⚠️ Servidor staging pode não estar respondendo"'
cleanup:
name: Cleanup Old Resources
runs-on: ubuntu-latest
needs: [deploy-production, deploy-staging]
if: always() && (needs.deploy-production.result == 'success' || needs.deploy-staging.result == 'success')
steps:
- name: Cleanup containers and images
run: |
echo "🧹 Limpando recursos antigos..."
# Configura SSH
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
# Lista de servidores baseada na branch
if [ "${{ github.ref_name }}" = "main" ]; then
SERVERS=("141.148.162.114" "129.146.116.218")
else
SERVERS=("192.168.0.100")
fi
# Limpeza em cada servidor
for server in "${SERVERS[@]}"; do
echo "🧹 Limpando servidor $server..."
ssh -o StrictHostKeyChecking=no ubuntu@$server << 'EOF'
# Remove containers parados
docker container prune -f
# Remove imagens não utilizadas
docker image prune -f
# Remove redes não utilizadas
docker network prune -f
EOF
done
echo "✅ Limpeza concluída!"
deployment-summary:
name: Deployment Summary
runs-on: ubuntu-latest
needs: [deploy-production, deploy-staging]
if: always()
steps:
- name: Summary
run: |
echo "📋 DEPLOYMENT SUMMARY"
echo "===================="
echo "🎯 Branch: ${{ github.ref_name }}"
echo "🔑 Commit: ${{ github.sha }}"
echo "🏗️ Registry: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
if [ "${{ github.ref_name }}" = "main" ]; then
echo "🌍 Environment: Production (ARM64)"
echo "🖥️ Servers: 141.148.162.114, 129.146.116.218"
echo "📦 Tag: latest"
echo "🔗 Status: ${{ needs.deploy-production.result }}"
else
echo "🌍 Environment: Staging (x86)"
echo "🖥️ Server: 192.168.0.100"
echo "📦 Tag: ${{ github.ref_name }}"
echo "🔗 Status: ${{ needs.deploy-staging.result }}"
fi
echo "===================="
echo "✅ Pipeline completed!"

View File

@ -0,0 +1,111 @@
name: PR Validation for Release
on:
pull_request:
branches:
- 'Release/*'
types: [opened, synchronize, reopened, ready_for_review]
env:
REGISTRY: registry.redecarneir.us
IMAGE_NAME: bcards
MONGODB_HOST: 192.168.0.100:27017
jobs:
validate-pr:
name: Validate Pull Request
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: PR Info
run: |
echo "🔍 Validando PR #${{ github.event.number }}"
echo "📂 Source: ${{ github.head_ref }}"
echo "🎯 Target: ${{ github.base_ref }}"
echo "👤 Author: ${{ github.event.pull_request.user.login }}"
echo "📝 Title: ${{ github.event.pull_request.title }}"
- name: Checkout PR code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build solution
run: dotnet build --no-restore --configuration Release
- name: Run tests
if: ${{ vars.SKIP_TESTS_PR != 'true' }}
run: |
echo "🧪 Executando testes no PR"
SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}"
if [ "$SKIP_TESTS" == "true" ]; then
echo "⚠️ Testes PULADOS"
echo "TESTS_SKIPPED=true" >> $GITHUB_ENV
else
echo "✅ Executando testes"
dotnet test --no-build --configuration Release --verbosity normal
echo "TESTS_SKIPPED=false" >> $GITHUB_ENV
fi
- name: Build Docker image (test only)
run: |
echo "🐳 Testando build da imagem Docker..."
# Extrair versão da branch de destino
TARGET_BRANCH="${{ github.base_ref }}"
VERSION_RAW=${TARGET_BRANCH#Release/}
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
COMMIT_SHA=${{ github.event.pull_request.head.sha }}
SHORT_COMMIT=${COMMIT_SHA:0:7}
echo "📦 Version: $VERSION"
echo "🔑 Commit: $SHORT_COMMIT"
# Build apenas para teste (sem push)
docker buildx build \
--platform linux/amd64 \
--file Dockerfile.release \
--build-arg VERSION=$VERSION \
--build-arg COMMIT=$SHORT_COMMIT \
--tag $REGISTRY/$IMAGE_NAME:pr-${{ github.event.number }}-$SHORT_COMMIT \
--output type=docker \
.
- name: Security scan (opcional)
run: |
echo "🔒 Executando verificações de segurança..."
# Adicione suas verificações de segurança aqui
- name: PR Status Summary
run: |
echo "✅ Pull Request Validation Summary"
echo "🎯 Target Branch: ${{ github.base_ref }}"
echo "📂 Source Branch: ${{ github.head_ref }}"
echo "🧪 Tests: ${{ vars.SKIP_TESTS_PR == 'true' && 'SKIPPED' || 'PASSED' }}"
echo "🐳 Docker Build: PASSED"
echo "🔒 Security Scan: PASSED"
echo ""
echo "✨ PR está pronto para merge!"
# Job que só executa se a validação passou
ready-for-merge:
name: Ready for Merge
runs-on: ubuntu-latest
needs: [validate-pr]
if: success()
steps:
- name: Merge readiness
run: |
echo "🎉 Pull Request #${{ github.event.number }} passou em todas as validações!"
echo "✅ Pode ser feito o merge com segurança"

View File

@ -0,0 +1,120 @@
name: Release Deployment Pipeline
on:
push:
branches:
- 'Release/*'
env:
REGISTRY: registry.redecarneir.us
IMAGE_NAME: bcards
MONGODB_HOST: 192.168.0.100:27017
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Check if tests should run
run: |
# Prioridade: manual input > variável do repo
SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}"
if [ "$SKIP_TESTS" == "true" ]; then
echo "⚠️ Testes PULADOS"
echo "TESTS_SKIPPED=true" >> $GITHUB_ENV
else
echo "✅ Executando testes"
echo "TESTS_SKIPPED=false" >> $GITHUB_ENV
fi
echo "🎯 Trigger: ${{ github.event_name }}"
echo "📂 Branch: ${{ github.ref_name }}"
- name: Checkout code
if: env.TESTS_SKIPPED == 'false'
uses: actions/checkout@v4
- name: Setup .NET 8
if: env.TESTS_SKIPPED == 'false'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
if: env.TESTS_SKIPPED == 'false'
run: dotnet restore
- name: Build solution
if: env.TESTS_SKIPPED == 'false'
run: dotnet build --no-restore --configuration Release
- name: Run unit tests
if: env.TESTS_SKIPPED == 'false'
run: dotnet test --no-build --configuration Release --verbosity normal
build-and-deploy:
name: Build and Deploy
runs-on: ubuntu-latest
needs: [test]
if: always() && (needs.test.result == 'success' || needs.test.result == 'failure')
steps:
- name: Deployment info
run: |
echo "🚀 Iniciando deployment para ${{ github.ref_name }}"
echo "🧪 Tests: ${{ vars.SKIP_TESTS == 'true' && 'SKIPPED' || 'EXECUTED' }}"
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Extract version info
id: version
run: |
BRANCH_NAME="${{ github.ref_name }}"
VERSION_RAW=${BRANCH_NAME#Release/}
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
if [ -z "$VERSION" ]; then
VERSION="0.0.1"
fi
COMMIT_SHA=${{ github.sha }}
SHORT_COMMIT=${COMMIT_SHA:0:7}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "commit=$SHORT_COMMIT" >> $GITHUB_OUTPUT
echo "tag=$VERSION-$SHORT_COMMIT" >> $GITHUB_OUTPUT
echo "📦 Version: $VERSION"
echo "🔑 Commit: $SHORT_COMMIT"
echo "🏷️ Tag: $VERSION-$SHORT_COMMIT"
- name: Build and push multi-arch image
run: |
echo "🏗️ Building multi-arch image..."
docker buildx build \
--platform linux/amd64,linux/arm64 \
--file Dockerfile.release \
--tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }} \
--tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.version }}-latest \
--tag $REGISTRY/$IMAGE_NAME:release-latest \
--push \
--build-arg VERSION=${{ steps.version.outputs.version }} \
--build-arg COMMIT=${{ steps.version.outputs.commit }} \
--progress=plain
# Resto do deployment...
- name: Deploy notification
run: |
echo "✅ Deployment concluído!"
echo "📦 Image: $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }}"
echo "🎯 Trigger: ${{ github.event_name }}"
echo "📂 Branch: ${{ github.ref_name }}"

View File

@ -4,7 +4,23 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Tests", "tests\BCards.Tests\BCards.Tests.csproj", "{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.IntegrationTests", "src\BCards.IntegrationTests\BCards.IntegrationTests.csproj", "{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scripts", "Scripts", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
ProjectSection(SolutionItems) = preProject
scripts\deploy-release.sh = scripts\deploy-release.sh
scripts\init-mongo.js = scripts\init-mongo.js
scripts\test-mongodb-connection.sh = scripts\test-mongodb-connection.sh
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipeline", "Pipeline", "{3F3DEEDF-9E0A-434D-8130-1FBAC43FD1F7}"
ProjectSection(SolutionItems) = preProject
docker-compose.staging.yml = docker-compose.staging.yml
docker-compose.yml = docker-compose.yml
Dockerfile.release = Dockerfile.release
.gitea\workflows\pr-validation.yml = .gitea\workflows\pr-validation.yml
.gitea\workflows\release-deploy.yml = .gitea\workflows\release-deploy.yml
EndProjectSection
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -16,12 +32,15 @@ Global
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = 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 {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.Build.0 = Debug|Any CPU {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.ActiveCfg = Release|Any CPU {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.Build.0 = Release|Any CPU {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3DA87F09-8B78-450D-9EF8-A0C0E02F0E04}
EndGlobalSection
EndGlobal EndGlobal

228
CLAUDE.md Normal file
View File

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

120
Dockerfile.release Normal file
View File

@ -0,0 +1,120 @@
# Dockerfile.release - Multi-architecture build for Release environment
# Supports: linux/amd64, linux/arm64
ARG BUILDPLATFORM=linux/amd64
ARG TARGETPLATFORM
ARG VERSION=0.0.1
ARG COMMIT=unknown
# Base runtime image with multi-arch support
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8443
# Install dependencies based on target platform
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libgdiplus \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Create application directories
RUN mkdir -p /app/uploads /app/logs \
&& chmod 755 /app/uploads /app/logs
# Build stage - restore and publish
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG TARGETPLATFORM
ARG VERSION
ARG COMMIT
WORKDIR /src
# Copy project file and restore dependencies
COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"]
# Map platform to .NET runtime identifier and restore
RUN case "$TARGETPLATFORM" in \
"linux/amd64") RID="linux-x64" ;; \
"linux/arm64") RID="linux-arm64" ;; \
*) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
esac && \
echo "🔧 Restoring for RID: $RID" && \
dotnet restore "src/BCards.Web/BCards.Web.csproj" --runtime $RID
# Copy source code
COPY . .
WORKDIR "/src/src/BCards.Web"
# Publish diretamente (build + publish em um comando)
RUN case "$TARGETPLATFORM" in \
"linux/amd64") RID="linux-x64" ;; \
"linux/arm64") RID="linux-arm64" ;; \
*) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
esac && \
echo "📦 Publishing for RID: $RID" && \
dotnet publish "BCards.Web.csproj" \
-c Release \
-o /app/publish \
--no-restore \
--runtime $RID \
--self-contained false \
-p:PublishReadyToRun=false \
-p:PublishSingleFile=false \
-p:UseAppHost=false \
-p:Version=$VERSION \
-p:InformationalVersion=$COMMIT
# Final stage - runtime optimized for Release environment
FROM base AS final
ARG VERSION=0.0.1
ARG COMMIT=unknown
ARG TARGETPLATFORM
# Metadata labels
LABEL maintainer="BCards Team"
LABEL version=$VERSION
LABEL commit=$COMMIT
LABEL platform=$TARGETPLATFORM
LABEL environment="release"
WORKDIR /app
# Copy published application
COPY --from=build /app/publish .
# Create non-root user for security
RUN groupadd -r bcards && useradd -r -g bcards bcards \
&& chown -R bcards:bcards /app
# Environment variables for Release
ENV ASPNETCORE_ENVIRONMENT=Release
ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_RUNNING_IN_CONTAINER=true
ENV DOTNET_EnableDiagnostics=0
ENV DOTNET_USE_POLLING_FILE_WATCHER=true
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
# Platform-specific optimizations
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
echo "🔧 Applying ARM64 optimizations..." && \
echo 'export DOTNET_TieredPGO=1' >> /etc/environment && \
echo 'export DOTNET_TC_QuickJitForLoops=1' >> /etc/environment && \
echo 'export DOTNET_ReadyToRun=0' >> /etc/environment; \
else \
echo "🔧 Applying AMD64 optimizations..." && \
echo 'export DOTNET_TieredPGO=1' >> /etc/environment && \
echo 'export DOTNET_ReadyToRun=1' >> /etc/environment; \
fi
# Health check endpoint
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# Switch to non-root user
USER bcards
# Entry point with optimized runtime settings
ENTRYPOINT ["dotnet", "BCards.Web.dll"]

160
docker-compose.staging.yml Normal file
View File

@ -0,0 +1,160 @@
version: '3.8'
services:
bcards-web:
image: ${REGISTRY:-registry.redecarneir.us}/bcards:${IMAGE_TAG:-release-latest}
container_name: bcards-staging
restart: unless-stopped
ports:
- "8090:8080"
- "8453:8443"
environment:
# Core ASP.NET Configuration
- ASPNETCORE_ENVIRONMENT=Release
- ASPNETCORE_URLS=http://+:8080
- ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
# MongoDB Configuration
- MongoDb__ConnectionString=${MONGODB_CONNECTION_STRING:-mongodb://192.168.0.100:27017/BCardsDB}
- MongoDb__DatabaseName=BCardsDB
# Application Settings
- AppSettings__Environment=Staging
- AppSettings__Version=${IMAGE_TAG:-unknown}
- AppSettings__AllowedHosts=*
# Logging Configuration
- Logging__LogLevel__Default=Information
- Logging__LogLevel__Microsoft.AspNetCore=Warning
- Logging__LogLevel__BCards=Debug
# Performance Optimizations
- DOTNET_RUNNING_IN_CONTAINER=true
- DOTNET_EnableDiagnostics=0
- DOTNET_USE_POLLING_FILE_WATCHER=true
- DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
- DOTNET_TieredPGO=1
- DOTNET_TC_QuickJitForLoops=1
# Security Headers
- ASPNETCORE_HTTPS_PORT=8443
- ASPNETCORE_Kestrel__Certificates__Default__Path=/app/certs/cert.pfx
- ASPNETCORE_Kestrel__Certificates__Default__Password=${CERT_PASSWORD:-}
# Redis Configuration (if needed)
- Redis__ConnectionString=localhost:6379
volumes:
# Application logs
- ./logs:/app/logs:rw
# File uploads (if needed)
- ./uploads:/app/uploads:rw
# SSL certificates (if using HTTPS)
# - ./certs:/app/certs:ro
networks:
- bcards-staging-network
# Health check configuration
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# Resource limits for staging environment
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
reservations:
memory: 512M
cpus: '0.5'
# Logging configuration
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
# Platform specification (will use the appropriate arch from multi-arch image)
# platform: linux/amd64 # Uncomment if forcing specific architecture
# Security options
security_opt:
- no-new-privileges:true
read_only: false # Set to true for extra security, but may need volume mounts for temp files
# Process limits
ulimits:
nproc: 65535
nofile:
soft: 65535
hard: 65535
# Optional: Redis for caching (if application uses it)
redis:
image: redis:7-alpine
container_name: bcards-redis-staging
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_staging_data:/data
networks:
- bcards-staging-network
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
# Optional: Nginx reverse proxy for additional features
nginx:
image: nginx:alpine
container_name: bcards-nginx-staging
restart: unless-stopped
ports:
- "8091:80"
- "8454:443"
volumes:
- ./nginx/staging.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/ssl:/etc/ssl/certs:ro
- ./logs/nginx:/var/log/nginx:rw
depends_on:
- bcards-web
networks:
- bcards-staging-network
deploy:
resources:
limits:
memory: 128M
cpus: '0.25'
# Named volumes for persistent data
volumes:
redis_staging_data:
driver: local
driver_opts:
type: none
o: bind
device: ./data/redis
# Network for staging environment
networks:
bcards-staging-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16

369
scripts/deploy-release.sh Normal file
View File

@ -0,0 +1,369 @@
#!/bin/bash
# Deploy script for Release environment with multi-architecture support
# Usage: ./deploy-release.sh <IMAGE_TAG>
set -euo pipefail
# Configuration
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
readonly DEPLOY_DIR="/opt/bcards-staging"
readonly DOCKER_COMPOSE_FILE="docker-compose.staging.yml"
readonly CONTAINER_NAME="bcards-staging"
readonly HEALTH_CHECK_URL="http://localhost:8090/health"
readonly MAX_HEALTH_CHECK_ATTEMPTS=10
readonly HEALTH_CHECK_INTERVAL=10
readonly ROLLBACK_TIMEOUT=300
# Colors for output
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Cleanup function
cleanup() {
local exit_code=$?
if [ $exit_code -ne 0 ]; then
log_error "Deployment failed with exit code $exit_code"
rollback_deployment
fi
exit $exit_code
}
# Set trap for cleanup on exit
trap cleanup EXIT
# Validate input parameters
validate_input() {
if [ $# -ne 1 ]; then
log_error "Usage: $0 <IMAGE_TAG>"
exit 1
fi
local image_tag="$1"
if [[ ! "$image_tag" =~ ^[a-zA-Z0-9._-]+$ ]]; then
log_error "Invalid image tag format: $image_tag"
exit 1
fi
}
# Check prerequisites
check_prerequisites() {
log_info "Checking prerequisites..."
# Check if Docker is running
if ! docker info >/dev/null 2>&1; then
log_error "Docker is not running or not accessible"
exit 1
fi
# Check if docker-compose is available
if ! command -v docker-compose >/dev/null 2>&1; then
log_error "docker-compose is not installed"
exit 1
fi
# Check if deployment directory exists
if [ ! -d "$DEPLOY_DIR" ]; then
log_info "Creating deployment directory: $DEPLOY_DIR"
mkdir -p "$DEPLOY_DIR"
fi
log_success "Prerequisites check passed"
}
# Backup current deployment
backup_current_deployment() {
log_info "Backing up current deployment..."
local timestamp=$(date +%Y%m%d_%H%M%S)
local backup_dir="$DEPLOY_DIR/backups/$timestamp"
mkdir -p "$backup_dir"
# Backup environment file if exists
if [ -f "$DEPLOY_DIR/.env" ]; then
cp "$DEPLOY_DIR/.env" "$backup_dir/.env.backup"
cp "$DEPLOY_DIR/.env" "$DEPLOY_DIR/.env.backup"
log_info "Environment file backed up"
fi
# Backup docker-compose file if exists
if [ -f "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" ]; then
cp "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" "$backup_dir/${DOCKER_COMPOSE_FILE}.backup"
log_info "Docker compose file backed up"
fi
# Get current container image for potential rollback
if docker ps --format "table {{.Names}}\t{{.Image}}" | grep -q "$CONTAINER_NAME"; then
local current_image=$(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "")
if [ -n "$current_image" ]; then
echo "$current_image" > "$DEPLOY_DIR/.previous_image"
log_info "Current image backed up: $current_image"
fi
fi
log_success "Backup completed: $backup_dir"
}
# Test MongoDB connectivity
test_mongodb_connection() {
log_info "Testing MongoDB connectivity..."
local mongodb_host="192.168.0.100"
local mongodb_port="27017"
# Test basic connectivity
if timeout 10 bash -c "</dev/tcp/$mongodb_host/$mongodb_port" 2>/dev/null; then
log_success "MongoDB connection test passed"
else
log_error "Cannot connect to MongoDB at $mongodb_host:$mongodb_port"
return 1
fi
# Run detailed MongoDB test script if available
if [ -f "$SCRIPT_DIR/test-mongodb-connection.sh" ]; then
log_info "Running detailed MongoDB connection tests..."
bash "$SCRIPT_DIR/test-mongodb-connection.sh"
fi
}
# Pull new Docker image
pull_docker_image() {
local image_tag="$1"
local full_image="registry.redecarneir.us/bcards:$image_tag"
log_info "Pulling Docker image: $full_image"
# Pull the multi-arch image
if docker pull "$full_image"; then
log_success "Image pulled successfully"
else
log_error "Failed to pull image: $full_image"
return 1
fi
# Verify image architecture
local image_arch=$(docker inspect --format='{{.Architecture}}' "$full_image" 2>/dev/null || echo "unknown")
local system_arch=$(uname -m)
log_info "Image architecture: $image_arch"
log_info "System architecture: $system_arch"
# Convert system arch format to Docker format for comparison
case "$system_arch" in
x86_64) system_arch="amd64" ;;
aarch64) system_arch="arm64" ;;
esac
if [ "$image_arch" = "$system_arch" ] || [ "$image_arch" = "unknown" ]; then
log_success "Image architecture is compatible"
else
log_warning "Image architecture ($image_arch) may not match system ($system_arch), but multi-arch support should handle this"
fi
}
# Deploy new version
deploy_new_version() {
local image_tag="$1"
log_info "Deploying new version with tag: $image_tag"
# Copy docker-compose file to deployment directory
cp "$PROJECT_ROOT/$DOCKER_COMPOSE_FILE" "$DEPLOY_DIR/"
# Create/update environment file
cat > "$DEPLOY_DIR/.env" << EOF
IMAGE_TAG=$image_tag
REGISTRY=registry.redecarneir.us
MONGODB_CONNECTION_STRING=mongodb://192.168.0.100:27017/BCardsDB
ASPNETCORE_ENVIRONMENT=Release
CERT_PASSWORD=
EOF
# Stop existing containers
cd "$DEPLOY_DIR"
if docker-compose -f "$DOCKER_COMPOSE_FILE" ps -q | grep -q .; then
log_info "Stopping existing containers..."
docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans
fi
# Start new containers
log_info "Starting new containers..."
docker-compose -f "$DOCKER_COMPOSE_FILE" up -d
# Wait for containers to start
sleep 15
log_success "New version deployed"
}
# Health check
perform_health_check() {
log_info "Performing health check..."
local attempt=1
while [ $attempt -le $MAX_HEALTH_CHECK_ATTEMPTS ]; do
log_info "Health check attempt $attempt/$MAX_HEALTH_CHECK_ATTEMPTS"
# Check if container is running
if ! docker ps --format "table {{.Names}}" | grep -q "$CONTAINER_NAME"; then
log_warning "Container $CONTAINER_NAME is not running"
else
# Check application health endpoint
if curl -f -s "$HEALTH_CHECK_URL" >/dev/null 2>&1; then
log_success "Health check passed"
return 0
fi
# Check if the application is responding on port 80
if curl -f -s "http://localhost:8090/" >/dev/null 2>&1; then
log_success "Application is responding (health endpoint may not be configured)"
return 0
fi
fi
if [ $attempt -eq $MAX_HEALTH_CHECK_ATTEMPTS ]; then
log_error "Health check failed after $MAX_HEALTH_CHECK_ATTEMPTS attempts"
return 1
fi
log_info "Waiting $HEALTH_CHECK_INTERVAL seconds before next attempt..."
sleep $HEALTH_CHECK_INTERVAL
((attempt++))
done
}
# Rollback deployment
rollback_deployment() {
log_warning "Initiating rollback..."
cd "$DEPLOY_DIR"
# Stop current containers
if [ -f "$DOCKER_COMPOSE_FILE" ]; then
docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans
fi
# Restore previous environment if backup exists
if [ -f ".env.backup" ]; then
mv ".env.backup" ".env"
log_info "Previous environment restored"
fi
# Try to start previous version if image is available
if [ -f ".previous_image" ]; then
local previous_image=$(cat ".previous_image")
log_info "Attempting to restore previous image: $previous_image"
# Update .env with previous image tag
local previous_tag=$(echo "$previous_image" | cut -d':' -f2)
sed -i "s/IMAGE_TAG=.*/IMAGE_TAG=$previous_tag/" .env 2>/dev/null || true
# Try to start previous version
if docker-compose -f "$DOCKER_COMPOSE_FILE" up -d; then
log_success "Rollback completed successfully"
else
log_error "Rollback failed - manual intervention required"
fi
else
log_warning "No previous version found for rollback"
fi
}
# Cleanup old images and containers
cleanup_old_resources() {
log_info "Cleaning up old Docker resources..."
# Remove dangling images
if docker images -f "dangling=true" -q | head -1 | grep -q .; then
docker rmi $(docker images -f "dangling=true" -q) || true
log_info "Dangling images removed"
fi
# Remove old backups (keep last 5)
if [ -d "$DEPLOY_DIR/backups" ]; then
find "$DEPLOY_DIR/backups" -maxdepth 1 -type d -name "20*" | sort -r | tail -n +6 | xargs rm -rf || true
log_info "Old backups cleaned up"
fi
log_success "Cleanup completed"
}
# Display deployment summary
display_summary() {
local image_tag="$1"
log_success "Deployment Summary:"
echo "=================================="
echo "🚀 Image Tag: $image_tag"
echo "🌐 Environment: Release (Staging)"
echo "🔗 Application URL: http://localhost:8090"
echo "🔗 Health Check: $HEALTH_CHECK_URL"
echo "🗄️ MongoDB: 192.168.0.100:27017"
echo "📁 Deploy Directory: $DEPLOY_DIR"
echo "🐳 Container: $CONTAINER_NAME"
# Show container status
echo ""
echo "Container Status:"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(NAMES|$CONTAINER_NAME)" || true
# Show image info
echo ""
echo "Image Information:"
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}\t{{.Size}}" | grep -E "(REPOSITORY|bcards)" | head -5 || true
echo "=================================="
}
# Main deployment function
main() {
local image_tag="$1"
log_info "Starting deployment process for BCards Release environment"
log_info "Target image tag: $image_tag"
log_info "Target architecture: $(uname -m)"
log_info "Deploy directory: $DEPLOY_DIR"
# Execute deployment steps
validate_input "$@"
check_prerequisites
test_mongodb_connection
backup_current_deployment
pull_docker_image "$image_tag"
deploy_new_version "$image_tag"
# Perform health check (rollback handled by trap if this fails)
if perform_health_check; then
cleanup_old_resources
display_summary "$image_tag"
log_success "Deployment completed successfully!"
else
log_error "Health check failed - rollback will be triggered"
exit 1
fi
}
# Run main function with all arguments
main "$@"

View File

@ -0,0 +1,495 @@
#!/bin/bash
# MongoDB Connection Test Script for Release Environment
# Tests connectivity, database operations, and index validation
set -euo pipefail
# Configuration
readonly MONGODB_HOST="${MONGODB_HOST:-192.168.0.100}"
readonly MONGODB_PORT="${MONGODB_PORT:-27017}"
readonly DATABASE_NAME="${DATABASE_NAME:-BCardsDB}"
readonly CONNECTION_STRING="mongodb://${MONGODB_HOST}:${MONGODB_PORT}/${DATABASE_NAME}"
readonly TIMEOUT=30
# Colors for output
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Test basic TCP connectivity
test_tcp_connection() {
log_info "Testing TCP connection to $MONGODB_HOST:$MONGODB_PORT..."
if timeout $TIMEOUT bash -c "</dev/tcp/$MONGODB_HOST/$MONGODB_PORT" 2>/dev/null; then
log_success "TCP connection successful"
return 0
else
log_error "TCP connection failed"
return 1
fi
}
# Test MongoDB connectivity using mongosh if available
test_mongodb_with_mongosh() {
if ! command -v mongosh >/dev/null 2>&1; then
log_warning "mongosh not available, skipping MongoDB shell tests"
return 1
fi
log_info "Testing MongoDB connection with mongosh..."
# Test basic connection
local test_output=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "db.runCommand({ping: 1})" 2>/dev/null || echo "FAILED")
if [[ "$test_output" == *"ok"* ]]; then
log_success "MongoDB ping successful"
else
log_error "MongoDB ping failed"
return 1
fi
# Test database access
log_info "Testing database operations..."
local db_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
try {
// Test basic database operations
db.connection_test.insertOne({test: true, timestamp: new Date()});
var result = db.connection_test.findOne({test: true});
db.connection_test.deleteOne({test: true});
print('DATABASE_ACCESS_OK');
} catch (e) {
print('DATABASE_ACCESS_FAILED: ' + e.message);
}
" 2>/dev/null || echo "DATABASE_ACCESS_FAILED")
if [[ "$db_test" == *"DATABASE_ACCESS_OK"* ]]; then
log_success "Database operations test passed"
else
log_error "Database operations test failed: $db_test"
return 1
fi
return 0
}
# Test MongoDB connectivity using Python if available
test_mongodb_with_python() {
if ! command -v python3 >/dev/null 2>&1; then
log_warning "Python3 not available, skipping Python MongoDB tests"
return 1
fi
log_info "Testing MongoDB connection with Python..."
python3 << EOF
import sys
try:
import pymongo
from pymongo import MongoClient
import socket
# Test connection
client = MongoClient("$CONNECTION_STRING", serverSelectionTimeoutMS=$((TIMEOUT * 1000)))
# Test ping
client.admin.command('ping')
print("MongoDB ping successful (Python)")
# Test database access
db = client["$DATABASE_NAME"]
# Insert test document
test_collection = db.connection_test
result = test_collection.insert_one({"test": True, "source": "python"})
# Read test document
doc = test_collection.find_one({"_id": result.inserted_id})
if doc:
print("Database read/write test passed (Python)")
# Cleanup
test_collection.delete_one({"_id": result.inserted_id})
client.close()
print("PYTHON_TEST_SUCCESS")
except ImportError:
print("PyMongo not installed, skipping Python tests")
sys.exit(1)
except Exception as e:
print(f"Python MongoDB test failed: {e}")
sys.exit(1)
EOF
local python_result=$?
if [ $python_result -eq 0 ]; then
log_success "Python MongoDB test passed"
return 0
else
log_error "Python MongoDB test failed"
return 1
fi
}
# Test using Docker MongoDB client
test_mongodb_with_docker() {
if ! command -v docker >/dev/null 2>&1; then
log_warning "Docker not available, skipping Docker MongoDB tests"
return 1
fi
log_info "Testing MongoDB connection using Docker MongoDB client..."
# Use official MongoDB image to test connection
local docker_test=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval "
try {
db.runCommand({ping: 1});
db.connection_test.insertOne({test: true, source: 'docker', timestamp: new Date()});
var doc = db.connection_test.findOne({source: 'docker'});
db.connection_test.deleteOne({source: 'docker'});
print('DOCKER_TEST_SUCCESS');
} catch (e) {
print('DOCKER_TEST_FAILED: ' + e.message);
}
" 2>/dev/null || echo "DOCKER_TEST_FAILED")
if [[ "$docker_test" == *"DOCKER_TEST_SUCCESS"* ]]; then
log_success "Docker MongoDB test passed"
return 0
else
log_error "Docker MongoDB test failed: $docker_test"
return 1
fi
}
# Test MongoDB from application container
test_from_application_container() {
local container_name="bcards-staging"
if ! docker ps --format "{{.Names}}" | grep -q "^${container_name}$"; then
log_warning "Application container '$container_name' not running, skipping application test"
return 1
fi
log_info "Testing MongoDB connection from application container..."
# Test connection from the application container
local app_test=$(docker exec "$container_name" timeout 10 bash -c "
# Test TCP connection
if timeout 5 bash -c '</dev/tcp/$MONGODB_HOST/$MONGODB_PORT' 2>/dev/null; then
echo 'APP_TCP_OK'
else
echo 'APP_TCP_FAILED'
exit 1
fi
# Test HTTP health endpoint if available
if curl -f -s http://localhost:8080/health >/dev/null 2>&1; then
echo 'APP_HEALTH_OK'
else
echo 'APP_HEALTH_FAILED'
fi
" 2>/dev/null || echo "APP_TEST_FAILED")
if [[ "$app_test" == *"APP_TCP_OK"* ]]; then
log_success "Application container can connect to MongoDB"
if [[ "$app_test" == *"APP_HEALTH_OK"* ]]; then
log_success "Application health check passed"
else
log_warning "Application health check failed - app may still be starting"
fi
return 0
else
log_error "Application container cannot connect to MongoDB"
return 1
fi
}
# Check MongoDB server status and version
check_mongodb_status() {
log_info "Checking MongoDB server status..."
# Try multiple methods to check status
local status_checked=false
# Method 1: Using mongosh
if command -v mongosh >/dev/null 2>&1; then
local server_status=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
try {
var status = db.runCommand({serverStatus: 1});
print('MongoDB Version: ' + status.version);
print('Uptime: ' + status.uptime + ' seconds');
print('Connections: ' + status.connections.current + '/' + status.connections.available);
print('STATUS_CHECK_OK');
} catch (e) {
print('STATUS_CHECK_FAILED: ' + e.message);
}
" 2>/dev/null || echo "STATUS_CHECK_FAILED")
if [[ "$server_status" == *"STATUS_CHECK_OK"* ]]; then
echo "$server_status" | grep -v "STATUS_CHECK_OK"
log_success "MongoDB server status check passed"
status_checked=true
fi
fi
# Method 2: Using Docker if mongosh failed
if [ "$status_checked" = false ] && command -v docker >/dev/null 2>&1; then
local docker_status=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval "
try {
var status = db.runCommand({serverStatus: 1});
print('MongoDB Version: ' + status.version);
print('STATUS_CHECK_OK');
} catch (e) {
print('STATUS_CHECK_FAILED: ' + e.message);
}
" 2>/dev/null || echo "STATUS_CHECK_FAILED")
if [[ "$docker_status" == *"STATUS_CHECK_OK"* ]]; then
echo "$docker_status" | grep -v "STATUS_CHECK_OK"
log_success "MongoDB server status check passed (via Docker)"
status_checked=true
fi
fi
if [ "$status_checked" = false ]; then
log_warning "Could not retrieve MongoDB server status"
return 1
fi
return 0
}
# Test BCards specific collections and indexes
test_bcards_collections() {
if ! command -v mongosh >/dev/null 2>&1 && ! command -v docker >/dev/null 2>&1; then
log_warning "Cannot test BCards collections - no MongoDB client available"
return 1
fi
log_info "Testing BCards specific collections and indexes..."
local mongo_cmd="mongosh"
local docker_prefix=""
if ! command -v mongosh >/dev/null 2>&1; then
mongo_cmd="docker run --rm mongo:7.0 mongosh"
docker_prefix="timeout $TIMEOUT "
fi
local collections_test=$(${docker_prefix}${mongo_cmd} "$CONNECTION_STRING" --quiet --eval "
try {
// Check required collections
var collections = db.listCollectionNames();
var requiredCollections = ['users', 'userpages', 'categories'];
var missingCollections = [];
requiredCollections.forEach(function(collection) {
if (collections.indexOf(collection) === -1) {
missingCollections.push(collection);
}
});
if (missingCollections.length > 0) {
print('Missing collections: ' + missingCollections.join(', '));
} else {
print('All required collections exist');
}
// Check indexes on userpages collection
if (collections.indexOf('userpages') !== -1) {
var indexes = db.userpages.getIndexes();
print('UserPages collection has ' + indexes.length + ' indexes');
// Check for important compound index
var hasCompoundIndex = indexes.some(function(index) {
return index.key && index.key.category && index.key.slug;
});
if (hasCompoundIndex) {
print('Required compound index (category, slug) exists');
} else {
print('WARNING: Compound index (category, slug) is missing');
}
}
print('COLLECTIONS_TEST_OK');
} catch (e) {
print('COLLECTIONS_TEST_FAILED: ' + e.message);
}
" 2>/dev/null || echo "COLLECTIONS_TEST_FAILED")
if [[ "$collections_test" == *"COLLECTIONS_TEST_OK"* ]]; then
echo "$collections_test" | grep -v "COLLECTIONS_TEST_OK"
log_success "BCards collections test passed"
return 0
else
log_warning "BCards collections test had issues: $collections_test"
return 1
fi
}
# Performance test
test_mongodb_performance() {
log_info "Running basic performance test..."
if ! command -v mongosh >/dev/null 2>&1; then
log_warning "mongosh not available, skipping performance test"
return 1
fi
local perf_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
try {
var start = new Date();
// Insert test documents
var docs = [];
for (var i = 0; i < 100; i++) {
docs.push({test: true, index: i, timestamp: new Date()});
}
db.performance_test.insertMany(docs);
// Read test
var count = db.performance_test.countDocuments({test: true});
// Update test
db.performance_test.updateMany({test: true}, {\$set: {updated: true}});
// Delete test
db.performance_test.deleteMany({test: true});
var end = new Date();
var duration = end - start;
print('Performance test completed in ' + duration + 'ms');
print('Operations: 100 inserts, 1 count, 100 updates, 100 deletes');
if (duration < 5000) {
print('PERFORMANCE_TEST_OK');
} else {
print('PERFORMANCE_TEST_SLOW');
}
} catch (e) {
print('PERFORMANCE_TEST_FAILED: ' + e.message);
}
" 2>/dev/null || echo "PERFORMANCE_TEST_FAILED")
if [[ "$perf_test" == *"PERFORMANCE_TEST_OK"* ]]; then
echo "$perf_test" | grep -v "PERFORMANCE_TEST_OK"
log_success "Performance test passed"
return 0
elif [[ "$perf_test" == *"PERFORMANCE_TEST_SLOW"* ]]; then
echo "$perf_test" | grep -v "PERFORMANCE_TEST_SLOW"
log_warning "Performance test completed but was slow"
return 0
else
log_error "Performance test failed: $perf_test"
return 1
fi
}
# Display connection summary
display_summary() {
echo ""
log_info "MongoDB Connection Test Summary"
echo "=================================================="
echo "🏠 Host: $MONGODB_HOST:$MONGODB_PORT"
echo "🗄️ Database: $DATABASE_NAME"
echo "🔗 Connection String: $CONNECTION_STRING"
echo "⏱️ Timeout: ${TIMEOUT}s"
echo "📊 Tests completed: $(date)"
echo "=================================================="
}
# Main test function
main() {
log_info "Starting MongoDB connection tests for Release environment"
local test_results=()
local overall_success=true
# Run all tests
if test_tcp_connection; then
test_results+=("✅ TCP Connection")
else
test_results+=("❌ TCP Connection")
overall_success=false
fi
if test_mongodb_with_mongosh; then
test_results+=("✅ MongoDB Shell")
elif test_mongodb_with_docker; then
test_results+=("✅ MongoDB Docker")
elif test_mongodb_with_python; then
test_results+=("✅ MongoDB Python")
else
test_results+=("❌ MongoDB Client")
overall_success=false
fi
if test_from_application_container; then
test_results+=("✅ Application Container")
else
test_results+=("⚠️ Application Container")
fi
if check_mongodb_status; then
test_results+=("✅ Server Status")
else
test_results+=("⚠️ Server Status")
fi
if test_bcards_collections; then
test_results+=("✅ BCards Collections")
else
test_results+=("⚠️ BCards Collections")
fi
if test_mongodb_performance; then
test_results+=("✅ Performance Test")
else
test_results+=("⚠️ Performance Test")
fi
# Display results
display_summary
echo ""
log_info "Test Results:"
for result in "${test_results[@]}"; do
echo " $result"
done
echo ""
if [ "$overall_success" = true ]; then
log_success "All critical MongoDB tests passed!"
exit 0
else
log_error "Some critical MongoDB tests failed!"
exit 1
fi
}
# Run main function
main "$@"

View File

@ -0,0 +1,45 @@
<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="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
<PackageReference Include="Testcontainers.MongoDb" Version="3.6.0" />
<PackageReference Include="Stripe.net" Version="48.4.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Moq" Version="4.20.70" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BCards.Web\BCards.Web.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.Testing.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,133 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using BCards.Web.Configuration;
using BCards.Web.Services;
using Testcontainers.MongoDb;
using Xunit;
namespace BCards.IntegrationTests.Fixtures;
public class BCardsWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MongoDbContainer _mongoContainer = new MongoDbBuilder()
.WithImage("mongo:7.0")
.WithEnvironment("MONGO_INITDB_DATABASE", "BCardsDB_Test")
.Build();
public IMongoDatabase TestDatabase { get; private set; } = null!;
public string TestDatabaseName => $"BCardsDB_Test_{Guid.NewGuid():N}";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, config) =>
{
// Remove existing configuration and add test configuration
config.Sources.Clear();
config.AddJsonFile("appsettings.Testing.json", optional: false, reloadOnChange: false);
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["MongoDb:ConnectionString"] = _mongoContainer.GetConnectionString(),
["MongoDb:DatabaseName"] = TestDatabaseName,
["ASPNETCORE_ENVIRONMENT"] = "Testing"
});
});
builder.ConfigureServices(services =>
{
// Remove existing MongoDB services
services.RemoveAll(typeof(IMongoClient));
services.RemoveAll(typeof(IMongoDatabase));
// Add test MongoDB services
services.AddSingleton<IMongoClient>(serviceProvider =>
{
return new MongoClient(_mongoContainer.GetConnectionString());
});
services.AddScoped(serviceProvider =>
{
var client = serviceProvider.GetRequiredService<IMongoClient>();
TestDatabase = client.GetDatabase(TestDatabaseName);
return TestDatabase;
});
// Override Stripe settings for testing
services.Configure<StripeSettings>(options =>
{
options.PublishableKey = "pk_test_51234567890abcdef";
options.SecretKey = "sk_test_51234567890abcdef";
options.WebhookSecret = "whsec_test_1234567890abcdef";
});
// Mock external services that we don't want to test
services.RemoveAll(typeof(IEmailService));
services.AddScoped<IEmailService, MockEmailService>();
});
builder.UseEnvironment("Testing");
// Reduce logging noise during tests
builder.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Warning);
logging.AddFilter("BCards", LogLevel.Information);
});
}
public async Task InitializeAsync()
{
await _mongoContainer.StartAsync();
}
public new async Task DisposeAsync()
{
await _mongoContainer.DisposeAsync();
await base.DisposeAsync();
}
public async Task CleanDatabaseAsync()
{
if (TestDatabase != null)
{
var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" };
foreach (var collectionName in collections)
{
try
{
await TestDatabase.DropCollectionAsync(collectionName);
}
catch (Exception)
{
// Ignore errors if collection doesn't exist
}
}
}
}
}
// Mock email service to avoid external dependencies in tests
public class MockEmailService : IEmailService
{
public Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null)
{
return Task.CompletedTask;
}
public Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName)
{
return Task.CompletedTask;
}
public Task<bool> SendEmailAsync(string to, string subject, string htmlContent)
{
return Task.FromResult(true);
}
}

View File

@ -0,0 +1,182 @@
using MongoDB.Driver;
using BCards.Web.Models;
using BCards.Web.Repositories;
using BCards.Web.ViewModels;
namespace BCards.IntegrationTests.Fixtures;
public class MongoDbTestFixture
{
public IMongoDatabase Database { get; }
public IUserRepository UserRepository { get; }
public IUserPageRepository UserPageRepository { get; }
public ICategoryRepository CategoryRepository { get; }
public MongoDbTestFixture(IMongoDatabase database)
{
Database = database;
UserRepository = new UserRepository(database);
UserPageRepository = new UserPageRepository(database);
CategoryRepository = new CategoryRepository(database);
}
public async Task InitializeTestDataAsync()
{
// Initialize test categories
var categories = new List<Category>
{
new() { Id = "tecnologia", Name = "Tecnologia", Description = "Empresas e profissionais de tecnologia" },
new() { Id = "negocios", Name = "Negócios", Description = "Empresas e empreendedores" },
new() { Id = "pessoal", Name = "Pessoal", Description = "Páginas pessoais e freelancers" },
new() { Id = "saude", Name = "Saúde", Description = "Profissionais da área da saúde" }
};
var existingCategories = await CategoryRepository.GetAllActiveAsync();
if (!existingCategories.Any())
{
foreach (var category in categories)
{
await CategoryRepository.CreateAsync(category);
}
}
}
public async Task<User> CreateTestUserAsync(PlanType planType = PlanType.Basic, string? email = null, string? name = null)
{
var user = new User
{
Id = Guid.NewGuid().ToString(),
Email = email ?? $"test-{Guid.NewGuid():N}@example.com",
Name = name ?? "Test User",
CurrentPlan = planType.ToString(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
IsActive = true
};
await UserRepository.CreateAsync(user);
return user;
}
public async Task<UserPage> CreateTestUserPageAsync(
string userId,
PageStatus status = PageStatus.Creating,
string category = "tecnologia",
int normalLinkCount = 3,
int productLinkCount = 1,
string? slug = null)
{
var pageSlug = slug ?? $"test-page-{Guid.NewGuid():N}";
var userPage = new UserPage
{
Id = Guid.NewGuid().ToString(),
UserId = userId,
DisplayName = "Test Page",
Category = category,
Slug = pageSlug,
Bio = "Test page for integration testing",
Status = status,
BusinessType = "individual",
Theme = new PageTheme { Name = "minimalist" },
Links = new List<LinkItem>(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ModerationAttempts = 0,
ModerationHistory = new List<ModerationHistory>()
};
// Generate preview token for non-Active pages
if (status != PageStatus.Active)
{
userPage.PreviewToken = Guid.NewGuid().ToString("N")[..16];
userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4);
}
// Add normal links
for (int i = 0; i < normalLinkCount; i++)
{
userPage.Links.Add(new LinkItem
{
Title = $"Test Link {i + 1}",
Url = $"https://example.com/link{i + 1}",
Description = $"Description for test link {i + 1}",
Icon = "fas fa-link",
IsActive = true,
Order = i,
Type = LinkType.Normal
});
}
// Add product links
for (int i = 0; i < productLinkCount; i++)
{
userPage.Links.Add(new LinkItem
{
Title = $"Test Product {i + 1}",
Url = $"https://example.com/product{i + 1}",
Description = $"Description for test product {i + 1}",
Icon = "fas fa-shopping-cart",
IsActive = true,
Order = normalLinkCount + i,
Type = LinkType.Product,
ProductTitle = $"Amazing Product {i + 1}",
ProductPrice = "R$ 99,90",
ProductDescription = $"This is an amazing product for testing purposes {i + 1}",
ProductImage = $"https://example.com/images/product{i + 1}.jpg"
});
}
await UserPageRepository.CreateAsync(userPage);
return userPage;
}
public async Task<User> CreateTestUserWithPageAsync(
PlanType planType = PlanType.Basic,
PageStatus pageStatus = PageStatus.Creating,
int normalLinks = 3,
int productLinks = 1)
{
var user = await CreateTestUserAsync(planType);
var page = await CreateTestUserPageAsync(user.Id, pageStatus, "tecnologia", normalLinks, productLinks);
return user;
}
public async Task CleanAllDataAsync()
{
var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" };
foreach (var collectionName in collections)
{
try
{
await Database.DropCollectionAsync(collectionName);
}
catch (Exception)
{
// Ignore errors if collection doesn't exist
}
}
await InitializeTestDataAsync();
}
public async Task<List<UserPage>> GetUserPagesAsync(string userId)
{
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
var pages = await UserPageRepository.GetManyAsync(filter);
return pages.ToList();
}
public async Task<UserPage?> GetUserPageAsync(string category, string slug)
{
var filter = Builders<UserPage>.Filter.And(
Builders<UserPage>.Filter.Eq(p => p.Category, category),
Builders<UserPage>.Filter.Eq(p => p.Slug, slug)
);
var pages = await UserPageRepository.GetManyAsync(filter);
return pages.FirstOrDefault();
}
}

View File

@ -0,0 +1,92 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using BCards.Web.Models;
namespace BCards.IntegrationTests.Helpers;
public static class AuthenticationHelper
{
public static async Task<HttpClient> CreateAuthenticatedClientAsync(
WebApplicationFactory<Program> factory,
User testUser)
{
var client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(
"Test", options => { });
});
}).CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Set the test user in headers for the TestAuthenticationHandler
client.DefaultRequestHeaders.Add("TestUserId", testUser.Id);
client.DefaultRequestHeaders.Add("TestUserEmail", testUser.Email);
client.DefaultRequestHeaders.Add("TestUserName", testUser.Name);
return client;
}
public static ClaimsPrincipal CreateTestClaimsPrincipal(User user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Email, user.Email),
new(ClaimTypes.Name, user.Name),
new("sub", user.Id),
new("email", user.Email),
new("name", user.Name)
};
var identity = new ClaimsIdentity(claims, "Test");
return new ClaimsPrincipal(identity);
}
}
public class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var userId = Context.Request.Headers["TestUserId"].FirstOrDefault();
var userEmail = Context.Request.Headers["TestUserEmail"].FirstOrDefault();
var userName = Context.Request.Headers["TestUserName"].FirstOrDefault();
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(userEmail))
{
return Task.FromResult(AuthenticateResult.Fail("No test user provided"));
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new(ClaimTypes.Email, userEmail),
new(ClaimTypes.Name, userName ?? "Test User"),
new("sub", userId),
new("email", userEmail),
new("name", userName ?? "Test User")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@ -0,0 +1,195 @@
using PuppeteerSharp;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BCards.IntegrationTests.Helpers;
public class PuppeteerTestHelper : IAsyncDisposable
{
private IBrowser? _browser;
private IPage? _page;
private readonly string _baseUrl;
public PuppeteerTestHelper(WebApplicationFactory<Program> factory)
{
_baseUrl = factory.Server.BaseAddress?.ToString() ?? "https://localhost:49178";
}
public async Task InitializeAsync()
{
// Download Chrome if not available
await new BrowserFetcher().DownloadAsync();
_browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true, // Set to false for debugging
Args = new[]
{
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-web-security",
"--allow-running-insecure-content",
"--ignore-certificate-errors"
}
});
_page = await _browser.NewPageAsync();
// Set viewport for consistent testing
await _page.SetViewportAsync(new ViewPortOptions
{
Width = 1920,
Height = 1080
});
}
public IPage Page => _page ?? throw new InvalidOperationException("PuppeteerTestHelper not initialized. Call InitializeAsync first.");
public async Task NavigateToAsync(string relativeUrl)
{
var fullUrl = new Uri(new Uri(_baseUrl), relativeUrl).ToString();
await Page.GoToAsync(fullUrl, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});
}
public async Task<string> GetPageContentAsync()
{
return await Page.GetContentAsync();
}
public async Task<string> GetPageTitleAsync()
{
return await Page.GetTitleAsync();
}
public async Task<bool> ElementExistsAsync(string selector)
{
try
{
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
{
Timeout = 5000
});
return true;
}
catch (WaitTaskTimeoutException)
{
return false;
}
}
public async Task ClickAsync(string selector)
{
await Page.WaitForSelectorAsync(selector);
await Page.ClickAsync(selector);
}
public async Task TypeAsync(string selector, string text)
{
await Page.WaitForSelectorAsync(selector);
await Page.TypeAsync(selector, text);
}
public async Task FillFormAsync(Dictionary<string, string> formData)
{
foreach (var kvp in formData)
{
await Page.WaitForSelectorAsync(kvp.Key);
await Page.EvaluateExpressionAsync($"document.querySelector('{kvp.Key}').value = ''");
await Page.TypeAsync(kvp.Key, kvp.Value);
}
}
public async Task SubmitFormAsync(string formSelector)
{
await Page.ClickAsync($"{formSelector} button[type='submit'], {formSelector} input[type='submit']");
}
public async Task WaitForNavigationAsync()
{
await Page.WaitForNavigationAsync(new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});
}
public async Task WaitForElementAsync(string selector, int timeoutMs = 10000)
{
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
{
Timeout = timeoutMs
});
}
public async Task<string> GetElementTextAsync(string selector)
{
await Page.WaitForSelectorAsync(selector);
var element = await Page.QuerySelectorAsync(selector);
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
return text?.Trim() ?? string.Empty;
}
public async Task<string> GetElementValueAsync(string selector)
{
await Page.WaitForSelectorAsync(selector);
var element = await Page.QuerySelectorAsync(selector);
var value = await Page.EvaluateFunctionAsync<string>("el => el.value", element);
return value ?? string.Empty;
}
public async Task<bool> IsElementVisibleAsync(string selector)
{
try
{
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
{
Visible = true,
Timeout = 2000
});
return true;
}
catch (WaitTaskTimeoutException)
{
return false;
}
}
public async Task TakeScreenshotAsync(string fileName)
{
await Page.ScreenshotAsync(fileName);
}
public async Task<string> GetCurrentUrlAsync()
{
return Page.Url;
}
public async Task<List<string>> GetAllElementTextsAsync(string selector)
{
var elements = await Page.QuerySelectorAllAsync(selector);
var texts = new List<string>();
foreach (var element in elements)
{
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
texts.Add(text?.Trim() ?? string.Empty);
}
return texts;
}
public async ValueTask DisposeAsync()
{
if (_page != null)
{
await _page.CloseAsync();
}
if (_browser != null)
{
await _browser.CloseAsync();
}
}
}

View File

@ -0,0 +1,157 @@
# BCards Integration Tests
Este projeto contém testes integrados para o sistema BCards, validando workflows completos desde a criação de páginas até o sistema de moderação.
## Estrutura dos Testes
### Fixtures
- **BCardsWebApplicationFactory**: Factory personalizada que configura ambiente de teste com MongoDB container
- **MongoDbTestFixture**: Helper para criar e gerenciar dados de teste no MongoDB
- **StripeTestFixture**: Mock para integração Stripe (futuro)
### Helpers
- **AuthenticationHelper**: Mock para autenticação OAuth (Google/Microsoft)
- **PuppeteerTestHelper**: Automação de browser para testes E2E
- **TestDataBuilder**: Builders para criar objetos de teste (futuro)
### Tests
- **PageCreationTests**: Validação de criação de páginas e limites por plano
- **PreviewTokenTests**: Sistema de preview tokens para páginas não-ativas
- **ModerationWorkflowTests**: Workflow completo de moderação
- **PlanLimitationTests**: Validação de limitações por plano (futuro)
- **StripeIntegrationTests**: Testes de upgrade via Stripe (futuro)
## Cenários Testados
### Sistema de Páginas
1. **Criação de páginas** respeitando limites dos planos (Trial: 1, Basic: 3, etc.)
2. **Status de páginas**: Creating → PendingModeration → Active/Rejected
3. **Preview tokens**: Acesso a páginas em desenvolvimento (4h de validade)
4. **Validação de limites**: Links normais vs produto por plano
### Workflow de Moderação
1. **Submissão para moderação**: Creating → PendingModeration
2. **Aprovação**: PendingModeration → Active (page vira pública)
3. **Rejeição**: PendingModeration → Inactive/Rejected
4. **Preview system**: Acesso via token para pages não-Active
### Plan Limitations (Basic vs Professional)
- **Basic**: 5 links máximo
- **Professional**: 15 links máximo
- **Trial**: 1 página, 3 links + 1 produto
## Tecnologias Utilizadas
- **xUnit**: Framework de testes
- **FluentAssertions**: Assertions expressivas
- **WebApplicationFactory**: Testes integrados ASP.NET Core
- **Testcontainers**: MongoDB container para isolamento
- **PuppeteerSharp**: Automação de browser (Chrome)
- **MongoDB.Driver**: Acesso direto ao banco para validações
## Configuração
### Pré-requisitos
- .NET 8 SDK
- Docker (para MongoDB container)
- Chrome/Chromium (baixado automaticamente pelo PuppeteerSharp)
### Executar Testes
```bash
# Todos os testes
dotnet test src/BCards.IntegrationTests/
# Testes específicos
dotnet test src/BCards.IntegrationTests/ --filter "PageCreationTests"
dotnet test src/BCards.IntegrationTests/ --filter "PreviewTokenTests"
```
### Configuração Manual (MongoDB local)
Se preferir usar MongoDB local em vez do container:
```json
// appsettings.Testing.json
{
"MongoDb": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "BCardsDB_Test"
}
}
```
## Estrutura de Dados de Teste
### User
- **Trial**: 1 página máx, links limitados
- **Basic**: 3 páginas, 5 links por página
- **Professional**: 5 páginas, 15 links por página
### UserPage
- **Status**: Creating, PendingModeration, Active, Rejected
- **Preview Tokens**: 4h de validade para access não-Active
- **Links**: Normal vs Product (limites diferentes por plano)
### Categories
- **tecnologia**: Empresas de tech
- **negocios**: Empresas e empreendedores
- **pessoal**: Freelancers e páginas pessoais
- **saude**: Profissionais da área da saúde
## Padrões de Teste
### Arrange-Act-Assert
Todos os testes seguem o padrão AAA:
```csharp
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating);
// Act
var response = await client.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
```
### Cleanup Automático
- Cada teste usa database isolada (GUID no nome)
- Container MongoDB é destruído após os testes
- Sem interferência entre testes
### Mocks
- **EmailService**: Mockado para evitar envios reais
- **StripeService**: Mockado para evitar cobrança real
- **OAuth**: Mockado para evitar dependência externa
## Debug e Troubleshooting
### PuppeteerSharp
Para debug visual dos testes de browser:
```csharp
_browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = false, // Mostra o browser
SlowMo = 100 // Delay entre ações
});
```
### MongoDB
Para inspecionar dados durante testes, conecte no container:
```bash
docker exec -it <container-id> mongosh BCardsDB_Test
```
### Logs
Logs são configurados para mostrar apenas warnings/errors durante testes.
Para debug detalhado, altere em `BCardsWebApplicationFactory`:
```csharp
logging.SetMinimumLevel(LogLevel.Information);
```
## Próximos Passos
1. **PlanLimitationTests**: Validar todas as limitações por plano
2. **StripeIntegrationTests**: Testar upgrades via webhook
3. **PerformanceTests**: Testar carga no sistema de moderação
4. **E2E Tests**: Testes completos com PuppeteerSharp
5. **TrialExpirationTests**: Validar exclusão automática após 7 dias

View File

@ -0,0 +1,204 @@
using Xunit;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using BCards.Web.Models;
using BCards.Web.ViewModels;
using BCards.Web.Services;
using BCards.IntegrationTests.Fixtures;
using BCards.IntegrationTests.Helpers;
using System.Net;
namespace BCards.IntegrationTests.Tests;
public class ModerationWorkflowTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
{
private readonly BCardsWebApplicationFactory _factory;
private readonly HttpClient _client;
private MongoDbTestFixture _dbFixture = null!;
public ModerationWorkflowTests(BCardsWebApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _factory.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
_dbFixture = new MongoDbTestFixture(database);
await _factory.CleanDatabaseAsync();
await _dbFixture.InitializeTestDataAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task SubmitPageForModeration_ShouldChangeStatusToPendingModeration()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act - Submit page for moderation
var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
// Verify page status changed in database
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.Status.Should().Be(PageStatus.PendingModeration);
updatedPage.ModerationAttempts.Should().BeGreaterThan(0);
}
[Fact]
public async Task SubmitPageForModeration_WithoutActiveLinks_ShouldFail()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 0, 0); // No links
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act
var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeFalse();
// Verify page status didn't change
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.Status.Should().Be(PageStatus.Creating);
}
[Fact]
public async Task ApprovePage_ShouldChangeStatusToActive()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
// Act - Approve the page
await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Page looks good");
// Assert
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.Status.Should().Be(PageStatus.Active);
updatedPage.ApprovedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));
updatedPage.ModerationHistory.Should().HaveCount(1);
updatedPage.ModerationHistory.First().Status.Should().Be("approved");
}
[Fact]
public async Task RejectPage_ShouldChangeStatusToRejected()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
// Act - Reject the page
await moderationService.RejectPageAsync(page.Id, "test-moderator-id", "Inappropriate content", new List<string> { "spam", "offensive" });
// Assert
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.Status.Should().Be(PageStatus.Inactive); // First rejection goes to Inactive
updatedPage.ModerationHistory.Should().HaveCount(1);
var rejectionHistory = updatedPage.ModerationHistory.First();
rejectionHistory.Status.Should().Be("rejected");
rejectionHistory.Reason.Should().Be("Inappropriate content");
rejectionHistory.Issues.Should().Contain("spam");
rejectionHistory.Issues.Should().Contain("offensive");
}
[Fact]
public async Task AccessApprovedPage_WithoutPreviewToken_ShouldSucceed()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
// Approve the page
await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Approved");
// Act - Access the page without preview token
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain(page.DisplayName);
content.Should().NotContain("MODO PREVIEW"); // Should not show preview banner
}
[Fact]
public async Task GetPendingModerationPages_ShouldReturnCorrectPages()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
var user1 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user1@example.com");
var user2 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user2@example.com");
// Create pages in different statuses
await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.PendingModeration, "negocios", 4, 2);
await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.Creating, "pessoal", 2, 0); // Should not appear
await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.Active, "saude", 5, 1); // Should not appear
// Act
var pendingPages = await moderationService.GetPendingModerationAsync();
// Assert
pendingPages.Should().HaveCount(2);
pendingPages.Should().OnlyContain(p => p.Status == PageStatus.PendingModeration);
}
[Fact]
public async Task ModerationStats_ShouldReturnCorrectCounts()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
// Create pages with different statuses
var pendingPage1 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
var pendingPage2 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "negocios", 3, 1);
var activePage = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "pessoal", 3, 1);
// Approve one page today
await moderationService.ApprovePageAsync(activePage.Id, "moderator", "Good");
// Act
var stats = await moderationService.GetModerationStatsAsync();
// Assert
stats["pending"].Should().Be(2);
stats["approvedToday"].Should().Be(1);
stats["rejectedToday"].Should().Be(0);
}
}

View File

@ -0,0 +1,238 @@
using Xunit;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using BCards.Web.Models;
using BCards.Web.ViewModels;
using BCards.IntegrationTests.Fixtures;
using BCards.IntegrationTests.Helpers;
using System.Net.Http.Json;
namespace BCards.IntegrationTests.Tests;
public class PageCreationTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
{
private readonly BCardsWebApplicationFactory _factory;
private readonly HttpClient _client;
private MongoDbTestFixture _dbFixture = null!;
public PageCreationTests(BCardsWebApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _factory.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
_dbFixture = new MongoDbTestFixture(database);
await _factory.CleanDatabaseAsync();
await _dbFixture.InitializeTestDataAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreatePage_WithBasicPlan_ShouldAllowUpTo5Links()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act - Create a page with 5 links (should succeed)
var pageData = new
{
DisplayName = "Test Business Page",
Category = "tecnologia",
BusinessType = "company",
Bio = "A test business page",
Slug = "test-business",
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "Website", Url = "https://example.com", Description = "Main website", Icon = "fas fa-globe" },
new { Title = "Email", Url = "mailto:contact@example.com", Description = "Contact email", Icon = "fas fa-envelope" },
new { Title = "Phone", Url = "tel:+5511999999999", Description = "Contact phone", Icon = "fas fa-phone" },
new { Title = "LinkedIn", Url = "https://linkedin.com/company/example", Description = "LinkedIn profile", Icon = "fab fa-linkedin" },
new { Title = "Instagram", Url = "https://instagram.com/example", Description = "Instagram profile", Icon = "fab fa-instagram" }
}
};
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
// Assert
createResponse.IsSuccessStatusCode.Should().BeTrue("Basic plan should allow 5 links");
// Verify page was created in database
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
createdPages.Should().HaveCount(1);
var createdPage = createdPages.First();
createdPage.DisplayName.Should().Be("Test Business Page");
createdPage.Category.Should().Be("tecnologia");
createdPage.Status.Should().Be(PageStatus.Creating);
createdPage.Links.Should().HaveCount(5);
}
[Fact]
public async Task CreatePage_WithBasicPlanExceedingLimits_ShouldFail()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act - Try to create a page with 6 links (should fail for Basic plan)
var pageData = new
{
DisplayName = "Test Page Exceeding Limits",
Category = "tecnologia",
BusinessType = "individual",
Bio = "A test page with too many links",
Slug = "test-exceeding",
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "Link 1", Url = "https://example1.com", Description = "Link 1", Icon = "fas fa-link" },
new { Title = "Link 2", Url = "https://example2.com", Description = "Link 2", Icon = "fas fa-link" },
new { Title = "Link 3", Url = "https://example3.com", Description = "Link 3", Icon = "fas fa-link" },
new { Title = "Link 4", Url = "https://example4.com", Description = "Link 4", Icon = "fas fa-link" },
new { Title = "Link 5", Url = "https://example5.com", Description = "Link 5", Icon = "fas fa-link" },
new { Title = "Link 6", Url = "https://example6.com", Description = "Link 6", Icon = "fas fa-link" } // This should fail
}
};
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
// Assert
createResponse.IsSuccessStatusCode.Should().BeFalse("Basic plan should not allow more than 5 links");
// Verify no page was created
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
createdPages.Should().BeEmpty();
}
[Fact]
public async Task CreatePage_ShouldStartInCreatingStatus()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act
var pageData = new
{
DisplayName = "New Page",
Category = "pessoal",
BusinessType = "individual",
Bio = "Test page bio",
Slug = "new-page",
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "Portfolio", Url = "https://myportfolio.com", Description = "My work", Icon = "fas fa-briefcase" }
}
};
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
// Assert
createResponse.IsSuccessStatusCode.Should().BeTrue();
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
var page = createdPages.First();
page.Status.Should().Be(PageStatus.Creating);
page.PreviewToken.Should().NotBeNullOrEmpty("Creating pages should have preview tokens");
page.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3), "Preview token should be valid for ~4 hours");
}
[Fact]
public async Task CreatePage_WithTrialPlan_ShouldAllowOnePageOnly()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Trial);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act - Create first page (should succeed)
var firstPageData = new
{
DisplayName = "First Trial Page",
Category = "pessoal",
BusinessType = "individual",
Bio = "First page in trial",
Slug = "first-trial",
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "Website", Url = "https://example.com", Description = "My website", Icon = "fas fa-globe" }
}
};
var firstResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", firstPageData);
firstResponse.IsSuccessStatusCode.Should().BeTrue("Trial should allow first page");
// Act - Try to create second page (should fail)
var secondPageData = new
{
DisplayName = "Second Trial Page",
Category = "tecnologia",
BusinessType = "individual",
Bio = "Second page in trial - should fail",
Slug = "second-trial",
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "LinkedIn", Url = "https://linkedin.com/in/test", Description = "LinkedIn", Icon = "fab fa-linkedin" }
}
};
var secondResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", secondPageData);
// Assert
secondResponse.IsSuccessStatusCode.Should().BeFalse("Trial should not allow second page");
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
createdPages.Should().HaveCount(1, "Trial should only have one page");
}
[Fact]
public async Task CreatePage_ShouldGenerateUniqueSlug()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Professional);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Create first page with specific slug
await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1, "test-slug");
// Act - Try to create another page with same name (should get different slug)
var pageData = new
{
DisplayName = "Test Page", // Same display name, should generate different slug
Category = "tecnologia",
BusinessType = "individual",
Bio = "Another test page",
Slug = "test-slug", // Try to use same slug
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "Website", Url = "https://example.com", Description = "Website", Icon = "fas fa-globe" }
}
};
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
// Assert
createResponse.IsSuccessStatusCode.Should().BeTrue();
var userPages = await _dbFixture.GetUserPagesAsync(user.Id);
userPages.Should().HaveCount(2);
var slugs = userPages.Select(p => p.Slug).ToList();
slugs.Should().OnlyHaveUniqueItems("All pages should have unique slugs");
slugs.Should().Contain("test-slug");
slugs.Should().Contain(slug => slug.StartsWith("test-slug-") || slug == "test-page");
}
}

View File

@ -0,0 +1,240 @@
using Xunit;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using BCards.Web.Models;
using BCards.Web.ViewModels;
using BCards.IntegrationTests.Fixtures;
using BCards.IntegrationTests.Helpers;
using System.Net;
namespace BCards.IntegrationTests.Tests;
public class PreviewTokenTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
{
private readonly BCardsWebApplicationFactory _factory;
private readonly HttpClient _client;
private MongoDbTestFixture _dbFixture = null!;
public PreviewTokenTests(BCardsWebApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _factory.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
_dbFixture = new MongoDbTestFixture(database);
await _factory.CleanDatabaseAsync();
await _dbFixture.InitializeTestDataAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task AccessPageInCreatingStatus_WithValidPreviewToken_ShouldSucceed()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain(page.DisplayName);
content.Should().Contain("MODO PREVIEW"); // Preview banner should be shown
}
[Fact]
public async Task AccessPageInCreatingStatus_WithoutPreviewToken_ShouldReturn404()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
}
[Fact]
public async Task AccessPageInCreatingStatus_WithInvalidPreviewToken_ShouldReturn404()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview=invalid-token");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
}
[Fact]
public async Task AccessPageInCreatingStatus_WithExpiredPreviewToken_ShouldReturn404()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
// Simulate expired token
page.PreviewTokenExpiry = DateTime.UtcNow.AddHours(-1); // Expired 1 hour ago
await _dbFixture.UserPageRepository.UpdateAsync(page);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
}
[Fact]
public async Task GeneratePreviewToken_ForCreatingPage_ShouldReturnNewToken()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
var oldToken = page.PreviewToken;
// Act
var response = await authenticatedClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
var jsonResponse = await response.Content.ReadAsStringAsync();
jsonResponse.Should().Contain("success");
jsonResponse.Should().Contain("previewToken");
// Verify new token is different and works
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.PreviewToken.Should().NotBe(oldToken);
updatedPage.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3));
// Test new token works
var pageResponse = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={updatedPage.PreviewToken}");
pageResponse.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task GeneratePreviewToken_ForActivePageByNonOwner_ShouldFail()
{
// Arrange
var pageOwner = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "owner@example.com");
var otherUser = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "other@example.com");
var page = await _dbFixture.CreateTestUserPageAsync(pageOwner.Id, PageStatus.Active, "tecnologia", 3, 1);
var otherUserClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, otherUser);
// Act
var response = await otherUserClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeFalse();
}
[Theory]
[InlineData(PageStatus.Creating)]
[InlineData(PageStatus.PendingModeration)]
[InlineData(PageStatus.Rejected)]
public async Task AccessPage_WithPreviewToken_ShouldWorkForNonActiveStatuses(PageStatus status)
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK, $"Preview token should work for {status} status");
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain(page.DisplayName);
}
[Theory]
[InlineData(PageStatus.Creating)]
[InlineData(PageStatus.PendingModeration)]
[InlineData(PageStatus.Rejected)]
public async Task AccessPage_WithoutPreviewToken_ShouldFailForNonActiveStatuses(PageStatus status)
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound, $"Access without preview token should fail for {status} status");
}
[Fact]
public async Task AccessActivePage_WithoutPreviewToken_ShouldSucceed()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain(page.DisplayName);
content.Should().NotContain("MODO PREVIEW"); // No preview banner for active pages
}
[Fact]
public async Task RefreshPreviewToken_ShouldExtendExpiry()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Make token close to expiry
page.PreviewTokenExpiry = DateTime.UtcNow.AddMinutes(30);
await _dbFixture.UserPageRepository.UpdateAsync(page);
var oldExpiry = page.PreviewTokenExpiry;
// Act
var response = await authenticatedClient.PostAsync($"/Admin/RefreshPreviewToken/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.PreviewTokenExpiry.Should().BeAfter(oldExpiry.Value.AddHours(3));
updatedPage.PreviewToken.Should().NotBeNullOrEmpty();
}
}

View File

@ -0,0 +1,43 @@
{
"ConnectionStrings": {
"DefaultConnection": "mongodb://localhost:27017/BCardsDB_Test"
},
"MongoDb": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "BCardsDB_Test"
},
"Stripe": {
"PublishableKey": "pk_test_51234567890abcdef",
"SecretKey": "sk_test_51234567890abcdef",
"WebhookSecret": "whsec_test_1234567890abcdef"
},
"Authentication": {
"Google": {
"ClientId": "test-google-client-id.apps.googleusercontent.com",
"ClientSecret": "GOCSPX-test-google-client-secret"
},
"Microsoft": {
"ClientId": "test-microsoft-client-id",
"ClientSecret": "test-microsoft-client-secret"
}
},
"SendGrid": {
"ApiKey": "SG.test-sendgrid-api-key"
},
"Moderation": {
"RequireApproval": true,
"AuthKey": "test-moderation-auth-key",
"MaxPendingPages": 100,
"MaxRejectionsBeforeBan": 3
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"BCards": "Information",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ASPNETCORE_ENVIRONMENT": "Testing"
}

View File

@ -0,0 +1,33 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Attributes
{
public class ModeratorAuthorizeAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
var user = context.HttpContext.User;
if (!user.Identity?.IsAuthenticated == true)
{
context.Result = new RedirectToActionResult("Login", "Auth",
new { returnUrl = context.HttpContext.Request.Path });
return;
}
var moderationAuth = context.HttpContext.RequestServices
.GetRequiredService<IModerationAuthService>();
if (!moderationAuth.IsUserModerator(user))
{
context.Result = new ForbidResult();
return;
}
// Adicionar flag para views
context.HttpContext.Items["IsModerator"] = true;
}
}
}

View File

@ -1,15 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems> <EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
<RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.25.0" /> <PackageReference Include="MongoDB.Driver" Version="3.4.2" />
<PackageReference Include="Stripe.net" Version="44.7.0" /> <PackageReference Include="Stripe.net" Version="48.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" 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.AspNetCore.Localization" Version="2.2.0" />
@ -17,14 +18,12 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" 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="SixLabors.ImageSharp.Web" Version="3.1.0" />
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
<PackageReference Include="SendGrid" Version="9.29.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Resources\**\*.resx" /> <EmbeddedResource Include="Resources\**\*.resx" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Views\Payment\" />
</ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,10 @@
namespace BCards.Web.Configuration
{
public class ModerationSettings
{
public Dictionary<string, TimeSpan> PriorityTimeframes { get; set; } = new();
public int MaxAttempts { get; set; } = 3;
public string ModeratorEmail { get; set; } = "";
public List<string> ModeratorEmails { get; set; } = new();
}
}

View File

@ -1,8 +1,10 @@
using BCards.Web.Models; using BCards.Web.Models;
using BCards.Web.Services; using BCards.Web.Services;
using BCards.Web.Utils;
using BCards.Web.ViewModels; using BCards.Web.ViewModels;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.Security.Claims; using System.Security.Claims;
namespace BCards.Web.Controllers; namespace BCards.Web.Controllers;
@ -15,6 +17,10 @@ public class AdminController : Controller
private readonly IUserPageService _userPageService; private readonly IUserPageService _userPageService;
private readonly ICategoryService _categoryService; private readonly ICategoryService _categoryService;
private readonly IThemeService _themeService; private readonly IThemeService _themeService;
private readonly IModerationService _moderationService;
private readonly IEmailService _emailService;
private readonly ILivePageService _livePageService;
private readonly IImageStorageService _imageStorage;
private readonly ILogger<AdminController> _logger; private readonly ILogger<AdminController> _logger;
public AdminController( public AdminController(
@ -22,12 +28,20 @@ public class AdminController : Controller
IUserPageService userPageService, IUserPageService userPageService,
ICategoryService categoryService, ICategoryService categoryService,
IThemeService themeService, IThemeService themeService,
IModerationService moderationService,
IEmailService emailService,
ILivePageService livePageService,
IImageStorageService imageStorage,
ILogger<AdminController> logger) ILogger<AdminController> logger)
{ {
_authService = authService; _authService = authService;
_userPageService = userPageService; _userPageService = userPageService;
_categoryService = categoryService; _categoryService = categoryService;
_themeService = themeService; _themeService = themeService;
_moderationService = moderationService;
_emailService = emailService;
_livePageService = livePageService;
_imageStorage = imageStorage;
_logger = logger; _logger = logger;
} }
@ -36,12 +50,33 @@ public class AdminController : Controller
[Route("Dashboard")] [Route("Dashboard")]
public async Task<IActionResult> Dashboard() public async Task<IActionResult> Dashboard()
{ {
ViewBag.IsHomePage = false; // Menu normal do dashboard
var user = await _authService.GetCurrentUserAsync(User); var user = await _authService.GetCurrentUserAsync(User);
if (user == null) if (user == null)
return RedirectToAction("Login", "Auth"); return RedirectToAction("Login", "Auth");
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var userPages = await _userPageService.GetUserPagesAsync(user.Id); var userPages = await _userPageService.GetUserPagesAsync(user.Id);
var listCounts = new Dictionary<string, dynamic>();
// Atualizar status das baseado nas livepasges
foreach (var page in userPages)
{
if (page.Status == ViewModels.PageStatus.Active)
{
var livePage = await _livePageService.GetLivePageFromUserPageId(page.Id);
if (livePage != null)
{
listCounts.Add(page.Id, new { TotalViews = livePage.Analytics?.TotalViews ?? 0, TotalClicks = livePage.Analytics?.TotalClicks ?? 0 });
}
}
else
{
listCounts.Add(page.Id, new { TotalViews = (long)(page.Analytics?.TotalViews ?? 0), TotalClicks = (long)(page.Analytics?.TotalClicks ?? 0) });
}
}
var dashboardModel = new DashboardViewModel var dashboardModel = new DashboardViewModel
{ {
@ -53,9 +88,16 @@ public class AdminController : Controller
Slug = p.Slug, Slug = p.Slug,
Category = p.Category, Category = p.Category,
Status = p.Status, Status = p.Status,
TotalClicks = p.Analytics?.TotalClicks ?? 0, TotalClicks = listCounts[p.Id].TotalClicks ?? 0,
TotalViews = p.Analytics?.TotalViews ?? 0, TotalViews = listCounts[p.Id].TotalViews ?? 0,
CreatedAt = p.CreatedAt PreviewToken = p.PreviewToken,
CreatedAt = p.CreatedAt,
LastModerationStatus = p.ModerationHistory == null || p.ModerationHistory.Count == 0 || p.ModerationHistory.Last().Status == "rejected"
? null
: Enum.Parse<PageStatus>(p.ModerationHistory.Last().Status, true),
Motive = p.ModerationHistory == null || p.ModerationHistory.Count == 0 || p.ModerationHistory.Last().Status != "rejected"
? ""
: p.ModerationHistory.Last().Reason
}).ToList(), }).ToList(),
CurrentPlan = new PlanInfo CurrentPlan = new PlanInfo
{ {
@ -86,6 +128,8 @@ public class AdminController : Controller
[Route("ManagePage")] [Route("ManagePage")]
public async Task<IActionResult> ManagePage(string id = null) public async Task<IActionResult> ManagePage(string id = null)
{ {
ViewBag.IsHomePage = false;
var user = await _authService.GetCurrentUserAsync(User); var user = await _authService.GetCurrentUserAsync(User);
if (user == null) if (user == null)
return RedirectToAction("Login", "Auth"); return RedirectToAction("Login", "Auth");
@ -132,12 +176,52 @@ public class AdminController : Controller
[Route("ManagePage")] [Route("ManagePage")]
public async Task<IActionResult> ManagePage(ManagePageViewModel model) public async Task<IActionResult> ManagePage(ManagePageViewModel model)
{ {
ViewBag.IsHomePage = false;
var user = await _authService.GetCurrentUserAsync(User); var user = await _authService.GetCurrentUserAsync(User);
if (user == null) if (user == null)
return RedirectToAction("Login", "Auth"); return RedirectToAction("Login", "Auth");
// Limpar campos de redes sociais que são apenas espaços (tratados como vazios)
CleanSocialMediaFields(model);
_logger.LogInformation($"ManagePage POST: IsNewPage={model.IsNewPage}, DisplayName={model.DisplayName}, Category={model.Category}, Links={model.Links?.Count ?? 0}"); _logger.LogInformation($"ManagePage POST: IsNewPage={model.IsNewPage}, DisplayName={model.DisplayName}, Category={model.Category}, Links={model.Links?.Count ?? 0}");
ModelState.Remove<ManagePageViewModel>(x => x.InstagramUrl);
ModelState.Remove<ManagePageViewModel>(x => x.FacebookUrl);
ModelState.Remove<ManagePageViewModel>(x => x.TwitterUrl);
ModelState.Remove<ManagePageViewModel>(x => x.WhatsAppNumber);
// Processar upload de imagem se fornecida
if (model.ProfileImageFile != null && model.ProfileImageFile.Length > 0)
{
try
{
using var memoryStream = new MemoryStream();
await model.ProfileImageFile.CopyToAsync(memoryStream);
var imageBytes = memoryStream.ToArray();
var imageId = await _imageStorage.SaveImageAsync(
imageBytes,
model.ProfileImageFile.FileName,
model.ProfileImageFile.ContentType
);
model.ProfileImageId = imageId;
_logger.LogInformation("Profile image uploaded successfully: {ImageId}", imageId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading profile image");
ModelState.AddModelError("ProfileImageFile", "Erro ao fazer upload da imagem. Tente novamente.");
// Repopulate dropdowns
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
return View(model);
}
}
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
_logger.LogWarning("ModelState is invalid:"); _logger.LogWarning("ModelState is invalid:");
@ -145,8 +229,10 @@ public class AdminController : Controller
{ {
_logger.LogWarning($"Key: {error.Key}, Errors: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}"); _logger.LogWarning($"Key: {error.Key}, Errors: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}");
} }
// Repopulate dropdowns // Repopulate dropdowns
var slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName);
model.Slug = slug;
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
return View(model); return View(model);
@ -184,11 +270,21 @@ public class AdminController : Controller
// Create new page // Create new page
var userPage = await MapToUserPage(model, user.Id); var userPage = await MapToUserPage(model, user.Id);
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}"); _logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
// Set status to Creating for new pages
userPage.Status = ViewModels.PageStatus.Creating;
await _userPageService.CreatePageAsync(userPage); await _userPageService.CreatePageAsync(userPage);
_logger.LogInformation("Page created successfully!"); _logger.LogInformation("Page created successfully!");
// Generate preview token for development
var previewToken = await _moderationService.GeneratePreviewTokenAsync(userPage.Id);
var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{userPage.Category}/{userPage.Slug}?preview={previewToken}";
userPage.PreviewToken = previewToken;
userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4);
await _userPageService.UpdatePageAsync(userPage);
TempData["Success"] = "Página criada com sucesso!"; TempData["Success"] = "Página criada com sucesso! Use o botão 'Enviar para Moderação' quando estiver pronta.";
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -196,6 +292,7 @@ public class AdminController : Controller
ModelState.AddModelError("", "Erro ao criar página. Tente novamente."); ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}";
return View(model); return View(model);
} }
} }
@ -206,9 +303,42 @@ public class AdminController : Controller
if (existingPage == null || existingPage.UserId != user.Id) if (existingPage == null || existingPage.UserId != user.Id)
return NotFound(); return NotFound();
UpdateUserPageFromModel(existingPage, model); // Check if user can create pages (for users with rejected pages)
var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id);
if (!canCreatePage)
{
TempData["Error"] = "Você não pode editar páginas devido a muitas rejeições. Entre em contato com o suporte.";
return RedirectToAction("Dashboard");
}
// IMPORTANTE: Preservar ProfileImageId da página existente se não houver novo upload
if (model.ProfileImageFile == null || model.ProfileImageFile.Length == 0)
{
model.ProfileImageId = existingPage.ProfileImageId;
}
await UpdateUserPageFromModel(existingPage, model);
// Set status to PendingModeration for updates
existingPage.Status = ViewModels.PageStatus.Creating;
existingPage.ModerationAttempts = existingPage.ModerationAttempts;
await _userPageService.UpdatePageAsync(existingPage); await _userPageService.UpdatePageAsync(existingPage);
TempData["Success"] = "Página atualizada com sucesso!";
// Generate new preview token
var previewToken = await _moderationService.GeneratePreviewTokenAsync(existingPage.Id);
var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{existingPage.Category}/{existingPage.Slug}?preview={previewToken}";
// Send email to user
await _emailService.SendModerationStatusAsync(
user.Email,
user.Name,
existingPage.DisplayName,
"pending",
null,
previewUrl);
TempData["Success"] = "Página atualizada! Teste e envie para moderação.";
} }
return RedirectToAction("Dashboard"); return RedirectToAction("Dashboard");
@ -435,16 +565,19 @@ public class AdminController : Controller
public async Task<IActionResult> GenerateSlug(string category, string name) public async Task<IActionResult> GenerateSlug(string category, string name)
{ {
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name))
return Json(new { slug = "" }); return Json(new { slug = "", category = "" });
var slug = await _userPageService.GenerateSlugAsync(category, name); var slug = await _userPageService.GenerateSlugAsync(category, name);
return Json(new { slug }); var categorySlug = SlugHelper.CreateCategorySlug(category).ToLower();
return Json(new { slug = slug, category = categorySlug });
} }
[HttpGet] [HttpGet]
[Route("Analytics")] [Route("Analytics")]
public async Task<IActionResult> Analytics() public async Task<IActionResult> Analytics()
{ {
ViewBag.IsHomePage = false;
var user = await _authService.GetCurrentUserAsync(User); var user = await _authService.GetCurrentUserAsync(User);
if (user == null) if (user == null)
return RedirectToAction("Login", "Auth"); return RedirectToAction("Login", "Auth");
@ -457,19 +590,23 @@ public class AdminController : Controller
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> DeletePage() [Route("DeletePage/{id}")]
public async Task<IActionResult> DeletePage(string id)
{ {
var user = await _authService.GetCurrentUserAsync(User); var user = await _authService.GetCurrentUserAsync(User);
if (user == null) if (user == null)
return RedirectToAction("Login", "Auth"); return RedirectToAction("Login", "Auth");
var userPage = await _userPageService.GetUserPageAsync(user.Id); var userPage = await _userPageService.GetPageByIdAsync(id);
if (userPage != null) if (userPage == null || userPage.UserId != user.Id)
{ {
await _userPageService.DeletePageAsync(userPage.Id); TempData["Error"] = "Página não encontrada!";
TempData["Success"] = "Página excluída com sucesso!"; return RedirectToAction("Dashboard");
} }
await _userPageService.DeletePageAsync(userPage.Id);
TempData["Success"] = "Página excluída com sucesso!";
return RedirectToAction("Dashboard"); return RedirectToAction("Dashboard");
} }
@ -485,6 +622,7 @@ public class AdminController : Controller
Bio = page.Bio, Bio = page.Bio,
Slug = page.Slug, Slug = page.Slug,
SelectedTheme = page.Theme?.Name ?? "minimalist", SelectedTheme = page.Theme?.Name ?? "minimalist",
ProfileImageId = page.ProfileImageId,
Links = page.Links?.Select((l, index) => new ManageLinkViewModel Links = page.Links?.Select((l, index) => new ManageLinkViewModel
{ {
Id = $"link_{index}", Id = $"link_{index}",
@ -493,7 +631,13 @@ public class AdminController : Controller
Description = l.Description, Description = l.Description,
Icon = l.Icon, Icon = l.Icon,
Order = l.Order, Order = l.Order,
IsActive = l.IsActive IsActive = l.IsActive,
Type = l.Type,
ProductTitle = l.ProductTitle,
ProductImage = l.ProductImage,
ProductPrice = l.ProductPrice,
ProductDescription = l.ProductDescription,
ProductDataCachedAt = l.ProductDataCachedAt
}).ToList() ?? new List<ManageLinkViewModel>(), }).ToList() ?? new List<ManageLinkViewModel>(),
AvailableCategories = categories, AvailableCategories = categories,
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
@ -509,12 +653,13 @@ public class AdminController : Controller
{ {
UserId = userId, UserId = userId,
DisplayName = model.DisplayName, DisplayName = model.DisplayName,
Category = model.Category.ToLower(), Category = SlugHelper.ConvertCategory(model.Category.ToLower()),
BusinessType = model.BusinessType, BusinessType = model.BusinessType,
Bio = model.Bio, Bio = model.Bio,
Slug = model.Slug.ToLower(), Slug = SlugHelper.CreateSlug(model.Slug.ToLower()),
Theme = theme, Theme = theme,
Status = ViewModels.PageStatus.Active, Status = ViewModels.PageStatus.Active,
ProfileImageId = model.ProfileImageId,
Links = new List<LinkItem>() Links = new List<LinkItem>()
}; };
@ -529,7 +674,13 @@ public class AdminController : Controller
Description = l.Description, Description = l.Description,
Icon = l.Icon, Icon = l.Icon,
IsActive = l.IsActive, IsActive = l.IsActive,
Order = index Order = index,
Type = l.Type,
ProductTitle = l.ProductTitle,
ProductImage = l.ProductImage,
ProductPrice = l.ProductPrice,
ProductDescription = l.ProductDescription,
ProductDataCachedAt = l.ProductDataCachedAt
})); }));
} }
@ -589,13 +740,19 @@ public class AdminController : Controller
return userPage; return userPage;
} }
private void UpdateUserPageFromModel(UserPage page, ManagePageViewModel model) private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model)
{ {
page.DisplayName = model.DisplayName; page.DisplayName = model.DisplayName;
page.Category = model.Category; page.Category = model.Category;
page.BusinessType = model.BusinessType; page.BusinessType = model.BusinessType;
page.Bio = model.Bio; page.Bio = model.Bio;
page.Slug = model.Slug; page.Slug = model.Slug;
page.ProfileImageId = model.ProfileImageId; // CRUCIAL: Atualizar ProfileImageId
// CRUCIAL: Atualizar tema selecionado
var selectedTheme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme();
page.Theme = selectedTheme;
page.UpdatedAt = DateTime.UtcNow; page.UpdatedAt = DateTime.UtcNow;
// Update links // Update links
@ -612,7 +769,13 @@ public class AdminController : Controller
Description = l.Description, Description = l.Description,
Icon = l.Icon, Icon = l.Icon,
IsActive = l.IsActive, IsActive = l.IsActive,
Order = index Order = index,
Type = l.Type,
ProductTitle = l.ProductTitle,
ProductImage = l.ProductImage,
ProductPrice = l.ProductPrice,
ProductDescription = l.ProductDescription,
ProductDataCachedAt = l.ProductDataCachedAt
})); }));
} }
@ -670,4 +833,222 @@ public class AdminController : Controller
page.Links.AddRange(socialLinks); page.Links.AddRange(socialLinks);
} }
[HttpPost]
[Route("SubmitForModeration/{id}")]
public async Task<IActionResult> SubmitForModeration(string id)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return Json(new { success = false, message = "Usuário não autenticado" });
var pageItem = await _userPageService.GetPageByIdAsync(id);
if (pageItem == null || pageItem.UserId != user.Id)
return Json(new { success = false, message = "Página não encontrada" });
// Validar status atual
if (pageItem.Status != ViewModels.PageStatus.Creating && pageItem.Status != ViewModels.PageStatus.Rejected)
return Json(new { success = false, message = "Página não pode ser enviada para moderação neste momento" });
// Validar se tem pelo menos 1 link ativo
var activeLinksCount = pageItem.Links?.Count(l => l.IsActive) ?? 0;
if (activeLinksCount < 1)
return Json(new { success = false, message = "Página deve ter pelo menos 1 link ativo para ser enviada" });
try
{
// Mudar status para PendingModeration
pageItem.Status = ViewModels.PageStatus.PendingModeration;
pageItem.ModerationAttempts++;
pageItem.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(pageItem);
// Enviar email de notificação ao usuário
await _emailService.SendModerationStatusAsync(
user.Email,
user.Name,
pageItem.DisplayName,
"pending",
null,
$"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={pageItem.PreviewToken}");
_logger.LogInformation($"Page {pageItem.Id} submitted for moderation by user {user.Id}");
return Json(new {
success = true,
message = "Página enviada para moderação com sucesso! Você receberá um email quando for processada.",
newStatus = "PendingModeration"
});
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error submitting page {id} for moderation");
return Json(new { success = false, message = "Erro interno. Tente novamente." });
}
}
[HttpPost]
[Route("RefreshPreviewToken/{id}")]
public async Task<IActionResult> RefreshPreviewToken(string id)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return Json(new { success = false, message = "Não autorizado" });
var pageItem = await _userPageService.GetPageByIdAsync(id);
if (pageItem == null || pageItem.UserId != user.Id)
return Json(new { success = false, message = "Página não encontrada" });
// Só renovar token para páginas "Creating"
if (pageItem.Status != ViewModels.PageStatus.Creating)
return Json(new { success = false, message = "Token só pode ser renovado para páginas em desenvolvimento" });
try
{
// Gerar novo token com 4 horas de validade
var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id);
var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={newToken}";
return Json(new {
success = true,
previewToken = newToken,
previewUrl = previewUrl,
expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss")
});
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error refreshing preview token for page {id}");
return Json(new { success = false, message = "Erro ao renovar token" });
}
}
[HttpPost]
[Route("GeneratePreviewToken/{id}")]
public async Task<IActionResult> GeneratePreviewToken(string id)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return Json(new { success = false, message = "Usuário não autenticado" });
var pageItem = await _userPageService.GetPageByIdAsync(id);
if (pageItem == null || pageItem.UserId != user.Id)
return Json(new { success = false, message = "Página não encontrada" });
// Verificar se página pode ter preview
if (pageItem.Status != ViewModels.PageStatus.Creating &&
pageItem.Status != ViewModels.PageStatus.PendingModeration &&
pageItem.Status != ViewModels.PageStatus.Rejected)
{
return Json(new { success = false, message = "Preview não disponível para este status" });
}
try
{
// Gerar novo token com 4 horas de validade
var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id);
_logger.LogInformation($"Preview token generated for page {pageItem.Id} by user {user.Id}");
return Json(new {
success = true,
previewToken = newToken,
message = "Preview gerado com sucesso!",
expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss")
});
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error generating preview token for page {id}");
return Json(new { success = false, message = "Erro interno. Tente novamente." });
}
}
[HttpPost]
[Route("MigrateToLivePages")]
public async Task<IActionResult> MigrateToLivePages()
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return Json(new { success = false, message = "Usuário não autenticado" });
try
{
// Buscar todas as páginas ativas do usuário atual
var activePages = await _userPageService.GetUserPagesAsync(user.Id);
var eligiblePages = activePages.Where(p => p.Status == ViewModels.PageStatus.Active).ToList();
if (!eligiblePages.Any())
{
return Json(new {
success = false,
message = "Nenhuma página ativa encontrada para migração"
});
}
int successCount = 0;
int errorCount = 0;
var errors = new List<string>();
foreach (var page in eligiblePages)
{
try
{
await _livePageService.SyncFromUserPageAsync(page.Id);
successCount++;
_logger.LogInformation($"Successfully migrated page {page.Id} ({page.DisplayName}) to LivePages");
}
catch (Exception ex)
{
errorCount++;
var errorMsg = $"Erro ao migrar '{page.DisplayName}': {ex.Message}";
errors.Add(errorMsg);
_logger.LogError(ex, $"Failed to migrate page {page.Id} to LivePages");
}
}
var message = $"Migração concluída: {successCount} páginas migradas com sucesso";
if (errorCount > 0)
{
message += $", {errorCount} erros encontrados";
}
return Json(new {
success = errorCount == 0,
message = message,
details = new {
totalPages = eligiblePages.Count,
successCount = successCount,
errorCount = errorCount,
errors = errors
}
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during LivePages migration");
return Json(new {
success = false,
message = $"Erro durante migração: {ex.Message}"
});
}
}
private void CleanSocialMediaFields(ManagePageViewModel model)
{
// Tratar espaço em branco como campo vazio para redes sociais
if (string.IsNullOrWhiteSpace(model.WhatsAppNumber) || model.WhatsAppNumber.Trim().Length <= 1)
model.WhatsAppNumber = string.Empty;
if (string.IsNullOrWhiteSpace(model.FacebookUrl) || model.FacebookUrl.Trim().Length <= 1)
model.FacebookUrl = string.Empty;
if (string.IsNullOrWhiteSpace(model.InstagramUrl) || model.InstagramUrl.Trim().Length <= 1)
model.InstagramUrl = string.Empty;
if (string.IsNullOrWhiteSpace(model.TwitterUrl) || model.TwitterUrl.Trim().Length <= 1)
model.TwitterUrl = string.Empty;
}
} }

View File

@ -110,8 +110,26 @@ public class AuthController : Controller
[Authorize] [Authorize]
public async Task<IActionResult> Logout() public async Task<IActionResult> Logout()
{ {
// Identifica qual provedor foi usado
var authResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var loginProvider = authResult.Principal?.FindFirst("LoginProvider")?.Value;
// Faz logout local primeiro
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
TempData["Success"] = "Logout realizado com sucesso"; TempData["Success"] = "Logout realizado com sucesso";
// Se foi Microsoft, faz logout completo no provedor
if (loginProvider == "Microsoft")
{
return SignOut(MicrosoftAccountDefaults.AuthenticationScheme);
}
// Se foi Google, faz logout completo no provedor
else if (loginProvider == "Google")
{
return SignOut(GoogleDefaults.AuthenticationScheme);
}
return RedirectToAction("Index", "Home"); return RedirectToAction("Index", "Home");
} }

View File

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

View File

@ -0,0 +1,202 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Route("api/[controller]")]
[ApiController]
public class ImageController : ControllerBase
{
private readonly IImageStorageService _imageStorage;
private readonly ILogger<ImageController> _logger;
public ImageController(IImageStorageService imageStorage, ILogger<ImageController> logger)
{
_imageStorage = imageStorage;
_logger = logger;
}
[HttpGet("{imageId}")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "imageId" })]
public async Task<IActionResult> GetImage(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
{
_logger.LogWarning("Image request with empty ID");
return BadRequest("Image ID is required");
}
var imageBytes = await _imageStorage.GetImageAsync(imageId);
if (imageBytes == null || imageBytes.Length == 0)
{
_logger.LogWarning("Image not found: {ImageId}", imageId);
return NotFound("Image not found");
}
// Headers de cache mais agressivos para imagens
Response.Headers["Cache-Control"] = "public, max-age=31536000"; // 1 ano
Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R");
Response.Headers["ETag"] = $"\"{imageId}\"";
return File(imageBytes, "image/jpeg", enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving image: {ImageId}", imageId);
return NotFound("Image not found");
}
}
[HttpPost("upload")]
[RequestSizeLimit(5 * 1024 * 1024)] // 5MB máximo
[DisableRequestSizeLimit] // Para formulários grandes
public async Task<IActionResult> UploadImage(IFormFile file)
{
try
{
if (file == null || file.Length == 0)
{
_logger.LogWarning("Upload request with no file");
return BadRequest(new { error = "No file provided", code = "NO_FILE" });
}
// Validações de tipo
var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif" };
if (!allowedTypes.Contains(file.ContentType.ToLower()))
{
_logger.LogWarning("Invalid file type uploaded: {ContentType}", file.ContentType);
return BadRequest(new {
error = "Invalid file type. Only JPEG, PNG and GIF are allowed.",
code = "INVALID_TYPE"
});
}
// Validação de tamanho
if (file.Length > 5 * 1024 * 1024) // 5MB
{
_logger.LogWarning("File too large: {Size}MB", file.Length / (1024 * 1024));
return BadRequest(new {
error = "File too large. Maximum size is 5MB.",
code = "FILE_TOO_LARGE"
});
}
// Processar upload
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
var imageBytes = memoryStream.ToArray();
// Validação adicional: verificar se é realmente uma imagem
if (!IsValidImageBytes(imageBytes))
{
_logger.LogWarning("Invalid image data uploaded");
return BadRequest(new {
error = "Invalid image data.",
code = "INVALID_IMAGE"
});
}
var imageId = await _imageStorage.SaveImageAsync(imageBytes, file.FileName, file.ContentType);
_logger.LogInformation("Image uploaded successfully: {ImageId}, Original: {FileName}, Size: {Size}KB",
imageId, file.FileName, file.Length / 1024);
return Ok(new {
success = true,
imageId,
url = $"/api/image/{imageId}",
originalSize = file.Length,
fileName = file.FileName
});
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Invalid upload parameters");
return BadRequest(new {
error = ex.Message,
code = "VALIDATION_ERROR"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading image: {FileName}", file?.FileName);
return StatusCode(500, new {
error = "Error uploading image. Please try again.",
code = "UPLOAD_ERROR"
});
}
}
[HttpDelete("{imageId}")]
public async Task<IActionResult> DeleteImage(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return BadRequest(new { error = "Image ID is required" });
var deleted = await _imageStorage.DeleteImageAsync(imageId);
if (!deleted)
return NotFound(new { error = "Image not found" });
_logger.LogInformation("Image deleted: {ImageId}", imageId);
return Ok(new { success = true, message = "Image deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting image: {ImageId}", imageId);
return StatusCode(500, new { error = "Error deleting image" });
}
}
[HttpHead("{imageId}")]
public async Task<IActionResult> ImageExists(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return BadRequest();
var exists = await _imageStorage.ImageExistsAsync(imageId);
return exists ? Ok() : NotFound();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking image existence: {ImageId}", imageId);
return StatusCode(500);
}
}
private static bool IsValidImageBytes(byte[] bytes)
{
if (bytes == null || bytes.Length < 4)
return false;
// Verificar assinaturas de arquivos de imagem
var jpegSignature = new byte[] { 0xFF, 0xD8, 0xFF };
var pngSignature = new byte[] { 0x89, 0x50, 0x4E, 0x47 };
var gifSignature = new byte[] { 0x47, 0x49, 0x46 };
return StartsWithSignature(bytes, jpegSignature) ||
StartsWithSignature(bytes, pngSignature) ||
StartsWithSignature(bytes, gifSignature);
}
private static bool StartsWithSignature(byte[] bytes, byte[] signature)
{
if (bytes.Length < signature.Length)
return false;
for (int i = 0; i < signature.Length; i++)
{
if (bytes[i] != signature[i])
return false;
}
return true;
}
}

View File

@ -0,0 +1,93 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Route("page")]
public class LivePageController : Controller
{
private readonly ILivePageService _livePageService;
private readonly ILogger<LivePageController> _logger;
public LivePageController(ILivePageService livePageService, ILogger<LivePageController> logger)
{
_livePageService = livePageService;
_logger = logger;
}
[Route("{category}/{slug}")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })]
public async Task<IActionResult> Display(string category, string slug)
{
// Se tem parâmetro preview, redirecionar para sistema de preview
if (HttpContext.Request.Query.ContainsKey("preview"))
{
_logger.LogInformation("Redirecting preview request for {Category}/{Slug} to UserPageController", category, slug);
return RedirectToAction("Display", "UserPage", new {
category = category,
slug = slug,
preview = HttpContext.Request.Query["preview"].ToString()
});
}
var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug);
if (livePage == null)
{
_logger.LogInformation("LivePage not found for {Category}/{Slug}, falling back to UserPageController", category, slug);
// Fallback: tentar no sistema antigo
return RedirectToAction("Display", "UserPage", new { category = category, slug = slug });
}
// Incrementar view de forma assíncrona (não bloquear response)
_ = Task.Run(async () =>
{
try
{
await _livePageService.IncrementViewAsync(livePage.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePage.Id);
}
});
// Configurar ViewBag para indicar que é uma live page
ViewBag.IsLivePage = true;
ViewBag.PageUrl = $"https://bcards.site/page/{category}/{slug}";
ViewBag.Title = $"{livePage.DisplayName} - {livePage.Category} | BCards";
_logger.LogInformation("Serving LivePage {LivePageId} for {Category}/{Slug}", livePage.Id, category, slug);
// Usar a mesma view do UserPage mas com dados da LivePage
return View("~/Views/UserPage/Display.cshtml", livePage);
}
[Route("{category}/{slug}/link/{linkIndex}")]
public async Task<IActionResult> TrackLinkClick(string category, string slug, int linkIndex)
{
var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug);
if (livePage == null || linkIndex < 0 || linkIndex >= livePage.Links.Count)
{
return NotFound();
}
var link = livePage.Links[linkIndex];
// Track click de forma assíncrona
_ = Task.Run(async () =>
{
try
{
await _livePageService.IncrementLinkClickAsync(livePage.Id, linkIndex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to track click for LivePage {LivePageId} link {LinkIndex}", livePage.Id, linkIndex);
}
});
_logger.LogInformation("Tracking click for LivePage {LivePageId} link {LinkIndex} -> {Url}", livePage.Id, linkIndex, link.Url);
return Redirect(link.Url);
}
}

View File

@ -0,0 +1,230 @@
using Microsoft.AspNetCore.Mvc;
using BCards.Web.Services;
using BCards.Web.Models;
using BCards.Web.ViewModels;
using BCards.Web.Repositories;
using System.Security.Claims;
using BCards.Web.Attributes;
namespace BCards.Web.Controllers;
[ModeratorAuthorize]
[Route("Moderation")]
public class ModerationController : Controller
{
private readonly IModerationService _moderationService;
private readonly IEmailService _emailService;
private readonly IUserRepository _userRepository;
private readonly ILogger<ModerationController> _logger;
public ModerationController(
IModerationService moderationService,
IEmailService emailService,
IUserRepository userRepository,
ILogger<ModerationController> logger)
{
_moderationService = moderationService;
_emailService = emailService;
_userRepository = userRepository;
_logger = logger;
}
[HttpGet("Dashboard")]
public async Task<IActionResult> Dashboard(int page = 1, int size = 20)
{
var skip = (page - 1) * size;
var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size);
var stats = await _moderationService.GetModerationStatsAsync();
var viewModel = new ModerationDashboardViewModel
{
PendingPages = pendingPages.Select(p => new PendingPageViewModel
{
Id = p.Id,
DisplayName = p.DisplayName,
Category = p.Category,
Slug = p.Slug,
CreatedAt = p.CreatedAt,
ModerationAttempts = p.ModerationAttempts,
PlanType = p.PlanLimitations.PlanType.ToString(),
PreviewUrl = !string.IsNullOrEmpty(p.PreviewToken)
? $"/page/{p.Category}/{p.Slug}?preview={p.PreviewToken}"
: null
}).ToList(),
Stats = stats,
CurrentPage = page,
PageSize = size,
HasNextPage = pendingPages.Count == size
};
return View(viewModel);
}
[HttpGet("Review/{id}")]
public async Task<IActionResult> Review(string id)
{
var page = await _moderationService.GetPageForModerationAsync(id);
if (page == null)
{
TempData["Error"] = "Página não encontrada ou não está pendente de moderação.";
return RedirectToAction("Dashboard");
}
var user = await _userRepository.GetByIdAsync(page.UserId);
if (user == null)
{
TempData["Error"] = "Usuário não encontrado.";
return RedirectToAction("Dashboard");
}
var viewModel = new ModerationReviewViewModel
{
Page = page,
User = user,
PreviewUrl = !string.IsNullOrEmpty(page.PreviewToken)
? $"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"
: null,
ModerationCriteria = GetModerationCriteria()
};
return View(viewModel);
}
[HttpPost("Approve/{id}")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Approve(string id, string notes)
{
try
{
var page = await _moderationService.GetPageForModerationAsync(id);
if (page == null)
{
TempData["Error"] = "Página não encontrada.";
return RedirectToAction("Dashboard");
}
var user = await _userRepository.GetByIdAsync(page.UserId);
var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
await _moderationService.ApprovePageAsync(id, moderatorId, notes);
if (user != null)
{
await _emailService.SendModerationStatusAsync(
user.Email,
user.Name,
page.DisplayName,
PageStatus.Active.ToString());
}
TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!";
return RedirectToAction("Dashboard");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error approving page {PageId}", id);
TempData["Error"] = "Erro ao aprovar página.";
return RedirectToAction("Review", new { id });
}
}
[HttpPost("Reject/{id}")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Reject(string id, string reason, List<string> issues)
{
try
{
var page = await _moderationService.GetPageForModerationAsync(id);
if (page == null)
{
TempData["Error"] = "Página não encontrada.";
return RedirectToAction("Dashboard");
}
var user = await _userRepository.GetByIdAsync(page.UserId);
var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
await _moderationService.RejectPageAsync(id, moderatorId, reason, issues);
if (user != null)
{
await _emailService.SendModerationStatusAsync(
user.Email,
user.Name,
page.DisplayName,
"rejected",
reason);
}
TempData["Success"] = $"Página '{page.DisplayName}' rejeitada.";
return RedirectToAction("Dashboard");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error rejecting page {PageId}", id);
TempData["Error"] = "Erro ao rejeitar página.";
return RedirectToAction("Review", new { id });
}
}
[HttpGet("History")]
public async Task<IActionResult> History(int page = 1, int size = 20)
{
var skip = (page - 1) * size;
var historyPages = await _moderationService.GetModerationHistoryAsync(skip, size);
var viewModel = new ModerationHistoryViewModel
{
Pages = historyPages.Select(p => new ModerationPageViewModel
{
Id = p.Id,
DisplayName = p.DisplayName,
Category = p.Category,
Slug = p.Slug,
CreatedAt = p.CreatedAt,
Status = p.Status.ToString(),
ModerationAttempts = p.ModerationAttempts,
PlanType = p.PlanLimitations.PlanType.ToString(),
ApprovedAt = p.ApprovedAt,
LastModerationEntry = p.ModerationHistory.LastOrDefault()
}).ToList(),
CurrentPage = page,
PageSize = size,
HasNextPage = historyPages.Count == size
};
return View(viewModel);
}
private List<ModerationCriterion> GetModerationCriteria()
{
return new List<ModerationCriterion>
{
new() { Category = "Conteúdo Proibido", Items = new List<string>
{
"Pornografia e conteúdo sexual explícito",
"Drogas ilegais e substâncias controladas",
"Armas e explosivos",
"Atividades ilegais (fraudes, pirataria)",
"Apostas e jogos de azar",
"Criptomoedas e esquemas de pirâmide",
"Conteúdo que promove violência ou ódio",
"Spam e links suspeitos/maliciosos"
}},
new() { Category = "Conteúdo Suspeito", Items = new List<string>
{
"Excesso de anúncios (>30% dos links)",
"Sites com pop-ups excessivos",
"Links encurtados suspeitos",
"Conteúdo que imita marcas conhecidas",
"Produtos \"milagrosos\""
}},
new() { Category = "Verificações Técnicas", Items = new List<string>
{
"Links funcionais (não quebrados)",
"Sites com SSL válido",
"Não redirecionamentos maliciosos"
}}
};
}
}

View File

@ -1,5 +1,10 @@
using BCards.Web.Models;
using BCards.Web.Repositories;
using BCards.Web.Services; using BCards.Web.Services;
using BCards.Web.ViewModels;
using BCards.Web.Utils;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers; namespace BCards.Web.Controllers;
@ -9,16 +14,15 @@ public class PaymentController : Controller
{ {
private readonly IPaymentService _paymentService; private readonly IPaymentService _paymentService;
private readonly IAuthService _authService; private readonly IAuthService _authService;
private readonly IUserRepository _userService;
private readonly ISubscriptionRepository _subscriptionRepository;
public PaymentController(IPaymentService paymentService, IAuthService authService) public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository)
{ {
_paymentService = paymentService; _paymentService = paymentService;
_authService = authService; _authService = authService;
} _userService = userService;
_subscriptionRepository = subscriptionRepository;
public IActionResult Plans()
{
return View();
} }
[HttpPost] [HttpPost]
@ -31,6 +35,8 @@ public class PaymentController : Controller
var successUrl = Url.Action("Success", "Payment", null, Request.Scheme); var successUrl = Url.Action("Success", "Payment", null, Request.Scheme);
var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme); var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme);
TempData[$"PlanType|{user.Id}"] = planType;
try try
{ {
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync( var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
@ -44,20 +50,31 @@ public class PaymentController : Controller
catch (Exception ex) catch (Exception ex)
{ {
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}"; TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
return RedirectToAction("Plans"); return RedirectToAction("Pricing", "Home");
} }
} }
public IActionResult Success() public async Task<IActionResult> Success()
{ {
TempData["Success"] = "Assinatura ativada com sucesso! Agora você pode aproveitar todos os recursos do seu plano."; try
return RedirectToAction("Dashboard", "Admin"); {
var user = await _authService.GetCurrentUserAsync(User);
var planType = TempData[$"PlanType|{user.Id}"].ToString();
TempData["Success"] = $"Assinatura {planType} ativada com sucesso!";
return RedirectToAction("Dashboard", "Admin");
}
catch (Exception ex)
{
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
return RedirectToAction("Dashboard", "Admin");
}
} }
public IActionResult Cancel() public IActionResult Cancel()
{ {
TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser."; TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser.";
return RedirectToAction("Plans"); return RedirectToAction("Pricing", "Home");
} }
[HttpPost] [HttpPost]
@ -92,7 +109,45 @@ public class PaymentController : Controller
if (user == null) if (user == null)
return RedirectToAction("Login", "Auth"); return RedirectToAction("Login", "Auth");
return View(user); try
{
// Parse do plano atual (mesmo que o Dashboard)
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var currentPlanString = userPlanType.ToString().ToLower();
var subscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
var viewModel = new ManageSubscriptionViewModel
{
User = user,
StripeSubscription = await _paymentService.GetSubscriptionDetailsAsync(user.Id),
PaymentHistory = await _paymentService.GetPaymentHistoryAsync(user.Id),
AvailablePlans = GetAvailablePlans(currentPlanString),
CurrentPeriodEnd = (DateTime?) subscription.CurrentPeriodEnd
};
// Pegar assinatura local se existir
if (!string.IsNullOrEmpty(user.StripeCustomerId))
{
// Aqui você poderia buscar a subscription local se necessário
// viewModel.LocalSubscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
}
return View(viewModel);
}
catch (Exception ex)
{
// Parse do plano atual também no catch
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var currentPlanString = userPlanType.ToString().ToLower();
var errorViewModel = new ManageSubscriptionViewModel
{
User = user,
ErrorMessage = $"Erro ao carregar dados da assinatura: {ex.Message}",
AvailablePlans = GetAvailablePlans(currentPlanString)
};
return View(errorViewModel);
}
} }
[HttpPost] [HttpPost]
@ -110,4 +165,126 @@ public class PaymentController : Controller
return RedirectToAction("ManageSubscription"); return RedirectToAction("ManageSubscription");
} }
[HttpPost]
public async Task<IActionResult> ChangePlan(string newPlanType)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
try
{
// Para mudanças de plano, vamos usar o Stripe Checkout
var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
var cancelUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
user.Id,
newPlanType,
returnUrl!,
cancelUrl!);
return Redirect(checkoutUrl);
}
catch (Exception ex)
{
TempData["Error"] = $"Erro ao alterar plano: {ex.Message}";
return RedirectToAction("ManageSubscription");
}
}
[HttpPost]
public async Task<IActionResult> OpenStripePortal()
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
{
TempData["Error"] = "Erro: dados de assinatura não encontrados.";
return RedirectToAction("ManageSubscription");
}
try
{
var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
var portalUrl = await _paymentService.CreatePortalSessionAsync(user.StripeCustomerId, returnUrl!);
return Redirect(portalUrl);
}
catch (Exception ex)
{
TempData["Error"] = $"Erro ao abrir portal de pagamento: {ex.Message}";
return RedirectToAction("ManageSubscription");
}
}
private List<AvailablePlanViewModel> GetAvailablePlans(string currentPlan)
{
var plans = new List<AvailablePlanViewModel>
{
// Plano Trial não é incluído aqui pois é gerenciado internamente
// O "downgrade" para Trial acontece via cancelamento da assinatura
new()
{
PlanType = "basic",
DisplayName = "Básico",
Price = 9.90m,
PriceId = "price_basic", // Substitua pelos IDs reais do Stripe
MaxLinks = 5,
AllowAnalytics = true,
Features = new List<string> { "5 links", "Temas básicos", "Análises básicas" },
IsCurrentPlan = currentPlan == "basic"
},
new()
{
PlanType = "professional",
DisplayName = "Profissional",
Price = 24.90m,
PriceId = "price_professional", // Substitua pelos IDs reais do Stripe
MaxLinks = 15,
AllowAnalytics = true,
AllowCustomDomain = true,
Features = new List<string> { "15 links", "Todos os temas", "Página rápida", "Análises avançadas" },
IsCurrentPlan = currentPlan == "professional"
},
new()
{
PlanType = "premium",
DisplayName = "Premium",
Price = 29.90m,
PriceId = "price_premium", // Substitua pelos IDs reais do Stripe
MaxLinks = -1, // Ilimitado
AllowCustomThemes = true,
AllowAnalytics = true,
AllowCustomDomain = true,
Features = new List<string> { "Links ilimitados", "Temas personalizados", "Página rápida", "Suporte prioritário" },
IsCurrentPlan = currentPlan == "premium"
}
};
// Marcar upgrades e filtrar downgrades
var currentPlanIndex = plans.FindIndex(p => p.IsCurrentPlan);
// Se usuário está no Trial (não encontrou plano atual), todos são upgrades
if (currentPlanIndex == -1 && (currentPlan == "trial" || currentPlan == "free"))
{
foreach (var plan in plans)
{
plan.IsUpgrade = true;
}
return plans; // Mostrar todos os planos pagos como upgrade
}
// Para planos pagos, marcar apenas upgrades superiores
for (int i = 0; i < plans.Count; i++)
{
if (i > currentPlanIndex)
plans[i].IsUpgrade = true;
else if (i < currentPlanIndex)
plans[i].IsDowngrade = true;
}
// Retornar apenas plano atual e upgrades (Stripe não gerencia downgrades automaticamente)
return plans.Where(p => p.IsCurrentPlan || p.IsUpgrade).ToList();
}
} }

View File

@ -0,0 +1,131 @@
using BCards.Web.Models;
using BCards.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ProductController : ControllerBase
{
private readonly IOpenGraphService _openGraphService;
private readonly ILogger<ProductController> _logger;
public ProductController(
IOpenGraphService openGraphService,
ILogger<ProductController> logger)
{
_openGraphService = openGraphService;
_logger = logger;
}
[HttpPost("extract")]
public async Task<IActionResult> ExtractProduct([FromBody] ExtractProductRequest request)
{
try
{
if (string.IsNullOrWhiteSpace(request.Url))
{
return BadRequest(new { success = false, message = "URL é obrigatória." });
}
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri))
{
return BadRequest(new { success = false, message = "URL inválida." });
}
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new { success = false, message = "Usuário não autenticado." });
}
// Verificar rate limiting antes de tentar extrair
var isRateLimited = await _openGraphService.IsRateLimitedAsync(userId);
if (isRateLimited)
{
return this.TooManyRequests(new {
success = false,
message = "Aguarde 1 minuto antes de extrair dados de outro produto."
});
}
var ogData = await _openGraphService.ExtractDataAsync(request.Url, userId);
if (!ogData.IsValid)
{
return BadRequest(new {
success = false,
message = string.IsNullOrEmpty(ogData.ErrorMessage)
? "Não foi possível extrair dados desta página."
: ogData.ErrorMessage
});
}
return Ok(new {
success = true,
title = ogData.Title,
description = ogData.Description,
image = ogData.Image,
price = ogData.Price,
currency = ogData.Currency
});
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Operação inválida na extração de produto para usuário {UserId}",
User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
return BadRequest(new { success = false, message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro interno na extração de produto para usuário {UserId}",
User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
return StatusCode(500, new {
success = false,
message = "Erro interno do servidor. Tente novamente em alguns instantes."
});
}
}
[HttpGet("cache/{urlHash}")]
public Task<IActionResult> GetCachedData(string urlHash)
{
try
{
// Por segurança, vamos reconstruir a URL a partir do hash (se necessário)
// Por agora, apenas retornamos erro se não encontrado
return Task.FromResult<IActionResult>(NotFound(new { success = false, message = "Cache não encontrado." }));
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar cache para hash {UrlHash}", urlHash);
return Task.FromResult<IActionResult>(StatusCode(500, new { success = false, message = "Erro interno do servidor." }));
}
}
}
public class ExtractProductRequest
{
public string Url { get; set; } = string.Empty;
}
// Custom result for 429 Too Many Requests
public class TooManyRequestsResult : ObjectResult
{
public TooManyRequestsResult(object value) : base(value)
{
StatusCode = 429;
}
}
public static class ControllerBaseExtensions
{
public static TooManyRequestsResult TooManyRequests(this ControllerBase controller, object value)
{
return new TooManyRequestsResult(value);
}
}

View File

@ -8,11 +8,16 @@ namespace BCards.Web.Controllers;
public class SitemapController : Controller public class SitemapController : Controller
{ {
private readonly IUserPageService _userPageService; private readonly IUserPageService _userPageService;
private readonly ILivePageService _livePageService;
private readonly ILogger<SitemapController> _logger; private readonly ILogger<SitemapController> _logger;
public SitemapController(IUserPageService userPageService, ILogger<SitemapController> logger) public SitemapController(
IUserPageService userPageService,
ILivePageService livePageService,
ILogger<SitemapController> logger)
{ {
_userPageService = userPageService; _userPageService = userPageService;
_livePageService = livePageService;
_logger = logger; _logger = logger;
} }
@ -22,7 +27,8 @@ public class SitemapController : Controller
{ {
try try
{ {
var activePages = await _userPageService.GetActivePagesAsync(); // 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages
var livePages = await _livePageService.GetAllActiveAsync();
var sitemap = new XDocument( var sitemap = new XDocument(
new XDeclaration("1.0", "utf-8", "yes"), new XDeclaration("1.0", "utf-8", "yes"),
@ -43,11 +49,11 @@ public class SitemapController : Controller
new XElement("priority", "0.9") new XElement("priority", "0.9")
), ),
// Add user pages (only active ones) // Add live pages (SEO-optimized URLs only)
activePages.Select(page => livePages.Select(page =>
new XElement("url", new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"), new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"),
new XElement("lastmod", page.UpdatedAt.ToString("yyyy-MM-dd")), new XElement("lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")),
new XElement("changefreq", "weekly"), new XElement("changefreq", "weekly"),
new XElement("priority", "0.8") new XElement("priority", "0.8")
) )
@ -55,7 +61,7 @@ public class SitemapController : Controller
) )
); );
_logger.LogInformation($"Generated sitemap with {activePages.Count} user pages"); _logger.LogInformation($"Generated sitemap with {livePages.Count} live pages");
return Content(sitemap.ToString(), "application/xml", Encoding.UTF8); return Content(sitemap.ToString(), "application/xml", Encoding.UTF8);
} }

View File

@ -59,19 +59,19 @@ public class StripeWebhookController : ControllerBase
switch (stripeEvent.Type) switch (stripeEvent.Type)
{ {
case Events.InvoicePaymentSucceeded: case "invoice.payment_succeeded":
await HandlePaymentSucceeded(stripeEvent); await HandlePaymentSucceeded(stripeEvent);
break; break;
case Events.InvoicePaymentFailed: case "invoice.payment_failed":
await HandlePaymentFailed(stripeEvent); await HandlePaymentFailed(stripeEvent);
break; break;
case Events.CustomerSubscriptionDeleted: case "customer.subscription.deleted":
await HandleSubscriptionDeleted(stripeEvent); await HandleSubscriptionDeleted(stripeEvent);
break; break;
case Events.CustomerSubscriptionUpdated: case "customer.subscription.updated":
await HandleSubscriptionUpdated(stripeEvent); await HandleSubscriptionUpdated(stripeEvent);
break; break;
@ -99,8 +99,9 @@ public class StripeWebhookController : ControllerBase
if (stripeEvent.Data.Object is Invoice invoice) if (stripeEvent.Data.Object is Invoice invoice)
{ {
_logger.LogInformation($"Payment succeeded for customer: {invoice.CustomerId}"); _logger.LogInformation($"Payment succeeded for customer: {invoice.CustomerId}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId); var subscriptionId = GetSubscriptionId(stripeEvent);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (subscription != null) if (subscription != null)
{ {
subscription.Status = "active"; subscription.Status = "active";
@ -126,8 +127,9 @@ public class StripeWebhookController : ControllerBase
if (stripeEvent.Data.Object is Invoice invoice) if (stripeEvent.Data.Object is Invoice invoice)
{ {
_logger.LogInformation($"Payment failed for customer: {invoice.CustomerId}"); _logger.LogInformation($"Payment failed for customer: {invoice.CustomerId}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId); var subscriptionId = GetSubscriptionId(stripeEvent);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (subscription != null) if (subscription != null)
{ {
subscription.Status = "past_due"; subscription.Status = "past_due";
@ -153,7 +155,7 @@ public class StripeWebhookController : ControllerBase
if (stripeEvent.Data.Object is Subscription stripeSubscription) if (stripeEvent.Data.Object is Subscription stripeSubscription)
{ {
_logger.LogInformation($"Subscription cancelled: {stripeSubscription.Id}"); _logger.LogInformation($"Subscription cancelled: {stripeSubscription.Id}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id); var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null) if (subscription != null)
{ {
@ -184,9 +186,12 @@ public class StripeWebhookController : ControllerBase
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id); var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null) if (subscription != null)
{ {
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
subscription.Status = stripeSubscription.Status; subscription.Status = stripeSubscription.Status;
subscription.CurrentPeriodStart = stripeSubscription.CurrentPeriodStart; subscription.CurrentPeriodStart = subItem.CurrentPeriodStart;
subscription.CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd; subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd;
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd; subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
subscription.UpdatedAt = DateTime.UtcNow; subscription.UpdatedAt = DateTime.UtcNow;
@ -216,4 +221,33 @@ public class StripeWebhookController : ControllerBase
_ => "trial" _ => "trial"
}; };
} }
private string GetSubscriptionId(Event stripeEvent)
{
if (stripeEvent.Data.Object is Invoice invoice)
{
var subscriptionLineItem = invoice.Lines?.Data
.FirstOrDefault(line =>
!string.IsNullOrEmpty(line.SubscriptionId) ||
line.Subscription != null
);
string subscriptionId = null;
if (subscriptionLineItem != null)
{
// Tenta obter o ID da assinatura de duas formas diferentes
subscriptionId = subscriptionLineItem.SubscriptionId
?? subscriptionLineItem.Subscription?.Id;
}
return subscriptionId;
}
else if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
return stripeSubscription.Id;
}
return null;
}
} }

View File

@ -1,4 +1,6 @@
using BCards.Web.Models;
using BCards.Web.Services; using BCards.Web.Services;
using BCards.Web.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers; namespace BCards.Web.Controllers;
@ -9,36 +11,94 @@ public class UserPageController : Controller
private readonly IUserPageService _userPageService; private readonly IUserPageService _userPageService;
private readonly ICategoryService _categoryService; private readonly ICategoryService _categoryService;
private readonly ISeoService _seoService; private readonly ISeoService _seoService;
private readonly IThemeService _themeService;
private readonly IModerationService _moderationService;
public UserPageController( public UserPageController(
IUserPageService userPageService, IUserPageService userPageService,
ICategoryService categoryService, ICategoryService categoryService,
ISeoService seoService) ISeoService seoService,
IThemeService themeService,
IModerationService moderationService)
{ {
_userPageService = userPageService; _userPageService = userPageService;
_categoryService = categoryService; _categoryService = categoryService;
_seoService = seoService; _seoService = seoService;
_themeService = themeService;
_moderationService = moderationService;
} }
//[Route("{category}/{slug}")] //[Route("{category}/{slug}")]
[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })] //VOltar a linha abaixo em prod
//[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })]
public async Task<IActionResult> Display(string category, string slug) public async Task<IActionResult> Display(string category, string slug)
{ {
var userPage = await _userPageService.GetPageAsync(category, slug); var userPage = await _userPageService.GetPageAsync(category, slug);
if (userPage == null || !userPage.IsActive) if (userPage == null)
return NotFound(); return NotFound();
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category); var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
if (categoryObj == null) if (categoryObj == null)
return NotFound(); return NotFound();
// Check if it's a preview request
var isPreview = HttpContext.Items.ContainsKey("IsPreview") && (bool)HttpContext.Items["IsPreview"];
var previewToken = Request.Query["preview"].FirstOrDefault();
if (!string.IsNullOrEmpty(previewToken))
{
// Handle preview request
var isValidPreview = await _moderationService.ValidatePreviewTokenAsync(userPage.Id, previewToken);
if (!isValidPreview)
{
return View("PreviewExpired");
}
// Set preview flag
ViewBag.IsPreview = true;
ViewBag.PreviewToken = previewToken;
}
else
{
// Regular request - check if page is active
if (userPage.Status == ViewModels.PageStatus.PendingModeration)
{
return View("PendingModeration");
}
if (userPage.Status == ViewModels.PageStatus.Rejected)
{
return View("PageRejected");
}
if (userPage.Status == ViewModels.PageStatus.Inactive || !userPage.IsActive)
{
return NotFound();
}
}
// Ensure theme is loaded - critical fix for theme display issue
if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor))
{
userPage.Theme = _themeService.GetDefaultTheme();
}
// Generate SEO settings // Generate SEO settings
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj); var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
// Record page view (async, don't wait) // Record page view (async, don't wait) - only for non-preview requests
var referrer = Request.Headers["Referer"].FirstOrDefault(); Console.WriteLine($"DEBUG VIEW COUNT - Page: {userPage.Slug}, Status: {userPage.Status}, IsPreview: {isPreview}, PreviewToken: {previewToken}");
var userAgent = Request.Headers["User-Agent"].FirstOrDefault(); if (!isPreview)
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent)); {
Console.WriteLine($"DEBUG: Recording view for page {userPage.Slug}");
var referrer = Request.Headers["Referer"].FirstOrDefault();
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
}
else
{
Console.WriteLine($"DEBUG: NOT recording view - isPreview = true");
}
ViewBag.SeoSettings = seoSettings; ViewBag.SeoSettings = seoSettings;
ViewBag.Category = categoryObj; ViewBag.Category = categoryObj;
@ -65,9 +125,16 @@ public class UserPageController : Controller
if (categoryObj == null) if (categoryObj == null)
return NotFound(); return NotFound();
// Ensure theme is loaded for preview too
if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor))
{
userPage.Theme = _themeService.GetDefaultTheme();
}
ViewBag.Category = categoryObj; ViewBag.Category = categoryObj;
ViewBag.IsPreview = true; ViewBag.IsPreview = true;
return View("Display", userPage); return View("Display", userPage);
} }
} }

View File

@ -0,0 +1,45 @@
using BCards.Web.Services;
namespace BCards.Web.Middleware
{
public class ModerationAuthMiddleware
{
private readonly RequestDelegate _next;
private readonly IModerationAuthService _moderationAuth;
public ModerationAuthMiddleware(RequestDelegate next, IModerationAuthService moderationAuth)
{
_next = next;
_moderationAuth = moderationAuth;
}
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value?.ToLowerInvariant();
// Verificar se é uma rota de moderação
if (path != null && path.StartsWith("/moderation"))
{
// Verificar se usuário está autenticado
if (!context.User.Identity?.IsAuthenticated == true)
{
context.Response.Redirect("/Auth/Login?returnUrl=" + Uri.EscapeDataString(context.Request.Path));
return;
}
// Verificar se é moderador
if (!_moderationAuth.IsUserModerator(context.User))
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync("Acesso negado. Você não tem permissão para acessar esta área.");
return;
}
// Adicionar flag para usar nas views
context.Items["IsModerator"] = true;
}
await _next(context);
}
}
}

View File

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

View File

@ -0,0 +1,121 @@
using BCards.Web.Services;
using Microsoft.Extensions.Caching.Memory;
namespace BCards.Web.Middleware;
public class PreviewTokenMiddleware
{
private readonly RequestDelegate _next;
private readonly IMemoryCache _cache;
private readonly ILogger<PreviewTokenMiddleware> _logger;
public PreviewTokenMiddleware(RequestDelegate next, IMemoryCache cache, ILogger<PreviewTokenMiddleware> logger)
{
_next = next;
_cache = cache;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value;
var query = context.Request.Query;
// Verificar se é uma requisição de preview
if (path != null && path.StartsWith("/page/") && query.ContainsKey("preview"))
{
var previewToken = query["preview"].FirstOrDefault();
if (!string.IsNullOrEmpty(previewToken))
{
var result = await HandlePreviewRequest(context, previewToken);
if (!result)
{
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Preview não encontrado ou expirado.");
return;
}
}
}
await _next(context);
}
private async Task<bool> HandlePreviewRequest(HttpContext context, string previewToken)
{
try
{
// Verificar rate limiting por IP
var clientIp = GetClientIpAddress(context);
var rateLimitKey = $"preview_rate_limit_{clientIp}";
if (_cache.TryGetValue(rateLimitKey, out int requestCount))
{
if (requestCount >= 10) // Máximo 10 requisições por minuto por IP
{
_logger.LogWarning("Rate limit exceeded for IP {IP} on preview token {Token}", clientIp, previewToken);
return false;
}
_cache.Set(rateLimitKey, requestCount + 1, TimeSpan.FromMinutes(1));
}
else
{
_cache.Set(rateLimitKey, 1, TimeSpan.FromMinutes(1));
}
// Verificar se o token é válido
var moderationService = context.RequestServices.GetService<IModerationService>();
if (moderationService == null)
{
_logger.LogError("ModerationService not found in DI container");
return false;
}
var page = await moderationService.GetPageByPreviewTokenAsync(previewToken);
if (page == null)
{
_logger.LogInformation("Invalid or expired preview token: {Token}", previewToken);
return false;
}
// Incrementar contador de visualizações
var incrementResult = await moderationService.IncrementPreviewViewAsync(page.Id);
if (!incrementResult)
{
_logger.LogWarning("Preview view limit exceeded for page {PageId}", page.Id);
return false;
}
// Adicionar informações do preview ao contexto
context.Items["IsPreview"] = true;
context.Items["PreviewPageId"] = page.Id;
context.Items["PreviewToken"] = previewToken;
_logger.LogInformation("Valid preview request for page {PageId} with token {Token}", page.Id, previewToken);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling preview request with token {Token}", previewToken);
return false;
}
}
private string GetClientIpAddress(HttpContext context)
{
// Verificar cabeçalhos de proxy
var xForwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(xForwardedFor))
{
return xForwardedFor.Split(',')[0].Trim();
}
var xRealIp = context.Request.Headers["X-Real-IP"].FirstOrDefault();
if (!string.IsNullOrEmpty(xRealIp))
{
return xRealIp;
}
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
}

View File

@ -0,0 +1,27 @@
namespace BCards.Web.Models
{
/// <summary>
/// Interface comum para páginas que podem ser exibidas publicamente
/// Facilita o envio de dados para views sem duplicação de código
/// </summary>
public interface IPageDisplay
{
string Id { get; }
string UserId { get; }
string Category { get; }
string Slug { get; }
string DisplayName { get; }
string Bio { get; }
string? ProfileImageId { get; }
string BusinessType { get; }
PageTheme Theme { get; }
List<LinkItem> Links { get; }
SeoSettings SeoSettings { get; }
string Language { get; }
DateTime CreatedAt { get; }
// Propriedades calculadas comuns
string FullUrl { get; }
string ProfileImageUrl { get; }
}
}

View File

@ -2,6 +2,12 @@ using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models; namespace BCards.Web.Models;
public enum LinkType
{
Normal = 0, // Link comum
Product = 1 // Link de produto com preview
}
public class LinkItem public class LinkItem
{ {
[BsonElement("title")] [BsonElement("title")]
@ -27,4 +33,23 @@ public class LinkItem
[BsonElement("createdAt")] [BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Campos para Link de Produto
[BsonElement("type")]
public LinkType Type { get; set; } = LinkType.Normal;
[BsonElement("productTitle")]
public string ProductTitle { get; set; } = string.Empty;
[BsonElement("productImage")]
public string ProductImage { get; set; } = string.Empty;
[BsonElement("productPrice")]
public string ProductPrice { get; set; } = string.Empty;
[BsonElement("productDescription")]
public string ProductDescription { get; set; } = string.Empty;
[BsonElement("productDataCachedAt")]
public DateTime? ProductDataCachedAt { get; set; }
} }

View File

@ -0,0 +1,89 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class LivePage : IPageDisplay
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
[BsonElement("originalPageId")]
[BsonRepresentation(BsonType.ObjectId)]
public string OriginalPageId { get; set; } = string.Empty;
[BsonElement("userId")]
[BsonRepresentation(BsonType.ObjectId)]
public string UserId { get; set; } = string.Empty;
[BsonElement("category")]
public string Category { get; set; } = string.Empty;
[BsonElement("slug")]
public string Slug { get; set; } = string.Empty;
[BsonElement("displayName")]
public string DisplayName { get; set; } = string.Empty;
[BsonElement("bio")]
public string Bio { get; set; } = string.Empty;
[BsonElement("profileImageId")]
public string? ProfileImageId { get; set; }
// Campo antigo - ignorar durante deserialização para compatibilidade
[BsonElement("profileImage")]
[BsonIgnoreIfDefault]
[BsonIgnore]
public string? ProfileImage { get; set; }
[BsonElement("businessType")]
public string BusinessType { 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 LivePageAnalytics Analytics { get; set; } = new();
[BsonElement("publishedAt")]
public DateTime PublishedAt { get; set; }
[BsonElement("lastSyncAt")]
public DateTime LastSyncAt { get; set; }
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; }
public string FullUrl => $"page/{Category}/{Slug}";
/// <summary>
/// URL da imagem de perfil ou imagem padrão se não houver upload
/// </summary>
[BsonIgnore]
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
? $"/api/image/{ProfileImageId}"
: "/images/default-avatar.svg";
}
public class LivePageAnalytics
{
[BsonElement("totalViews")]
public long TotalViews { get; set; }
[BsonElement("totalClicks")]
public long TotalClicks { get; set; }
[BsonElement("lastViewedAt")]
public DateTime? LastViewedAt { get; set; }
}

View File

@ -0,0 +1,25 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class ModerationHistory
{
[BsonElement("attempt")]
public int Attempt { get; set; }
[BsonElement("status")]
public string Status { get; set; } = "pending"; // "pending", "approved", "rejected"
[BsonElement("reason")]
public string? Reason { get; set; }
[BsonElement("moderatorId")]
public string? ModeratorId { get; set; }
[BsonElement("date")]
public DateTime Date { get; set; } = DateTime.UtcNow;
[BsonElement("issues")]
public List<string> Issues { get; set; } = new();
}

View File

@ -0,0 +1,55 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class OpenGraphCache
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
[BsonElement("url")]
public string Url { get; set; } = string.Empty;
[BsonElement("urlHash")]
public string UrlHash { get; set; } = string.Empty;
[BsonElement("title")]
public string Title { get; set; } = string.Empty;
[BsonElement("description")]
public string Description { get; set; } = string.Empty;
[BsonElement("image")]
public string Image { get; set; } = string.Empty;
[BsonElement("price")]
public string Price { get; set; } = string.Empty;
[BsonElement("currency")]
public string Currency { get; set; } = "BRL";
[BsonElement("isValid")]
public bool IsValid { get; set; }
[BsonElement("errorMessage")]
public string ErrorMessage { get; set; } = string.Empty;
[BsonElement("cachedAt")]
public DateTime CachedAt { get; set; } = DateTime.UtcNow;
[BsonElement("expiresAt")]
public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(24);
}
public class OpenGraphData
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Image { get; set; } = string.Empty;
public string Price { get; set; } = string.Empty;
public string Currency { get; set; } = "BRL";
public bool IsValid { get; set; }
public string ErrorMessage { get; set; } = string.Empty;
}

View File

@ -38,4 +38,9 @@ public class PageTheme
[BsonElement("createdAt")] [BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Campo translations - para compatibilidade futura, não usado por enquanto
[BsonElement("translations")]
[BsonIgnoreIfDefault]
public object? Translations { get; set; }
} }

View File

@ -24,4 +24,20 @@ public class PlanLimitations
[BsonElement("planType")] [BsonElement("planType")]
public string PlanType { get; set; } = "free"; public string PlanType { get; set; } = "free";
// Novos campos para Links de Produto
[BsonElement("maxProductLinks")]
public int MaxProductLinks { get; set; } = 0;
[BsonElement("maxOGExtractionsPerDay")]
public int MaxOGExtractionsPerDay { get; set; } = 0;
[BsonElement("allowProductLinks")]
public bool AllowProductLinks { get; set; } = false;
[BsonElement("ogExtractionsUsedToday")]
public int OGExtractionsUsedToday { get; set; } = 0;
[BsonElement("lastExtractionDate")]
public DateTime? LastExtractionDate { get; set; }
} }

View File

@ -91,4 +91,33 @@ public static class PlanTypeExtensions
{ {
return planType == PlanType.Trial ? 7 : 0; return planType == PlanType.Trial ? 7 : 0;
} }
public static int GetMaxProductLinks(this PlanType planType)
{
return planType switch
{
PlanType.Trial => 1, // 1 link de produto para trial
PlanType.Basic => 3, // 3 links de produto
PlanType.Professional => 8, // DECOY - mais caro para poucos benefícios
PlanType.Premium => int.MaxValue, // Ilimitado
_ => 0
};
}
public static int GetMaxOGExtractionsPerDay(this PlanType planType)
{
return planType switch
{
PlanType.Trial => 2, // 2 extrações por dia no trial
PlanType.Basic => 5, // 5 extrações por dia
PlanType.Professional => 15, // 15 extrações por dia
PlanType.Premium => int.MaxValue, // Ilimitado
_ => 0
};
}
public static bool AllowsProductLinks(this PlanType planType)
{
return GetMaxProductLinks(planType) > 0;
}
} }

View File

@ -25,10 +25,10 @@ public class User
public string StripeCustomerId { get; set; } = string.Empty; public string StripeCustomerId { get; set; } = string.Empty;
[BsonElement("subscriptionStatus")] [BsonElement("subscriptionStatus")]
public string SubscriptionStatus { get; set; } = "free"; public string SubscriptionStatus { get; set; } = "trial";
[BsonElement("currentPlan")] [BsonElement("currentPlan")]
public string CurrentPlan { get; set; } = "free"; public string CurrentPlan { get; set; } = "trial";
[BsonElement("createdAt")] [BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

View File

@ -4,7 +4,7 @@ using BCards.Web.ViewModels;
namespace BCards.Web.Models; namespace BCards.Web.Models;
public class UserPage public class UserPage : IPageDisplay
{ {
[BsonId] [BsonId]
[BsonRepresentation(BsonType.ObjectId)] [BsonRepresentation(BsonType.ObjectId)]
@ -29,8 +29,14 @@ public class UserPage
[BsonElement("bio")] [BsonElement("bio")]
public string Bio { get; set; } = string.Empty; public string Bio { get; set; } = string.Empty;
[BsonElement("profileImageId")]
public string? ProfileImageId { get; set; }
// Campo antigo - ignorar durante deserialização para compatibilidade
[BsonElement("profileImage")] [BsonElement("profileImage")]
public string ProfileImage { get; set; } = string.Empty; [BsonIgnoreIfDefault]
[BsonIgnore]
public string? ProfileImage { get; set; }
[BsonElement("theme")] [BsonElement("theme")]
public PageTheme Theme { get; set; } = new(); public PageTheme Theme { get; set; } = new();
@ -65,5 +71,34 @@ public class UserPage
[BsonElement("status")] [BsonElement("status")]
public PageStatus Status { get; set; } = PageStatus.Active; public PageStatus Status { get; set; } = PageStatus.Active;
[BsonElement("previewToken")]
public string? PreviewToken { get; set; }
[BsonElement("previewTokenExpiry")]
public DateTime? PreviewTokenExpiry { get; set; }
[BsonElement("moderationAttempts")]
public int ModerationAttempts { get; set; } = 0;
[BsonElement("moderationHistory")]
public List<ModerationHistory> ModerationHistory { get; set; } = new();
[BsonElement("approvedAt")]
public DateTime? ApprovedAt { get; set; }
[BsonElement("userScore")]
public int UserScore { get; set; } = 100;
[BsonElement("previewViewCount")]
public int PreviewViewCount { get; set; } = 0;
public string FullUrl => $"page/{Category}/{Slug}"; public string FullUrl => $"page/{Category}/{Slug}";
/// <summary>
/// URL da imagem de perfil ou imagem padrão se não houver upload
/// </summary>
[BsonIgnore]
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
? $"/api/image/{ProfileImageId}"
: "/images/default-avatar.svg";
} }

View File

@ -8,6 +8,11 @@ using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MongoDB.Driver; using MongoDB.Driver;
using System.Globalization; using System.Globalization;
using Stripe;
using Microsoft.AspNetCore.Authentication.OAuth;
using SendGrid;
using BCards.Web.Middleware;
using Microsoft.AspNetCore.Http.Features;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -45,6 +50,10 @@ builder.Services.Configure<GoogleAuthSettings>(
builder.Services.Configure<MicrosoftAuthSettings>( builder.Services.Configure<MicrosoftAuthSettings>(
builder.Configuration.GetSection("Authentication:Microsoft")); builder.Configuration.GetSection("Authentication:Microsoft"));
// Adicionar configurações
builder.Services.Configure<ModerationSettings>(
builder.Configuration.GetSection("Moderation"));
// Authentication // Authentication
builder.Services.AddAuthentication(options => builder.Services.AddAuthentication(options =>
{ {
@ -69,6 +78,19 @@ builder.Services.AddAuthentication(options =>
var msAuth = builder.Configuration.GetSection("Authentication:Microsoft"); var msAuth = builder.Configuration.GetSection("Authentication:Microsoft");
options.ClientId = msAuth["ClientId"] ?? ""; options.ClientId = msAuth["ClientId"] ?? "";
options.ClientSecret = msAuth["ClientSecret"] ?? ""; options.ClientSecret = msAuth["ClientSecret"] ?? "";
// Força seleção de conta a cada login
options.AuthorizationEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
options.TokenEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
options.Events = new OAuthEvents
{
OnRedirectToAuthorizationEndpoint = context =>
{
context.Response.Redirect(context.RedirectUri + "&prompt=login");
return Task.CompletedTask;
}
};
}); });
// Localization // Localization
@ -92,6 +114,8 @@ builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IUserPageRepository, UserPageRepository>(); builder.Services.AddScoped<IUserPageRepository, UserPageRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>(); builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>(); builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
builder.Services.AddSingleton<IModerationAuthService, ModerationAuthService>();
//builder.Services.AddScoped<IModerationAuthService, ModerationAuthService>();
builder.Services.AddScoped<IUserPageService, UserPageService>(); builder.Services.AddScoped<IUserPageService, UserPageService>();
builder.Services.AddScoped<IThemeService, ThemeService>(); builder.Services.AddScoped<IThemeService, ThemeService>();
@ -99,6 +123,35 @@ builder.Services.AddScoped<ISeoService, SeoService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IPaymentService, PaymentService>(); builder.Services.AddScoped<IPaymentService, PaymentService>();
builder.Services.AddScoped<ICategoryService, CategoryService>(); builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IOpenGraphService, OpenGraphService>();
builder.Services.AddScoped<IModerationService, ModerationService>();
builder.Services.AddScoped<IEmailService, EmailService>();
// Image Storage Service
builder.Services.AddScoped<IImageStorageService, GridFSImageStorage>();
// Configure upload limits for file uploads
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10MB for forms with files
options.ValueLengthLimit = int.MaxValue;
options.ValueCountLimit = int.MaxValue;
options.KeyLengthLimit = int.MaxValue;
});
// 🔥 NOVO: LivePage Services
builder.Services.AddScoped<ILivePageRepository, LivePageRepository>();
builder.Services.AddScoped<ILivePageService, LivePageService>();
// Add HttpClient for OpenGraphService
builder.Services.AddHttpClient<OpenGraphService>();
// Add SendGrid
builder.Services.AddSingleton<ISendGridClient>(provider =>
{
var apiKey = builder.Configuration["SendGrid:ApiKey"];
return new SendGridClient(apiKey);
});
// Background Services // Background Services
builder.Services.AddHostedService<TrialExpirationService>(); builder.Services.AddHostedService<TrialExpirationService>();
@ -131,45 +184,61 @@ app.UseAuthorization();
// Add custom middleware // Add custom middleware
app.UseMiddleware<BCards.Web.Middleware.PlanLimitationMiddleware>(); app.UseMiddleware<BCards.Web.Middleware.PlanLimitationMiddleware>();
app.UseMiddleware<BCards.Web.Middleware.PageStatusMiddleware>(); app.UseMiddleware<BCards.Web.Middleware.PageStatusMiddleware>();
app.UseMiddleware<BCards.Web.Middleware.PreviewTokenMiddleware>();
app.UseMiddleware<ModerationAuthMiddleware>();
app.Use(async (context, next) =>
{
Console.WriteLine($"=== REQUEST DEBUG ===");
Console.WriteLine($"Path: {context.Request.Path}");
Console.WriteLine($"Query: {context.Request.QueryString}");
Console.WriteLine($"Method: {context.Request.Method}");
await next();
});
app.UseResponseCaching(); app.UseResponseCaching();
// Rota padr<64>o primeiro (mais espec<65>fica) // Rotas específicas primeiro
app.MapControllerRoute( app.MapControllerRoute(
name: "default", name: "userpage-preview-path",
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}", pattern: "page/preview/{category}/{slug}",
defaults: new { controller = "UserPage", action = "Preview" }, defaults: new { controller = "UserPage", action = "Preview" },
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" }); constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
// Rota para click
app.MapControllerRoute( app.MapControllerRoute(
name: "userpage-click", name: "userpage-click",
pattern: "page/click/{pageId}", pattern: "page/click/{pageId}",
defaults: new { controller = "UserPage", action = "RecordClick" }); defaults: new { controller = "UserPage", action = "RecordClick" });
// Rota principal (deve vir por último)
app.MapControllerRoute( app.MapControllerRoute(
name: "userpage", name: "moderation",
pattern: "page/{category}/{slug}", pattern: "moderation/{action=Dashboard}/{id?}",
defaults: new { controller = "UserPage", action = "Display" }, defaults: new { controller = "Moderation" });
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
// Rota padrão // Rota principal que vai pegar ?preview=token
//app.MapControllerRoute(
// name: "userpage",
// pattern: "page/{category}/{slug}",
// defaults: new { controller = "UserPage", action = "Display" },
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
// 🔥 NOVA ROTA: LivePageController para páginas otimizadas de SEO
app.MapControllerRoute(
name: "livepage",
pattern: "page/{category}/{slug}",
defaults: new { controller = "LivePage", action = "Display" },
constraints: new
{
category = @"^[a-zA-Z0-9\-\u00C0-\u017F]+$", // ← Aceita acentos
slug = @"^[a-z0-9-]+$"
});
// Rota padrão por último
app.MapControllerRoute( app.MapControllerRoute(
name: "default", name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}"); pattern: "{controller=Home}/{action=Index}/{id?}");
// Initialize default data // Initialize default data
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
@ -199,4 +268,7 @@ using (var scope = app.Services.CreateScope())
} }
} }
app.Run(); app.Run();
// Make Program accessible for integration tests
public partial class Program { }

View File

@ -0,0 +1,18 @@
using BCards.Web.Models;
namespace BCards.Web.Repositories;
public interface ILivePageRepository
{
Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId);
Task<LivePage?> GetByIdAsync(string pageId);
Task<List<LivePage>> GetAllActiveAsync();
Task<LivePage> CreateAsync(LivePage livePage);
Task<LivePage> UpdateAsync(LivePage livePage);
Task<bool> DeleteAsync(string id);
Task<bool> DeleteByOriginalPageIdAsync(string originalPageId);
Task<bool> ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null);
Task IncrementViewAsync(string id);
Task IncrementLinkClickAsync(string id, int linkIndex);
}

View File

@ -1,4 +1,5 @@
using BCards.Web.Models; using BCards.Web.Models;
using MongoDB.Driver;
namespace BCards.Web.Repositories; namespace BCards.Web.Repositories;
@ -16,4 +17,15 @@ public interface IUserPageRepository
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10); Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20); Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics); Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
Task<List<UserPage>> GetManyAsync(
FilterDefinition<UserPage> filter,
SortDefinition<UserPage>? sort = null,
int skip = 0,
int take = 20);
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20);
Task<long> CountAsync(FilterDefinition<UserPage> filter);
Task<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update);
Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update);
Task<bool> ApprovePageAsync(string pageId);
Task<bool> RejectPageAsync(string pageId, string reason, List<string> issues);
} }

View File

@ -0,0 +1,127 @@
using BCards.Web.Models;
using MongoDB.Driver;
namespace BCards.Web.Repositories;
public class LivePageRepository : ILivePageRepository
{
private readonly IMongoCollection<LivePage> _collection;
public LivePageRepository(IMongoDatabase database)
{
_collection = database.GetCollection<LivePage>("livepages");
// Criar índices essenciais
CreateIndexes();
}
private void CreateIndexes()
{
try
{
// Índice único para category + slug
var categorySlugIndex = Builders<LivePage>.IndexKeys
.Ascending(x => x.Category)
.Ascending(x => x.Slug);
var uniqueOptions = new CreateIndexOptions { Unique = true };
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(categorySlugIndex, uniqueOptions));
// Outros índices importantes
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
Builders<LivePage>.IndexKeys.Ascending(x => x.UserId)));
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
Builders<LivePage>.IndexKeys.Descending(x => x.PublishedAt)));
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
Builders<LivePage>.IndexKeys.Ascending(x => x.OriginalPageId)));
}
catch
{
// Ignora erros de criação de índices (já podem existir)
}
}
public async Task<LivePage?> GetByIdAsync(string pageId)
{
return await _collection.Find(x => x.Id == pageId).FirstOrDefaultAsync();
}
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
{
return await _collection.Find(x => x.Category == category && x.Slug == slug).FirstOrDefaultAsync();
}
public async Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId)
{
return await _collection.Find(x => x.OriginalPageId == originalPageId).FirstOrDefaultAsync();
}
public async Task<List<LivePage>> GetAllActiveAsync()
{
return await _collection.Find(x => true)
.Sort(Builders<LivePage>.Sort.Descending(x => x.PublishedAt))
.ToListAsync();
}
public async Task<LivePage> CreateAsync(LivePage livePage)
{
livePage.CreatedAt = DateTime.UtcNow;
livePage.LastSyncAt = DateTime.UtcNow;
await _collection.InsertOneAsync(livePage);
return livePage;
}
public async Task<LivePage> UpdateAsync(LivePage livePage)
{
livePage.LastSyncAt = DateTime.UtcNow;
await _collection.ReplaceOneAsync(x => x.Id == livePage.Id, livePage);
return livePage;
}
public async Task<bool> DeleteAsync(string id)
{
var result = await _collection.DeleteOneAsync(x => x.Id == id);
return result.DeletedCount > 0;
}
public async Task<bool> DeleteByOriginalPageIdAsync(string originalPageId)
{
var result = await _collection.DeleteOneAsync(x => x.OriginalPageId == originalPageId);
return result.DeletedCount > 0;
}
public async Task<bool> ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null)
{
var filter = Builders<LivePage>.Filter.And(
Builders<LivePage>.Filter.Eq(x => x.Category, category),
Builders<LivePage>.Filter.Eq(x => x.Slug, slug)
);
if (!string.IsNullOrEmpty(excludeId))
{
filter = Builders<LivePage>.Filter.And(filter,
Builders<LivePage>.Filter.Ne(x => x.Id, excludeId));
}
return await _collection.Find(filter).AnyAsync();
}
public async Task IncrementViewAsync(string id)
{
var update = Builders<LivePage>.Update
.Inc(x => x.Analytics.TotalViews, 1)
.Set(x => x.Analytics.LastViewedAt, DateTime.UtcNow);
await _collection.UpdateOneAsync(x => x.Id == id, update);
}
public async Task IncrementLinkClickAsync(string id, int linkIndex)
{
var update = Builders<LivePage>.Update
.Inc(x => x.Analytics.TotalClicks, 1);
await _collection.UpdateOneAsync(x => x.Id == id, update);
}
}

View File

@ -31,7 +31,7 @@ public class UserPageRepository : IUserPageRepository
public async Task<UserPage?> GetBySlugAsync(string category, string slug) public async Task<UserPage?> GetBySlugAsync(string category, string slug)
{ {
return await _pages.Find(x => x.Category == category.ToLower() && x.Slug == slug && x.IsActive).FirstOrDefaultAsync(); return await _pages.Find(x => x.Category.ToLower() == category.ToLower() && x.Slug == slug && x.IsActive).FirstOrDefaultAsync();
} }
public async Task<UserPage?> GetByUserIdAsync(string userId) public async Task<UserPage?> GetByUserIdAsync(string userId)
@ -116,4 +116,105 @@ public class UserPageRepository : IUserPageRepository
.Set(x => x.UpdatedAt, DateTime.UtcNow) .Set(x => x.UpdatedAt, DateTime.UtcNow)
); );
} }
}
// Adicione estes métodos no UserPageRepository.cs
public async Task<List<UserPage>> GetManyAsync(
FilterDefinition<UserPage> filter,
SortDefinition<UserPage>? sort = null,
int skip = 0,
int take = 20)
{
var query = _pages.Find(filter);
if (sort != null)
query = query.Sort(sort);
return await query.Skip(skip).Limit(take).ToListAsync();
}
public async Task<long> CountAsync(FilterDefinition<UserPage> filter)
{
return await _pages.CountDocumentsAsync(filter);
}
// Método específico para moderação (mais simples)
public async Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20)
{
var filter = Builders<UserPage>.Filter.Eq(x => x.Status, BCards.Web.ViewModels.PageStatus.PendingModeration);
var sort = Builders<UserPage>.Sort
.Ascending("planLimitations.planType") // Premium primeiro
.Ascending(x => x.CreatedAt); // Mais antigos primeiro
return await _pages.Find(filter)
.Sort(sort)
.Skip(skip)
.Limit(take)
.ToListAsync();
}
// Adicione estes métodos no UserPageRepository.cs
public async Task<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update)
{
var combinedUpdate = Builders<UserPage>.Update
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
return await _pages.UpdateOneAsync(x => x.Id == id, combinedUpdate);
}
public async Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update)
{
var combinedUpdate = Builders<UserPage>.Update
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
return await _pages.UpdateManyAsync(filter, combinedUpdate);
}
// Métodos específicos para moderação (mais fáceis de usar)
public async Task<bool> ApprovePageAsync(string pageId)
{
var update = Builders<UserPage>.Update
.Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Active)
.Set(x => x.ApprovedAt, DateTime.UtcNow)
.Set(x => x.PublishedAt, DateTime.UtcNow)
.Unset(x => x.PreviewToken)
.Unset(x => x.PreviewTokenExpiry)
.Set(x => x.UpdatedAt, DateTime.UtcNow);
var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update);
return result.ModifiedCount > 0;
}
public async Task<bool> RejectPageAsync(string pageId, string reason, List<string> issues)
{
var page = await GetByIdAsync(pageId);
if (page == null) return false;
// Adicionar à história de moderação
var historyEntry = new ModerationHistory
{
Attempt = page.ModerationAttempts + 1,
Status = "rejected",
Reason = reason,
Date = DateTime.UtcNow,
Issues = issues
};
page.ModerationHistory.Add(historyEntry);
var update = Builders<UserPage>.Update
.Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Rejected)
.Set(x => x.ModerationAttempts, page.ModerationAttempts + 1)
.Set(x => x.ModerationHistory, page.ModerationHistory)
.Unset(x => x.PreviewToken)
.Unset(x => x.PreviewTokenExpiry)
.Set(x => x.UpdatedAt, DateTime.UtcNow);
var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update);
return result.ModifiedCount > 0;
}
}

View File

@ -3,6 +3,7 @@ using BCards.Web.Repositories;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using BCards.Web.Utils;
namespace BCards.Web.Services; namespace BCards.Web.Services;
@ -22,7 +23,7 @@ public class CategoryService : ICategoryService
public async Task<Category?> GetCategoryBySlugAsync(string slug) public async Task<Category?> GetCategoryBySlugAsync(string slug)
{ {
return await _categoryRepository.GetBySlugAsync(slug); return await _categoryRepository.GetBySlugAsync(SlugHelper.CreateSlug(slug));
} }
public async Task<string> GenerateSlugAsync(string name) public async Task<string> GenerateSlugAsync(string name)

View File

@ -0,0 +1,207 @@
using SendGrid;
using SendGrid.Helpers.Mail;
namespace BCards.Web.Services;
public class EmailService : IEmailService
{
private readonly ISendGridClient _sendGridClient;
private readonly IConfiguration _configuration;
private readonly ILogger<EmailService> _logger;
public EmailService(
ISendGridClient sendGridClient,
IConfiguration configuration,
ILogger<EmailService> logger)
{
_sendGridClient = sendGridClient;
_configuration = configuration;
_logger = logger;
}
public async Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null)
{
var (subject, htmlContent) = status switch
{
"pending" => GetPendingModerationTemplate(userName, pageTitle, previewUrl),
"approved" => GetApprovedTemplate(userName, pageTitle),
"rejected" => GetRejectedTemplate(userName, pageTitle, reason),
_ => throw new ArgumentException($"Unknown status: {status}")
};
await SendEmailAsync(userEmail, subject, htmlContent);
}
public async Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName)
{
var moderatorEmail = _configuration["Moderation:ModeratorEmail"];
if (string.IsNullOrEmpty(moderatorEmail))
return;
var priority = GetPriorityLabel(planType);
var subject = $"[{priority}] Nova página para moderação - {pageTitle}";
var htmlContent = $@"
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
<h2 style='color: #333;'>Nova página para moderação</h2>
<div style='background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0;'>
<p><strong>Título:</strong> {pageTitle}</p>
<p><strong>Usuário:</strong> {userName}</p>
<p><strong>Plano:</strong> {planType}</p>
<p><strong>Prioridade:</strong> <span style='color: {GetPriorityColor(planType)};'>{priority}</span></p>
<p><strong>ID da Página:</strong> {pageId}</p>
</div>
<p>
<a href='{_configuration["BaseUrl"]}/moderation/review/{pageId}'
style='background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;'>
Moderar Página
</a>
</p>
</div>";
await SendEmailAsync(moderatorEmail, subject, htmlContent);
}
public async Task<bool> SendEmailAsync(string to, string subject, string htmlContent)
{
try
{
var from = new EmailAddress(
_configuration["SendGrid:FromEmail"] ?? "ricardo.carneiro@jobmaker.com.br",
_configuration["SendGrid:FromName"] ?? "BCards");
var toEmail = new EmailAddress(to);
var msg = MailHelper.CreateSingleEmail(from, toEmail, subject, null, htmlContent);
var response = await _sendGridClient.SendEmailAsync(msg);
if (response.StatusCode == System.Net.HttpStatusCode.Accepted)
{
_logger.LogInformation("Email sent successfully to {Email}", to);
return true;
}
else
{
var content = await response.Body.ReadAsStringAsync();
_logger.LogWarning("Failed to send email to {Email}. Status: {StatusCode}", to, response.StatusCode);
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending email to {Email}", to);
return false;
}
}
private (string subject, string htmlContent) GetPendingModerationTemplate(string userName, string pageTitle, string? previewUrl)
{
var subject = "📋 Sua página está sendo analisada - bcards.site";
var previewButton = !string.IsNullOrEmpty(previewUrl)
? $"<p><a href='{previewUrl}' style='background: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>Ver Preview</a></p>"
: "";
var htmlContent = $@"
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
<h2 style='color: #333;'>Olá {userName}!</h2>
<p>Sua página <strong>'{pageTitle}'</strong> foi enviada para análise e estará disponível em breve!</p>
<div style='background: #e3f2fd; padding: 20px; border-radius: 5px; margin: 20px 0;'>
<p>🔍 <strong>Tempo estimado:</strong> 3-7 dias úteis</p>
<p>👀 <strong>Status:</strong> Em análise</p>
</div>
<p>Nossa equipe verifica se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.</p>
{previewButton}
<hr style='margin: 30px 0;'>
<p style='color: #666; font-size: 14px;'>
Você receberá outro email assim que sua página for aprovada ou se precisar de ajustes.
</p>
</div>";
return (subject, htmlContent);
}
private (string subject, string htmlContent) GetApprovedTemplate(string userName, string pageTitle)
{
var subject = "✅ Sua página foi aprovada! - bcards.site";
var htmlContent = $@"
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
<h2 style='color: #28a745;'>Parabéns {userName}! 🎉</h2>
<p>Sua página <strong>'{pageTitle}'</strong> foi aprovada e está no ar!</p>
<div style='background: #d4edda; padding: 20px; border-radius: 5px; margin: 20px 0;'>
<p> <strong>Status:</strong> Aprovada</p>
<p>🌐 <strong>Sua página está online!</strong></p>
</div>
<p>Agora você pode:</p>
<ul>
<li>Compartilhar sua página nas redes sociais</li>
<li>Adicionar o link na sua bio</li>
<li>Acompanhar as estatísticas no painel</li>
</ul>
<p>
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
style='background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
Acessar Painel
</a>
</p>
</div>";
return (subject, htmlContent);
}
private (string subject, string htmlContent) GetRejectedTemplate(string userName, string pageTitle, string? reason)
{
var subject = "⚠️ Sua página precisa de ajustes - bcards.site";
var reasonText = !string.IsNullOrEmpty(reason) ? $"<p><strong>Motivo:</strong> {reason}</p>" : "";
var htmlContent = $@"
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
<h2 style='color: #dc3545;'>Olá {userName}</h2>
<p>Sua página <strong>'{pageTitle}'</strong> não foi aprovada, mas você pode corrigir e reenviar!</p>
<div style='background: #f8d7da; padding: 20px; border-radius: 5px; margin: 20px 0;'>
<p> <strong>Status:</strong> Necessita ajustes</p>
{reasonText}
</div>
<p>Para que sua página seja aprovada, certifique-se de que:</p>
<ul>
<li>Não contém conteúdo proibido ou suspeito</li>
<li>Todos os links estão funcionando</li>
<li>As informações são precisas</li>
<li>Segue nossos termos de uso</li>
</ul>
<p>
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
style='background: #dc3545; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
Editar Página
</a>
</p>
</div>";
return (subject, htmlContent);
}
private string GetPriorityLabel(string planType) => planType.ToLower() switch
{
"premium" => "ALTA",
"professional" => "ALTA",
"basic" => "MÉDIA",
_ => "BAIXA"
};
private string GetPriorityColor(string planType) => planType.ToLower() switch
{
"premium" => "#dc3545",
"professional" => "#fd7e14",
"basic" => "#ffc107",
_ => "#6c757d"
};
}

View File

@ -0,0 +1,199 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Formats.Jpeg;
namespace BCards.Web.Services;
public class GridFSImageStorage : IImageStorageService
{
private readonly IMongoDatabase _database;
private readonly GridFSBucket _gridFS;
private readonly ILogger<GridFSImageStorage> _logger;
private const int TARGET_SIZE = 400;
private const int MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
private static readonly string[] ALLOWED_TYPES = { "image/jpeg", "image/jpg", "image/png", "image/gif" };
public GridFSImageStorage(IMongoDatabase database, ILogger<GridFSImageStorage> logger)
{
_database = database;
_gridFS = new GridFSBucket(database, new GridFSBucketOptions
{
BucketName = "profile_images"
});
_logger = logger;
}
public async Task<string> SaveImageAsync(byte[] imageBytes, string fileName, string contentType)
{
try
{
// Validações
if (imageBytes == null || imageBytes.Length == 0)
throw new ArgumentException("Image bytes cannot be null or empty");
if (imageBytes.Length > MAX_FILE_SIZE)
throw new ArgumentException($"File size exceeds maximum allowed size of {MAX_FILE_SIZE / (1024 * 1024)}MB");
if (!ALLOWED_TYPES.Contains(contentType.ToLower()))
throw new ArgumentException($"Content type {contentType} is not allowed");
// Processar e redimensionar imagem
var processedImage = await ProcessImageAsync(imageBytes);
// Metadata
var options = new GridFSUploadOptions
{
Metadata = new BsonDocument
{
{ "originalFileName", fileName },
{ "contentType", "image/jpeg" }, // Sempre JPEG após processamento
{ "uploadDate", DateTime.UtcNow },
{ "originalSize", imageBytes.Length },
{ "processedSize", processedImage.Length },
{ "dimensions", $"{TARGET_SIZE}x{TARGET_SIZE}" },
{ "version", "1.0" }
}
};
// Nome único para o arquivo
var uniqueFileName = $"profile_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}.jpg";
// Upload para GridFS
var fileId = await _gridFS.UploadFromBytesAsync(uniqueFileName, processedImage, options);
_logger.LogInformation("Image uploaded successfully: {FileId}, Size: {Size}KB",
fileId, processedImage.Length / 1024);
return fileId.ToString();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading image: {FileName}", fileName);
throw;
}
}
public async Task<byte[]?> GetImageAsync(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return null;
if (!ObjectId.TryParse(imageId, out var objectId))
{
_logger.LogWarning("Invalid ObjectId format: {ImageId}", imageId);
return null;
}
var imageBytes = await _gridFS.DownloadAsBytesAsync(objectId);
return imageBytes;
}
catch (GridFSFileNotFoundException)
{
_logger.LogWarning("Image not found: {ImageId}", imageId);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving image: {ImageId}", imageId);
return null;
}
}
public async Task<bool> DeleteImageAsync(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return false;
if (!ObjectId.TryParse(imageId, out var objectId))
return false;
await _gridFS.DeleteAsync(objectId);
_logger.LogInformation("Image deleted successfully: {ImageId}", imageId);
return true;
}
catch (GridFSFileNotFoundException)
{
_logger.LogWarning("Attempted to delete non-existent image: {ImageId}", imageId);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting image: {ImageId}", imageId);
return false;
}
}
public async Task<bool> ImageExistsAsync(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return false;
if (!ObjectId.TryParse(imageId, out var objectId))
return false;
var filter = Builders<GridFSFileInfo>.Filter.Eq("_id", objectId);
var fileInfo = await _gridFS.Find(filter).FirstOrDefaultAsync();
return fileInfo != null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking image existence: {ImageId}", imageId);
return false;
}
}
private async Task<byte[]> ProcessImageAsync(byte[] originalBytes)
{
return await Task.Run(() =>
{
using var originalImage = Image.Load(originalBytes);
// Calcular dimensões mantendo aspect ratio
var (newWidth, newHeight) = CalculateResizeDimensions(
originalImage.Width, originalImage.Height, TARGET_SIZE);
// Criar imagem com fundo branco
using var processedImage = new Image<SixLabors.ImageSharp.PixelFormats.Rgb24>(TARGET_SIZE, TARGET_SIZE);
// Preencher com fundo branco
processedImage.Mutate(ctx => ctx.BackgroundColor(SixLabors.ImageSharp.Color.White));
// Redimensionar a imagem original mantendo aspect ratio
originalImage.Mutate(ctx => ctx.Resize(newWidth, newHeight));
// Calcular posição para centralizar a imagem
var x = (TARGET_SIZE - newWidth) / 2;
var y = (TARGET_SIZE - newHeight) / 2;
// Desenhar a imagem centralizada sobre o fundo branco
processedImage.Mutate(ctx => ctx.DrawImage(originalImage, new Point(x, y), 1f));
// Converter para JPEG com compressão otimizada
using var outputStream = new MemoryStream();
var encoder = new JpegEncoder()
{
Quality = 85 // 85% qualidade
};
processedImage.SaveAsJpeg(outputStream, encoder);
return outputStream.ToArray();
});
}
private static (int width, int height) CalculateResizeDimensions(int originalWidth, int originalHeight, int targetSize)
{
var ratio = Math.Min((double)targetSize / originalWidth, (double)targetSize / originalHeight);
return ((int)(originalWidth * ratio), (int)(originalHeight * ratio));
}
}

View File

@ -0,0 +1,8 @@
namespace BCards.Web.Services;
public interface IEmailService
{
Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null);
Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName);
Task<bool> SendEmailAsync(string to, string subject, string htmlContent);
}

View File

@ -0,0 +1,34 @@
namespace BCards.Web.Services;
public interface IImageStorageService
{
/// <summary>
/// Salva uma imagem no storage, com redimensionamento automático para 400x400px
/// </summary>
/// <param name="imageBytes">Bytes da imagem original</param>
/// <param name="fileName">Nome original do arquivo</param>
/// <param name="contentType">Tipo de conteúdo da imagem</param>
/// <returns>ID único da imagem salva</returns>
Task<string> SaveImageAsync(byte[] imageBytes, string fileName, string contentType);
/// <summary>
/// Recupera os bytes de uma imagem pelo ID
/// </summary>
/// <param name="imageId">ID da imagem</param>
/// <returns>Bytes da imagem ou null se não encontrada</returns>
Task<byte[]?> GetImageAsync(string imageId);
/// <summary>
/// Remove uma imagem do storage
/// </summary>
/// <param name="imageId">ID da imagem</param>
/// <returns>True se removida com sucesso</returns>
Task<bool> DeleteImageAsync(string imageId);
/// <summary>
/// Verifica se uma imagem existe no storage
/// </summary>
/// <param name="imageId">ID da imagem</param>
/// <returns>True se a imagem existe</returns>
Task<bool> ImageExistsAsync(string imageId);
}

View File

@ -0,0 +1,14 @@
using BCards.Web.Models;
namespace BCards.Web.Services;
public interface ILivePageService
{
Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
Task<List<LivePage>> GetAllActiveAsync();
Task<LivePage?> GetLivePageFromUserPageId(string userPageId);
Task<LivePage> SyncFromUserPageAsync(string userPageId);
Task<bool> DeleteByOriginalPageIdAsync(string originalPageId);
Task IncrementViewAsync(string livePageId);
Task IncrementLinkClickAsync(string livePageId, int linkIndex);
}

View File

@ -0,0 +1,11 @@
using System.Security.Claims;
namespace BCards.Web.Services
{
public interface IModerationAuthService
{
bool IsUserModerator(ClaimsPrincipal user);
bool IsEmailModerator(string email);
List<string> GetModeratorEmails();
}
}

View File

@ -0,0 +1,19 @@
using BCards.Web.Models;
namespace BCards.Web.Services;
public interface IModerationService
{
Task<string> GeneratePreviewTokenAsync(string pageId);
Task<bool> ValidatePreviewTokenAsync(string pageId, string token);
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20);
Task<UserPage?> GetPageForModerationAsync(string pageId);
Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null);
Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues);
Task<bool> CanUserCreatePageAsync(string userId);
Task<bool> IncrementPreviewViewAsync(string pageId);
Task<Dictionary<string, int>> GetModerationStatsAsync();
Task<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20);
Task<UserPage?> GetPageByPreviewTokenAsync(string token);
Task DeleteForModerationAsync(string pageId);
}

View File

@ -0,0 +1,10 @@
using BCards.Web.Models;
namespace BCards.Web.Services;
public interface IOpenGraphService
{
Task<OpenGraphData> ExtractDataAsync(string url, string userId);
Task<bool> IsRateLimitedAsync(string userId);
Task<OpenGraphCache?> GetCachedDataAsync(string url);
}

View File

@ -12,4 +12,9 @@ public interface IPaymentService
Task<bool> CancelSubscriptionAsync(string subscriptionId); Task<bool> CancelSubscriptionAsync(string subscriptionId);
Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId); Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId);
Task<PlanLimitations> GetPlanLimitationsAsync(string planType); Task<PlanLimitations> GetPlanLimitationsAsync(string planType);
// Novos métodos para gerenciamento de assinatura
Task<Stripe.Subscription?> GetSubscriptionDetailsAsync(string userId);
Task<List<Invoice>> GetPaymentHistoryAsync(string userId);
Task<string> CreatePortalSessionAsync(string customerId, string returnUrl);
} }

View File

@ -8,6 +8,7 @@ public interface IThemeService
Task<PageTheme?> GetThemeByIdAsync(string themeId); Task<PageTheme?> GetThemeByIdAsync(string themeId);
Task<PageTheme?> GetThemeByNameAsync(string themeName); Task<PageTheme?> GetThemeByNameAsync(string themeName);
Task<string> GenerateCustomCssAsync(PageTheme theme); Task<string> GenerateCustomCssAsync(PageTheme theme);
Task<string> GenerateThemeCSSAsync(PageTheme theme, UserPage page);
Task InitializeDefaultThemesAsync(); Task InitializeDefaultThemesAsync();
PageTheme GetDefaultTheme(); PageTheme GetDefaultTheme();
} }

View File

@ -0,0 +1,118 @@
using BCards.Web.Models;
using BCards.Web.Repositories;
using BCards.Web.ViewModels;
namespace BCards.Web.Services;
public class LivePageService : ILivePageService
{
private readonly ILivePageRepository _livePageRepository;
private readonly IUserPageRepository _userPageRepository;
private readonly ILogger<LivePageService> _logger;
public LivePageService(
ILivePageRepository livePageRepository,
IUserPageRepository userPageRepository,
ILogger<LivePageService> logger)
{
_livePageRepository = livePageRepository;
_userPageRepository = userPageRepository;
_logger = logger;
}
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
{
return await _livePageRepository.GetByCategoryAndSlugAsync(category, slug);
}
public async Task<List<LivePage>> GetAllActiveAsync()
{
return await _livePageRepository.GetAllActiveAsync();
}
public async Task<LivePage?> GetLivePageFromUserPageId(string userPageId)
{
return await _livePageRepository.GetByOriginalPageIdAsync(userPageId);
}
public async Task<LivePage> SyncFromUserPageAsync(string userPageId)
{
var userPage = await _userPageRepository.GetByIdAsync(userPageId);
if (userPage == null)
throw new InvalidOperationException($"UserPage {userPageId} not found");
if (userPage.Status != PageStatus.Active)
throw new InvalidOperationException("UserPage must be Active to sync to LivePage");
// Verificar se já existe LivePage para este UserPage
var existingLivePage = await _livePageRepository.GetByOriginalPageIdAsync(userPageId);
var livePage = new LivePage
{
OriginalPageId = userPageId,
UserId = userPage.UserId,
Category = userPage.Category,
Slug = userPage.Slug,
DisplayName = userPage.DisplayName,
Bio = userPage.Bio,
ProfileImageId = userPage.ProfileImageId,
BusinessType = userPage.BusinessType,
Theme = userPage.Theme,
Links = userPage.Links,
SeoSettings = userPage.SeoSettings,
Language = userPage.Language,
Analytics = new LivePageAnalytics
{
TotalViews = existingLivePage?.Analytics?.TotalViews ?? 0,
TotalClicks = existingLivePage?.Analytics?.TotalClicks ?? 0,
LastViewedAt = existingLivePage?.Analytics?.LastViewedAt
},
PublishedAt = userPage.ApprovedAt ?? DateTime.UtcNow
};
if (existingLivePage != null)
{
// Atualizar existente
livePage.Id = existingLivePage.Id;
livePage.CreatedAt = existingLivePage.CreatedAt;
_logger.LogInformation("Updating existing LivePage {LivePageId} from UserPage {UserPageId}", livePage.Id, userPageId);
return await _livePageRepository.UpdateAsync(livePage);
}
else
{
// Criar nova
_logger.LogInformation("Creating new LivePage from UserPage {UserPageId}", userPageId);
return await _livePageRepository.CreateAsync(livePage);
}
}
public async Task<bool> DeleteByOriginalPageIdAsync(string originalPageId)
{
_logger.LogInformation("Deleting LivePage for UserPage {UserPageId}", originalPageId);
return await _livePageRepository.DeleteByOriginalPageIdAsync(originalPageId);
}
public async Task IncrementViewAsync(string livePageId)
{
try
{
await _livePageRepository.IncrementViewAsync(livePageId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePageId);
}
}
public async Task IncrementLinkClickAsync(string livePageId, int linkIndex)
{
try
{
await _livePageRepository.IncrementLinkClickAsync(livePageId, linkIndex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to increment click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex);
}
}
}

View File

@ -0,0 +1,39 @@
using BCards.Web.Configuration;
using Microsoft.Extensions.Options;
using System.Security.Claims;
namespace BCards.Web.Services
{
public class ModerationAuthService : IModerationAuthService
{
private readonly ModerationSettings _settings;
public ModerationAuthService(IOptions<ModerationSettings> settings)
{
_settings = settings.Value;
}
public bool IsUserModerator(ClaimsPrincipal user)
{
if (!user.Identity?.IsAuthenticated == true)
return false;
var email = user.FindFirst(ClaimTypes.Email)?.Value;
return IsEmailModerator(email);
}
public bool IsEmailModerator(string? email)
{
if (string.IsNullOrWhiteSpace(email))
return false;
return _settings.ModeratorEmails
.Any(moderatorEmail => moderatorEmail.Equals(email, StringComparison.OrdinalIgnoreCase));
}
public List<string> GetModeratorEmails()
{
return _settings.ModeratorEmails.ToList();
}
}
}

View File

@ -0,0 +1,245 @@
using BCards.Web.Models;
using BCards.Web.Repositories;
using BCards.Web.ViewModels;
using MongoDB.Driver;
namespace BCards.Web.Services;
public class ModerationService : IModerationService
{
private readonly IUserPageRepository _userPageRepository;
private readonly IUserRepository _userRepository;
private readonly ILivePageService _livePageService;
private readonly ILogger<ModerationService> _logger;
public ModerationService(
IUserPageRepository userPageRepository,
IUserRepository userRepository,
ILivePageService livePageService,
ILogger<ModerationService> logger)
{
_userPageRepository = userPageRepository;
_userRepository = userRepository;
_livePageService = livePageService;
_logger = logger;
}
public async Task<string> GeneratePreviewTokenAsync(string pageId)
{
var token = Guid.NewGuid().ToString("N")[..16];
var expiry = DateTime.UtcNow.AddDays(30); // Token válido por 30 dias
var page = await _userPageRepository.GetByIdAsync(pageId);
page.PreviewToken = token;
page.PreviewTokenExpiry = expiry;
page.PreviewViewCount = 0;
await _userPageRepository.UpdateAsync(page);
_logger.LogInformation("Generated preview token for page {PageId}", pageId);
return token;
}
public async Task<bool> ValidatePreviewTokenAsync(string pageId, string token)
{
var page = await _userPageRepository.GetByIdAsync(pageId);
if (page == null)
return false;
var isValid = page.PreviewToken == token &&
page.PreviewTokenExpiry > DateTime.UtcNow &&
page.PreviewViewCount < 50;
return isValid;
}
public async Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20)
{
var filter = Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.PendingModeration);
// Ordenar por prioridade do plano e depois por data
var sort = Builders<UserPage>.Sort
.Ascending("planLimitations.planType")
.Ascending(p => p.CreatedAt);
var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take);
return pages.ToList();
}
public async Task<UserPage?> GetPageForModerationAsync(string pageId)
{
var page = await _userPageRepository.GetByIdAsync(pageId);
if (page?.Status != PageStatus.PendingModeration)
return null;
return page;
}
public async Task DeleteForModerationAsync(string pageId)
{
var page = await _userPageRepository.GetByIdAsync(pageId);
await _userPageRepository.DeleteAsync(pageId);
}
public async Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null)
{
var page = await _userPageRepository.GetByIdAsync(pageId);
if (page == null)
throw new ArgumentException("Page not found");
var moderationEntry = new ModerationHistory
{
Attempt = page.ModerationAttempts + 1,
Status = "approved",
ModeratorId = moderatorId,
Date = DateTime.UtcNow,
Reason = notes
};
page.ModerationHistory.Add(moderationEntry);
var update = Builders<UserPage>.Update
.Set(p => p.Status, PageStatus.Active)
.Set(p => p.ApprovedAt, DateTime.UtcNow)
.Set(p => p.ModerationAttempts, page.ModerationAttempts + 1)
.Set(p => p.ModerationHistory, page.ModerationHistory)
.Set(p => p.PublishedAt, DateTime.UtcNow)
.Unset(p => p.PreviewToken)
.Unset(p => p.PreviewTokenExpiry);
await _userPageRepository.UpdateAsync(pageId, update);
_logger.LogInformation("Page {PageId} approved by moderator {ModeratorId}", pageId, moderatorId);
// 🔥 NOVA FUNCIONALIDADE: Sincronizar para LivePage
try
{
await _livePageService.SyncFromUserPageAsync(pageId);
_logger.LogInformation("Page {PageId} synced to LivePages successfully", pageId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sync page {PageId} to LivePages. Approval completed but sync failed.", pageId);
// Não falhar a aprovação se sync falhar
}
}
public async Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues)
{
var page = await _userPageRepository.GetByIdAsync(pageId);
if (page == null)
throw new ArgumentException("Page not found");
var moderationEntry = new ModerationHistory
{
Attempt = page.ModerationAttempts + 1,
Status = "rejected",
ModeratorId = moderatorId,
Date = DateTime.UtcNow,
Reason = reason,
Issues = issues
};
page.ModerationHistory.Add(moderationEntry);
var newStatus = page.ModerationAttempts >= 2 ? PageStatus.Rejected : PageStatus.Inactive;
var userScoreDeduction = Math.Min(20, page.UserScore / 5);
var update = Builders<UserPage>.Update
.Set(p => p.Status, newStatus)
.Set(p => p.ModerationAttempts, page.ModerationAttempts + 1)
.Set(p => p.ModerationHistory, page.ModerationHistory)
.Set(p => p.UserScore, Math.Max(0, page.UserScore - userScoreDeduction))
.Unset(p => p.PreviewToken)
.Unset(p => p.PreviewTokenExpiry);
await _userPageRepository.UpdateAsync(pageId, update);
_logger.LogInformation("Page {PageId} rejected by moderator {ModeratorId}. Reason: {Reason}",
pageId, moderatorId, reason);
// Remover da LivePages se existir
try
{
await _livePageService.DeleteByOriginalPageIdAsync(pageId);
_logger.LogInformation("LivePage removed for rejected UserPage {PageId}", pageId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove LivePage for UserPage {PageId}", pageId);
}
}
public async Task<bool> CanUserCreatePageAsync(string userId)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
return false;
//var rejectedPages = userPages.Count(p => p.Status == PageStatus.Rejected);
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
filter &= Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected);
var rejectedPages = await _userPageRepository.CountAsync(filter);
// Usuários com mais de 2 páginas rejeitadas não podem criar novas
return rejectedPages < 2;
}
public async Task<bool> IncrementPreviewViewAsync(string pageId)
{
var page = await _userPageRepository.GetByIdAsync(pageId);
if (page == null || page.PreviewViewCount >= 50)
return false;
var update = Builders<UserPage>.Update
.Inc(p => p.PreviewViewCount, 1);
await _userPageRepository.UpdateAsync(pageId, update);
return true;
}
public async Task<Dictionary<string, int>> GetModerationStatsAsync()
{
var stats = new Dictionary<string, int>();
var pendingCount = await _userPageRepository.CountAsync(
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.PendingModeration));
var approvedToday = await _userPageRepository.CountAsync(
Builders<UserPage>.Filter.And(
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
Builders<UserPage>.Filter.Gte(p => p.ApprovedAt, DateTime.UtcNow.Date)));
var rejectedToday = await _userPageRepository.CountAsync(
Builders<UserPage>.Filter.And(
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected),
Builders<UserPage>.Filter.Gte(p => p.UpdatedAt, DateTime.UtcNow.Date)));
stats["pending"] = (int)pendingCount;
stats["approvedToday"] = (int)approvedToday;
stats["rejectedToday"] = (int)rejectedToday;
return stats;
}
public async Task<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20)
{
var filter = Builders<UserPage>.Filter.Or(
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected));
var sort = Builders<UserPage>.Sort.Descending(p => p.UpdatedAt);
var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take);
return pages.ToList();
}
public async Task<UserPage?> GetPageByPreviewTokenAsync(string token)
{
var filter = Builders<UserPage>.Filter.And(
Builders<UserPage>.Filter.Eq(p => p.PreviewToken, token),
Builders<UserPage>.Filter.Gt(p => p.PreviewTokenExpiry, DateTime.UtcNow));
var pages = await _userPageRepository.GetManyAsync(filter);
return pages.FirstOrDefault();
}
}

View File

@ -0,0 +1,299 @@
using BCards.Web.Models;
using BCards.Web.Utils;
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Driver;
using HtmlAgilityPack;
using System.Text.RegularExpressions;
using System.Security.Cryptography;
using System.Text;
namespace BCards.Web.Services;
public class OpenGraphService : IOpenGraphService
{
private readonly IMemoryCache _cache;
private readonly ILogger<OpenGraphService> _logger;
private readonly HttpClient _httpClient;
private readonly IMongoCollection<OpenGraphCache> _ogCache;
public OpenGraphService(
IMemoryCache cache,
ILogger<OpenGraphService> logger,
HttpClient httpClient,
IMongoDatabase database)
{
_cache = cache;
_logger = logger;
_httpClient = httpClient;
_ogCache = database.GetCollection<OpenGraphCache>("openGraphCache");
// Configure HttpClient
_httpClient.DefaultRequestHeaders.Clear();
//_httpClient.DefaultRequestHeaders.Add("User-Agent",
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36");
_httpClient.DefaultRequestHeaders.Add("User-Agent",
"facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)");
_httpClient.Timeout = TimeSpan.FromSeconds(10);
}
public async Task<OpenGraphData> ExtractDataAsync(string url, string userId)
{
// 1. Validar domínio
if (!AllowedDomains.IsAllowed(url))
{
_logger.LogWarning("Tentativa de extração de domínio não permitido: {Url} pelo usuário {UserId}", url, userId);
throw new InvalidOperationException("Domínio não permitido. Use apenas e-commerces conhecidos e seguros.");
}
// 2. Verificar rate limit (1 request por minuto por usuário)
var rateLimitKey = $"og_rate_{userId}";
if (_cache.TryGetValue(rateLimitKey, out _))
{
_logger.LogWarning("Rate limit excedido para usuário {UserId}", userId);
throw new InvalidOperationException("Aguarde 1 minuto antes de extrair dados de outro produto.");
}
// 3. Verificar cache no MongoDB
var urlHash = GenerateUrlHash(url);
var cachedData = await GetCachedDataAsync(url);
if (cachedData != null && cachedData.ExpiresAt > DateTime.UtcNow)
{
_logger.LogInformation("Retornando dados do cache MongoDB para URL: {Url}", url);
return new OpenGraphData
{
Title = cachedData.Title,
Description = cachedData.Description,
Image = cachedData.Image,
Price = cachedData.Price,
Currency = cachedData.Currency,
IsValid = cachedData.IsValid,
ErrorMessage = cachedData.ErrorMessage
};
}
// 4. Extrair dados da URL
var extractedData = await ExtractFromUrlAsync(url);
// 5. Salvar no cache MongoDB
await SaveToCacheAsync(url, urlHash, extractedData);
// 6. Aplicar rate limit (1 minuto)
_cache.Set(rateLimitKey, true, TimeSpan.FromMinutes(1));
_logger.LogInformation("Dados extraídos com sucesso para URL: {Url}", url);
return extractedData;
}
public Task<bool> IsRateLimitedAsync(string userId)
{
var rateLimitKey = $"og_rate_{userId}";
return Task.FromResult(_cache.TryGetValue(rateLimitKey, out _));
}
public async Task<OpenGraphCache?> GetCachedDataAsync(string url)
{
var urlHash = GenerateUrlHash(url);
return await _ogCache
.Find(x => x.UrlHash == urlHash && x.ExpiresAt > DateTime.UtcNow)
.FirstOrDefaultAsync();
}
private async Task<OpenGraphData> ExtractFromUrlAsync(string url)
{
try
{
_logger.LogInformation("Iniciando extração de dados para URL: {Url}", url);
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var html = await response.Content.ReadAsStringAsync();
var doc = new HtmlDocument();
doc.LoadHtml(html);
var title = GetMetaContent(doc, "og:title", "title") ?? GetTitleFromHTML(doc);
var description = GetMetaContent(doc, "og:description", "description");
var image = GetMetaContent(doc, "og:image");
var price = GetMetaContent(doc, "og:price:amount") ?? ExtractPriceFromHTML(html, doc);
var currency = GetMetaContent(doc, "og:price:currency") ?? "BRL";
// Limpar e validar dados
title = CleanText(title);
description = CleanText(description);
price = CleanPrice(price);
image = ValidateImageUrl(image, url);
var isValid = !string.IsNullOrEmpty(title);
return new OpenGraphData
{
Title = title,
Description = description,
Image = image,
Price = price,
Currency = currency,
IsValid = isValid
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Falha ao extrair dados de {Url}", url);
return new OpenGraphData
{
IsValid = false,
ErrorMessage = $"Erro ao processar a página: {ex.Message}"
};
}
}
private string? GetMetaContent(HtmlDocument doc, params string[] properties)
{
foreach (var property in properties)
{
var meta = doc.DocumentNode
.SelectSingleNode($"//meta[@property='{property}' or @name='{property}' or @itemprop='{property}']");
var content = meta?.GetAttributeValue("content", null);
if (!string.IsNullOrWhiteSpace(content))
return content;
}
return null;
}
private string? GetTitleFromHTML(HtmlDocument doc)
{
var titleNode = doc.DocumentNode.SelectSingleNode("//title");
return titleNode?.InnerText?.Trim();
}
private string? ExtractPriceFromHTML(string html, HtmlDocument doc)
{
// Regex patterns para encontrar preços em diferentes formatos
var pricePatterns = new[]
{
@"R\$\s*[\d\.,]+",
@"BRL\s*[\d\.,]+",
@"[\$]\s*[\d\.,]+",
@"price[^>]*>([^<]*[\d\.,]+[^<]*)<",
@"valor[^>]*>([^<]*[\d\.,]+[^<]*)<",
@"preço[^>]*>([^<]*[\d\.,]+[^<]*)<"
};
foreach (var pattern in pricePatterns)
{
var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase);
if (match.Success)
{
return match.Value;
}
}
// Tentar encontrar por seletores específicos
var priceSelectors = new[]
{
".price", ".valor", ".preco", "[data-price]", ".price-current",
".price-value", ".product-price", ".sale-price"
};
foreach (var selector in priceSelectors)
{
var priceNode = doc.DocumentNode.SelectSingleNode($"//*[contains(@class, '{selector.Replace(".", "")}')]");
if (priceNode != null)
{
var priceText = priceNode.InnerText?.Trim();
if (Regex.IsMatch(priceText ?? "", @"[\d\.,]+"))
{
return priceText;
}
}
}
return null;
}
private string CleanText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
return Regex.Replace(text.Trim(), @"\s+", " ");
}
private string CleanPrice(string? price)
{
if (string.IsNullOrWhiteSpace(price))
return string.Empty;
// Limpar e formatar preço
var cleanPrice = Regex.Replace(price, @"[^\d\.,R\$]", " ").Trim();
return Regex.Replace(cleanPrice, @"\s+", " ");
}
private string ValidateImageUrl(string? imageUrl, string baseUrl)
{
if (string.IsNullOrWhiteSpace(imageUrl))
return string.Empty;
try
{
// Se for URL relativa, converter para absoluta
if (imageUrl.StartsWith("/"))
{
var baseUri = new Uri(baseUrl);
return $"{baseUri.Scheme}://{baseUri.Host}{imageUrl}";
}
// Validar se é uma URL válida
if (Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri))
{
return uri.ToString();
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Erro ao validar URL da imagem: {ImageUrl}", imageUrl);
}
return string.Empty;
}
private string GenerateUrlHash(string url)
{
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(url.ToLowerInvariant()));
return Convert.ToBase64String(hashBytes);
}
private async Task SaveToCacheAsync(string url, string urlHash, OpenGraphData data)
{
try
{
var cacheItem = new OpenGraphCache
{
Url = url,
UrlHash = urlHash,
Title = data.Title,
Description = data.Description,
Image = data.Image,
Price = data.Price,
Currency = data.Currency,
IsValid = data.IsValid,
ErrorMessage = data.ErrorMessage,
CachedAt = DateTime.UtcNow,
ExpiresAt = data.IsValid ? DateTime.UtcNow.AddHours(24) : DateTime.UtcNow.AddHours(1)
};
// Upsert no MongoDB
await _ogCache.ReplaceOneAsync(
x => x.UrlHash == urlHash,
cacheItem,
new ReplaceOptions { IsUpsert = true }
);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao salvar cache para URL: {Url}", url);
}
}
}

View File

@ -4,6 +4,8 @@ using BCards.Web.Repositories;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Stripe; using Stripe;
using Stripe.Checkout; using Stripe.Checkout;
using Stripe.BillingPortal;
using System.Numerics;
namespace BCards.Web.Services; namespace BCards.Web.Services;
@ -41,7 +43,7 @@ public class PaymentService : IPaymentService
var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name); var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name);
var options = new SessionCreateOptions var options = new Stripe.Checkout.SessionCreateOptions
{ {
PaymentMethodTypes = new List<string> { "card" }, PaymentMethodTypes = new List<string> { "card" },
Mode = "subscription", Mode = "subscription",
@ -63,7 +65,7 @@ public class PaymentService : IPaymentService
} }
}; };
var service = new SessionService(); var service = new Stripe.Checkout.SessionService();
var session = await service.CreateAsync(options); var session = await service.CreateAsync(options);
return session.Url; return session.Url;
@ -114,22 +116,22 @@ public class PaymentService : IPaymentService
{ {
try try
{ {
var stripeEvent = EventUtility.ConstructEvent(requestBody, signature, _stripeSettings.WebhookSecret); var stripeEvent = EventUtility.ConstructEvent(requestBody, signature, _stripeSettings.WebhookSecret, throwOnApiVersionMismatch: false);
switch (stripeEvent.Type) switch (stripeEvent.Type)
{ {
case Events.CheckoutSessionCompleted: case "checkout.session.completed":
var session = stripeEvent.Data.Object as Session; var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
await HandleCheckoutSessionCompletedAsync(session!); await HandleCheckoutSessionCompletedAsync(session!);
break; break;
case Events.InvoicePaymentSucceeded: case "invoice.finalized":
var invoice = stripeEvent.Data.Object as Invoice; var invoice = stripeEvent.Data.Object as Invoice;
await HandleInvoicePaymentSucceededAsync(invoice!); await HandleInvoicePaymentSucceededAsync(invoice!);
break; break;
case Events.CustomerSubscriptionUpdated: case "customer.subscription.updated":
case Events.CustomerSubscriptionDeleted: case "customer.subscription.deleted":
var subscription = stripeEvent.Data.Object as Stripe.Subscription; var subscription = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionUpdatedAsync(subscription!); await HandleSubscriptionUpdatedAsync(subscription!);
break; break;
@ -246,7 +248,7 @@ public class PaymentService : IPaymentService
return Task.FromResult(limitations); return Task.FromResult(limitations);
} }
private async Task HandleCheckoutSessionCompletedAsync(Session session) private async Task HandleCheckoutSessionCompletedAsync(Stripe.Checkout.Session session)
{ {
var userId = session.Metadata["user_id"]; var userId = session.Metadata["user_id"];
var planType = session.Metadata["plan_type"]; var planType = session.Metadata["plan_type"];
@ -254,6 +256,9 @@ public class PaymentService : IPaymentService
var subscriptionService = new SubscriptionService(); var subscriptionService = new SubscriptionService();
var stripeSubscription = await subscriptionService.GetAsync(session.SubscriptionId); var stripeSubscription = await subscriptionService.GetAsync(session.SubscriptionId);
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
var limitations = await GetPlanLimitationsAsync(planType); var limitations = await GetPlanLimitationsAsync(planType);
var subscription = new Models.Subscription var subscription = new Models.Subscription
@ -262,8 +267,8 @@ public class PaymentService : IPaymentService
StripeSubscriptionId = session.SubscriptionId, StripeSubscriptionId = session.SubscriptionId,
PlanType = planType, PlanType = planType,
Status = stripeSubscription.Status, Status = stripeSubscription.Status,
CurrentPeriodStart = stripeSubscription.CurrentPeriodStart, CurrentPeriodStart = subItem.CurrentPeriodStart,
CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd, CurrentPeriodEnd = subItem.CurrentPeriodEnd,
MaxLinks = limitations.MaxLinks, MaxLinks = limitations.MaxLinks,
AllowCustomThemes = limitations.AllowCustomThemes, AllowCustomThemes = limitations.AllowCustomThemes,
AllowAnalytics = limitations.AllowAnalytics, AllowAnalytics = limitations.AllowAnalytics,
@ -286,7 +291,8 @@ public class PaymentService : IPaymentService
private async Task HandleInvoicePaymentSucceededAsync(Invoice invoice) private async Task HandleInvoicePaymentSucceededAsync(Invoice invoice)
{ {
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId); var subscriptionId = GetSubscriptionId(invoice);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (subscription != null) if (subscription != null)
{ {
subscription.Status = "active"; subscription.Status = "active";
@ -299,9 +305,12 @@ public class PaymentService : IPaymentService
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id); var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null) if (subscription != null)
{ {
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
subscription.Status = stripeSubscription.Status; subscription.Status = stripeSubscription.Status;
subscription.CurrentPeriodStart = stripeSubscription.CurrentPeriodStart; subscription.CurrentPeriodStart = subItem.CurrentPeriodStart;
subscription.CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd; subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd;
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd; subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
await _subscriptionRepository.UpdateAsync(subscription); await _subscriptionRepository.UpdateAsync(subscription);
@ -313,10 +322,102 @@ public class PaymentService : IPaymentService
user.SubscriptionStatus = stripeSubscription.Status; user.SubscriptionStatus = stripeSubscription.Status;
if (stripeSubscription.Status != "active") if (stripeSubscription.Status != "active")
{ {
user.CurrentPlan = "free"; // Quando assinatura não está ativa, usuário volta para o plano trial (gratuito)
user.CurrentPlan = "trial";
} }
await _userRepository.UpdateAsync(user); await _userRepository.UpdateAsync(user);
} }
} }
} }
public async Task<Stripe.Subscription?> GetSubscriptionDetailsAsync(string userId)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
return null;
var subscription = await _subscriptionRepository.GetByUserIdAsync(userId);
if (subscription == null || string.IsNullOrEmpty(subscription.StripeSubscriptionId))
return null;
try
{
var service = new SubscriptionService();
return await service.GetAsync(subscription.StripeSubscriptionId);
}
catch (StripeException)
{
return null;
}
}
public async Task<List<Invoice>> GetPaymentHistoryAsync(string userId)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
return new List<Invoice>();
try
{
var service = new InvoiceService();
var options = new InvoiceListOptions
{
Customer = user.StripeCustomerId,
Limit = 50, // Últimas 50 faturas
Status = "paid"
};
var invoices = await service.ListAsync(options);
return invoices.Data;
}
catch (StripeException)
{
return new List<Invoice>();
}
}
public async Task<string> CreatePortalSessionAsync(string customerId, string returnUrl)
{
try
{
var options = new Stripe.BillingPortal.SessionCreateOptions
{
Customer = customerId,
ReturnUrl = returnUrl
};
var service = new Stripe.BillingPortal.SessionService();
var session = await service.CreateAsync(options);
return session.Url;
}
catch (StripeException ex)
{
throw new InvalidOperationException($"Erro ao criar sessão do portal: {ex.Message}");
}
}
private string GetSubscriptionId(Invoice? invoice)
{
if (invoice!=null)
{
var subscriptionLineItem = invoice.Lines?.Data
.FirstOrDefault(line =>
!string.IsNullOrEmpty(line.SubscriptionId) ||
line.Subscription != null
);
string subscriptionId = null;
if (subscriptionLineItem != null)
{
// Tenta obter o ID da assinatura de duas formas diferentes
subscriptionId = subscriptionLineItem.SubscriptionId
?? subscriptionLineItem.Subscription?.Id;
}
return subscriptionId;
}
return null;
}
} }

View File

@ -20,7 +20,7 @@ public class SeoService : ISeoService
Keywords = GenerateKeywords(userPage, category), Keywords = GenerateKeywords(userPage, category),
OgTitle = GeneratePageTitle(userPage, category), OgTitle = GeneratePageTitle(userPage, category),
OgDescription = GeneratePageDescription(userPage, category), OgDescription = GeneratePageDescription(userPage, category),
OgImage = !string.IsNullOrEmpty(userPage.ProfileImage) ? userPage.ProfileImage : $"{_baseUrl}/images/default-og.png", OgImage = !string.IsNullOrEmpty(userPage.ProfileImageId) ? userPage.ProfileImageUrl : $"{_baseUrl}/images/default-og.png",
CanonicalUrl = GenerateCanonicalUrl(userPage), CanonicalUrl = GenerateCanonicalUrl(userPage),
TwitterCard = "summary_large_image" TwitterCard = "summary_large_image"
}; };

View File

@ -1,5 +1,6 @@
using BCards.Web.Models; using BCards.Web.Models;
using MongoDB.Driver; using MongoDB.Driver;
using System.Text;
namespace BCards.Web.Services; namespace BCards.Web.Services;
@ -133,6 +134,128 @@ public class ThemeService : IThemeService
return Task.FromResult(css); return Task.FromResult(css);
} }
public async Task<string> GenerateThemeCSSAsync(PageTheme theme, UserPage page)
{
var css = new StringBuilder();
// CSS base com variáveis do tema
css.AppendLine($":root {{");
css.AppendLine($" --primary-color: {theme.PrimaryColor};");
css.AppendLine($" --secondary-color: {theme.SecondaryColor};");
css.AppendLine($" --background-color: {theme.BackgroundColor};");
css.AppendLine($" --text-color: {theme.TextColor};");
css.AppendLine($"}}");
// CSS específico por tema
switch (theme.Name?.ToLower())
{
case "minimalista":
css.AppendLine(GetMinimalistCSS());
break;
case "corporativo":
css.AppendLine(GetCorporateCSS());
break;
case "dark mode":
css.AppendLine(GetDarkCSS());
break;
case "natureza":
css.AppendLine(GetNatureCSS());
break;
case "vibrante":
css.AppendLine(GetVibrantCSS());
break;
default:
css.AppendLine(await GenerateCustomCssAsync(theme));
break;
}
return css.ToString();
}
private string GetMinimalistCSS() => @"
.profile-card {
background: white;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
border-radius: 12px;
}
.link-button {
background: var(--primary-color);
border-radius: 8px;
}
";
private string GetCorporateCSS() => @"
.user-page {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.profile-card {
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
border: 1px solid #e2e8f0;
}
.link-button {
background: var(--primary-color);
border-radius: 6px;
font-weight: 600;
}
";
private string GetDarkCSS() => @"
.user-page {
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
}
.profile-card {
background: rgba(255,255,255,0.1);
backdrop-filter: blur(15px);
border: 1px solid rgba(255,255,255,0.2);
color: #f9fafb;
}
.link-button {
background: var(--primary-color);
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.profile-name, .profile-bio {
color: #f9fafb;
}
";
private string GetNatureCSS() => @"
.user-page {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
background-image: url('data:image/svg+xml,<svg xmlns=""http://www.w3.org/2000/svg"" viewBox=""0 0 100 100""><defs><pattern id=""grain"" width=""100"" height=""100"" patternUnits=""userSpaceOnUse""><circle cx=""25"" cy=""25"" r=""1"" fill=""%23059669"" opacity=""0.1""/><circle cx=""75"" cy=""75"" r=""1"" fill=""%23059669"" opacity=""0.1""/></pattern></defs><rect width=""100"" height=""100"" fill=""url(%23grain)""/></svg>');
}
.profile-card {
background: rgba(255,255,255,0.9);
backdrop-filter: blur(10px);
border: 1px solid rgba(34, 197, 94, 0.2);
}
.link-button {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
border-radius: 25px;
}
";
private string GetVibrantCSS() => @"
.user-page {
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 50%, #fecaca 100%);
}
.profile-card {
background: rgba(255,255,255,0.95);
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.2);
border: 2px solid rgba(220, 38, 38, 0.1);
}
.link-button {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
border-radius: 30px;
transform: perspective(1000px) rotateX(0deg);
transition: all 0.3s ease;
}
.link-button:hover {
transform: perspective(1000px) rotateX(-5deg) translateY(-5px);
box-shadow: 0 15px 30px rgba(220, 38, 38, 0.3);
}
";
public async Task InitializeDefaultThemesAsync() public async Task InitializeDefaultThemesAsync()
{ {
var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync(); var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync();

View File

@ -157,7 +157,7 @@ public class TrialExpirationService : BackgroundService
Básico - R$ 9,90/mês - 5 links, analytics básicos 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 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 Premium - R$ 29,90/mês - Links ilimitados, temas premium, analytics completos
Seus dados estão seguros e serão restaurados assim que você escolher um plano. Seus dados estão seguros e serão restaurados assim que você escolher um plano.

View File

@ -3,6 +3,7 @@ using BCards.Web.Repositories;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using BCards.Web.Utils;
namespace BCards.Web.Services; namespace BCards.Web.Services;
@ -11,15 +12,18 @@ public class UserPageService : IUserPageService
private readonly IUserPageRepository _userPageRepository; private readonly IUserPageRepository _userPageRepository;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly ISubscriptionRepository _subscriptionRepository; private readonly ISubscriptionRepository _subscriptionRepository;
private readonly ILivePageRepository _livePageRepository;
public UserPageService( public UserPageService(
IUserPageRepository userPageRepository, IUserPageRepository userPageRepository,
IUserRepository userRepository, IUserRepository userRepository,
ISubscriptionRepository subscriptionRepository) ISubscriptionRepository subscriptionRepository,
ILivePageRepository livePageRepository)
{ {
_userPageRepository = userPageRepository; _userPageRepository = userPageRepository;
_userRepository = userRepository; _userRepository = userRepository;
_subscriptionRepository = subscriptionRepository; _subscriptionRepository = subscriptionRepository;
_livePageRepository = livePageRepository;
} }
public async Task<UserPage?> GetPageAsync(string category, string slug) public async Task<UserPage?> GetPageAsync(string category, string slug)
@ -76,7 +80,7 @@ public class UserPageService : IUserPageService
public async Task<string> GenerateSlugAsync(string category, string name) public async Task<string> GenerateSlugAsync(string category, string name)
{ {
var slug = GenerateSlug(name); var slug = SlugHelper.CreateSlug(GenerateSlug(name));
var originalSlug = slug; var originalSlug = slug;
var counter = 1; var counter = 1;
@ -147,14 +151,21 @@ public class UserPageService : IUserPageService
public async Task RecordLinkClickAsync(string pageId, int linkIndex) public async Task RecordLinkClickAsync(string pageId, int linkIndex)
{ {
var page = await _userPageRepository.GetByIdAsync(pageId); var livePageExists = await _livePageRepository.GetByIdAsync(pageId);
if (page?.PlanLimitations.AllowAnalytics != true) return; if (livePageExists == null) return;
if (linkIndex >= 0 && linkIndex < page.Links.Count) var page = await _userPageRepository.GetByIdAsync(livePageExists.OriginalPageId);
var livepage = (LivePage)livePageExists;
if (linkIndex >= 0 && linkIndex < livepage.Links.Count)
{ {
livepage.Links[linkIndex].Clicks++;
page.Links[linkIndex].Clicks++; page.Links[linkIndex].Clicks++;
} }
var analyticsLive = livepage.Analytics;
analyticsLive.TotalClicks++;
var analytics = page.Analytics; var analytics = page.Analytics;
analytics.TotalClicks++; analytics.TotalClicks++;
@ -165,6 +176,7 @@ public class UserPageService : IUserPageService
else else
analytics.MonthlyClicks[monthKey] = 1; analytics.MonthlyClicks[monthKey] = 1;
await _livePageRepository.UpdateAsync(livepage);
await _userPageRepository.UpdateAsync(page); await _userPageRepository.UpdateAsync(page);
} }

View File

@ -0,0 +1 @@
 stripe listen --forward-to localhost:49178/webhook/stripe

View File

@ -0,0 +1,69 @@
namespace BCards.Web.Utils;
public static class AllowedDomains
{
public static readonly HashSet<string> EcommerceWhitelist = new(StringComparer.OrdinalIgnoreCase)
{
// Principais E-commerces Brasileiros
"mercadolivre.com.br", "mercadolibre.com",
"amazon.com.br", "amazon.com",
"magazineluiza.com.br", "magalu.com.br",
"americanas.com", "submarino.com.br",
"extra.com.br", "pontofrio.com.br",
"casasbahia.com.br", "casas.com.br",
"shopee.com.br", "shopee.com", "s.shopee.com.br",
"aliexpress.com", "aliexpress.us",
"netshoes.com.br", "centauro.com.br",
"dafiti.com.br", "kanui.com.br",
"fastshop.com.br", "kabum.com.br",
"pichau.com.br", "terabyteshop.com.br",
// Marketplaces Internacionais Seguros
"ebay.com", "etsy.com", "walmart.com",
"target.com", "bestbuy.com",
// E-commerces de Moda
"zara.com", "hm.com", "gap.com",
"uniqlo.com", "forever21.com",
// Livrarias e Educação
"saraiva.com.br", "livrariacultura.com.br",
"estantevirtual.com.br",
// Casa e Decoração
"mobly.com.br", "tok-stok.com.br",
"westwing.com.br", "madeiramadeira.com.br"
};
public static bool IsAllowed(string url)
{
try
{
var uri = new Uri(url);
var domain = uri.Host.ToLowerInvariant();
// Remove "www." se existir
if (domain.StartsWith("www."))
domain = domain.Substring(4);
return EcommerceWhitelist.Contains(domain);
}
catch
{
return false;
}
}
public static string GetDomainFromUrl(string url)
{
try
{
var uri = new Uri(url);
return uri.Host.ToLowerInvariant().Replace("www.", "");
}
catch
{
return string.Empty;
}
}
}

View File

@ -0,0 +1,72 @@
using BCards.Web.Models;
using BCards.Web.ViewModels;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.ComponentModel.DataAnnotations;
// Atributo de validação customizado para links
public class ConditionalRequiredAttribute : ValidationAttribute
{
private readonly string _dependentProperty;
private readonly object _targetValue;
public ConditionalRequiredAttribute(string dependentProperty, object targetValue)
{
_dependentProperty = dependentProperty;
_targetValue = targetValue;
}
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
var dependentProperty = validationContext.ObjectType.GetProperty(_dependentProperty);
if (dependentProperty == null)
return ValidationResult.Success;
var dependentValue = dependentProperty.GetValue(validationContext.ObjectInstance);
// Se o valor dependente não é o target, não valida
if (!Equals(dependentValue, _targetValue))
return ValidationResult.Success;
// Se é o target value e o campo está vazio, retorna erro
if (value == null || string.IsNullOrWhiteSpace(value.ToString()))
return new ValidationResult(ErrorMessage ?? $"{validationContext.DisplayName} é obrigatório.");
return ValidationResult.Success;
}
}
// Método de extensão para validação personalizada no Controller
public static class ModelStateExtensions
{
public static void ValidateLinks(this ModelStateDictionary modelState, List<ManageLinkViewModel> links)
{
for (int i = 0; i < links.Count; i++)
{
var link = links[i];
// Validação condicional baseada no tipo
if (link.Type == LinkType.Product)
{
// Para links de produto, ProductTitle é obrigatório
if (string.IsNullOrWhiteSpace(link.ProductTitle))
{
modelState.AddModelError($"Links[{i}].ProductTitle", "Título do produto é obrigatório");
}
// Title pode ser vazio para links de produto (será preenchido automaticamente)
modelState.Remove($"Links[{i}].Title");
}
else
{
// Para links normais, Title é obrigatório
if (string.IsNullOrWhiteSpace(link.Title))
{
modelState.AddModelError($"Links[{i}].Title", "Título é obrigatório");
}
// Campos de produto podem ser vazios para links normais
modelState.Remove($"Links[{i}].ProductTitle");
}
}
}
}

View File

@ -0,0 +1,25 @@
using BCards.Web.Models;
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Utils
{
public class ModerationMenuViewComponent : ViewComponent
{
private readonly IModerationAuthService _moderationAuth;
public ModerationMenuViewComponent(IModerationAuthService moderationAuth)
{
_moderationAuth = moderationAuth;
}
public IViewComponentResult Invoke()
{
var user = HttpContext.User;
var isModerator = user.Identity?.IsAuthenticated == true &&
_moderationAuth.IsUserModerator(user);
return View(isModerator);
}
}
}

View File

@ -0,0 +1,116 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Text;
namespace BCards.Web.Utils
{
public static class SlugHelper
{
/// <summary>
/// Remove acentos e caracteres especiais, criando um slug limpo
/// </summary>
public static string RemoveAccents(string text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
// Normalizar para NFD (decompor caracteres acentuados)
var normalizedString = text.Normalize(NormalizationForm.FormD);
var stringBuilder = new StringBuilder();
// Filtrar apenas caracteres que não são marcas diacríticas
foreach (var c in normalizedString)
{
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
{
stringBuilder.Append(c);
}
}
return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
}
/// <summary>
/// Cria um slug limpo e URL-friendly
/// </summary>
public static string CreateSlug(string text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
// 1. Remover acentos
var slug = RemoveAccents(text);
// 2. Converter para minúsculas
slug = slug.ToLowerInvariant();
// 3. Substituir espaços e caracteres especiais por hífen
slug = Regex.Replace(slug, @"[^a-z0-9\s-]", "");
// 4. Substituir múltiplos espaços por hífen único
slug = Regex.Replace(slug, @"[\s-]+", "-");
// 5. Remover hífens do início e fim
slug = slug.Trim('-');
// 6. Limitar tamanho (opcional)
if (slug.Length > 50)
slug = slug.Substring(0, 50).TrimEnd('-');
return slug;
}
/// <summary>
/// Cria uma categoria limpa (sem acentos, minúscula)
/// </summary>
public static string CreateCategorySlug(string category)
{
if (string.IsNullOrWhiteSpace(category))
return string.Empty;
var slug = RemoveAccents(category);
slug = slug.ToLowerInvariant();
slug = Regex.Replace(slug, @"[^a-z0-9]", "");
return slug;
}
/// <summary>
/// Dicionário de conversões comuns para categorias brasileiras
/// </summary>
private static readonly Dictionary<string, string> CategoryMappings = new()
{
{ "saúde", "saude" },
{ "educação", "educacao" },
{ "tecnologia", "tecnologia" },
{ "negócios", "negocios" },
{ "serviços", "servicos" },
{ "alimentação", "alimentacao" },
{ "construção", "construcao" },
{ "automóveis", "automoveis" },
{ "beleza", "beleza" },
{ "esportes", "esportes" },
{ "música", "musica" },
{ "fotografia", "fotografia" }
};
/// <summary>
/// Converte categoria com mapeamento personalizado
/// </summary>
public static string ConvertCategory(string category)
{
if (string.IsNullOrWhiteSpace(category))
return string.Empty;
var lowerCategory = category.ToLowerInvariant().Trim();
// Verificar mapeamento direto
if (CategoryMappings.ContainsKey(lowerCategory))
return CategoryMappings[lowerCategory];
// Fallback para conversão automática
return CreateCategorySlug(category);
}
}
}

View File

@ -0,0 +1,14 @@
using BCards.Web.Services;
using System.Security.Claims;
namespace BCards.Web.Utils
{
public static class ViewExtensions
{
public static bool IsModerator(this ClaimsPrincipal user, IServiceProvider services)
{
var moderationAuth = services.GetRequiredService<IModerationAuthService>();
return moderationAuth.IsUserModerator(user);
}
}
}

View File

@ -20,16 +20,12 @@ public class CreatePageViewModel
[Required(ErrorMessage = "Tema é obrigatório")] [Required(ErrorMessage = "Tema é obrigatório")]
public string SelectedTheme { get; set; } = "minimalist"; public string SelectedTheme { get; set; } = "minimalist";
[Phone(ErrorMessage = "Número de WhatsApp inválido")]
public string WhatsAppNumber { get; set; } = string.Empty; public string WhatsAppNumber { get; set; } = string.Empty;
[Url(ErrorMessage = "URL do Facebook inválida")]
public string FacebookUrl { get; set; } = string.Empty; public string FacebookUrl { get; set; } = string.Empty;
[Url(ErrorMessage = "URL do X/Twitter inválida")]
public string TwitterUrl { get; set; } = string.Empty; public string TwitterUrl { get; set; } = string.Empty;
[Url(ErrorMessage = "URL do Instagram inválida")]
public string InstagramUrl { get; set; } = string.Empty; public string InstagramUrl { get; set; } = string.Empty;
public List<CreateLinkViewModel> Links { get; set; } = new(); public List<CreateLinkViewModel> Links { get; set; } = new();

View File

@ -35,6 +35,10 @@ public class ManagePageViewModel
public string InstagramUrl { get; set; } = string.Empty; public string InstagramUrl { get; set; } = string.Empty;
public List<ManageLinkViewModel> Links { get; set; } = new(); public List<ManageLinkViewModel> Links { get; set; } = new();
// Profile image fields
public string? ProfileImageId { get; set; }
public IFormFile? ProfileImageFile { get; set; }
// Data for dropdowns and selections // Data for dropdowns and selections
public List<Category> AvailableCategories { get; set; } = new(); public List<Category> AvailableCategories { get; set; } = new();
@ -43,6 +47,13 @@ public class ManagePageViewModel
// Plan limitations // Plan limitations
public int MaxLinksAllowed { get; set; } = 3; public int MaxLinksAllowed { get; set; } = 3;
public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower()); public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower());
/// <summary>
/// URL da imagem de perfil ou imagem padrão se não houver upload
/// </summary>
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
? $"/api/image/{ProfileImageId}"
: "/images/default-avatar.svg";
} }
public class ManageLinkViewModel public class ManageLinkViewModel
@ -50,19 +61,35 @@ public class ManageLinkViewModel
public string Id { get; set; } = "new"; public string Id { get; set; } = "new";
[Required(ErrorMessage = "Título é obrigatório")] [Required(ErrorMessage = "Título é obrigatório")]
[StringLength(50, ErrorMessage = "Título deve ter no máximo 50 caracteres")] [StringLength(200, ErrorMessage = "Título deve ter no máximo 50 caracteres")]
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
[Required(ErrorMessage = "URL é obrigatória")] [Required(ErrorMessage = "URL é obrigatória")]
[Url(ErrorMessage = "URL inválida")] [Url(ErrorMessage = "URL inválida")]
public string Url { get; set; } = string.Empty; public string Url { get; set; } = string.Empty;
[StringLength(100, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")] [StringLength(3000, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")]
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty; public string Icon { get; set; } = string.Empty;
public int Order { get; set; } = 0; public int Order { get; set; } = 0;
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
// Campos para Links de Produto
public LinkType Type { get; set; } = LinkType.Normal;
[StringLength(200, ErrorMessage = "Título do produto deve ter no máximo 100 caracteres")]
public string ProductTitle { get; set; } = string.Empty;
public string ProductImage { get; set; } = string.Empty;
[StringLength(50, ErrorMessage = "Preço deve ter no máximo 50 caracteres")]
public string? ProductPrice { get; set; } = string.Empty;
[StringLength(3000, ErrorMessage = "Descrição do produto deve ter no máximo 200 caracteres")]
public string ProductDescription { get; set; } = string.Empty;
public DateTime? ProductDataCachedAt { get; set; }
} }
public class DashboardViewModel public class DashboardViewModel
@ -81,10 +108,13 @@ public class UserPageSummary
public string Slug { get; set; } = string.Empty; public string Slug { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty; public string Category { get; set; } = string.Empty;
public PageStatus Status { get; set; } = PageStatus.Active; public PageStatus Status { get; set; } = PageStatus.Active;
public int TotalClicks { get; set; } = 0; public long TotalClicks { get; set; } = 0;
public int TotalViews { get; set; } = 0; public long TotalViews { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}"; public string? PreviewToken { get; set; } = string.Empty;
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}?preview={PreviewToken}";
public PageStatus? LastModerationStatus { get; set; } = PageStatus.PendingModeration;
public string? Motive { get; set; } = string.Empty;
} }
public class PlanInfo public class PlanInfo
@ -104,5 +134,9 @@ public enum PageStatus
Active, // Funcionando normalmente Active, // Funcionando normalmente
Expired, // Trial vencido -> 301 redirect Expired, // Trial vencido -> 301 redirect
PendingPayment, // Pagamento atrasado -> aviso na página PendingPayment, // Pagamento atrasado -> aviso na página
Inactive // Pausada pelo usuário Inactive, // Pausada pelo usuário
PendingModeration = 4, // Aguardando moderação
Rejected = 5, // Rejeitada na moderação
Creating = 6, // Em desenvolvimento/criação
Approved = 7 // Aprovada
} }

View File

@ -0,0 +1,83 @@
using BCards.Web.Models;
using Stripe;
namespace BCards.Web.ViewModels;
public class ManageSubscriptionViewModel
{
public User User { get; set; } = new();
public Stripe.Subscription? StripeSubscription { get; set; }
public Models.Subscription? LocalSubscription { get; set; }
public List<Invoice> PaymentHistory { get; set; } = new();
public List<AvailablePlanViewModel> AvailablePlans { get; set; } = new();
public string? ErrorMessage { get; set; }
public string? SuccessMessage { get; set; }
// Propriedades calculadas
public bool HasActiveSubscription => StripeSubscription?.Status == "active";
public bool CanUpgrade => HasActiveSubscription && User.CurrentPlan != "premium";
public bool CanDowngrade => HasActiveSubscription && User.CurrentPlan != "basic";
public bool WillCancelAtPeriodEnd => StripeSubscription?.CancelAtPeriodEnd == true;
public DateTime? CurrentPeriodEnd { get; set; }
public DateTime? NextBillingDate => !WillCancelAtPeriodEnd ? CurrentPeriodEnd : null;
public decimal? MonthlyAmount => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.UnitAmount / 100m;
public string? Currency => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.Currency?.ToUpper();
public string StatusDisplayName => (StripeSubscription?.Status) switch
{
"active" => "Ativa",
"past_due" => "Em atraso",
"canceled" => "Cancelada",
"unpaid" => "Não paga",
"incomplete" => "Incompleta",
"incomplete_expired" => "Expirada",
"trialing" => "Em período de teste",
_ => "Desconhecido"
};
public string PlanDisplayName => User.CurrentPlan switch
{
"basic" => "Básico",
"professional" => "Profissional",
"premium" => "Premium",
_ => "Gratuito"
};
}
public class AvailablePlanViewModel
{
public string PlanType { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public decimal Price { get; set; }
public string PriceId { get; set; } = string.Empty;
public int MaxLinks { get; set; }
public bool AllowCustomThemes { get; set; }
public bool AllowAnalytics { get; set; }
public bool AllowCustomDomain { get; set; }
public bool IsCurrentPlan { get; set; }
public bool IsUpgrade { get; set; }
public bool IsDowngrade { get; set; }
public List<string> Features { get; set; } = new();
}
public class PaymentHistoryItemViewModel
{
public string InvoiceId { get; set; } = string.Empty;
public DateTime Date { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string? ReceiptUrl { get; set; }
public string StatusDisplayName => Status switch
{
"paid" => "Pago",
"open" => "Em aberto",
"void" => "Cancelado",
"uncollectible" => "Incobrável",
_ => "Desconhecido"
};
}

View File

@ -0,0 +1,81 @@
using BCards.Web.Models;
namespace BCards.Web.ViewModels;
public class ModerationDashboardViewModel
{
public List<PendingPageViewModel> PendingPages { get; set; } = new();
public Dictionary<string, int> Stats { get; set; } = new();
public int CurrentPage { get; set; } = 1;
public int PageSize { get; set; } = 20;
public bool HasNextPage { get; set; } = false;
public bool HasPreviousPage => CurrentPage > 1;
}
public class ModerationPageViewModel
{
public string Id { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public string Status { get; set; } = string.Empty;
public int ModerationAttempts { get; set; }
public string PlanType { get; set; } = string.Empty;
public string? PreviewUrl { get; set; }
public DateTime? ApprovedAt { get; set; }
public ModerationHistory? LastModerationEntry { get; set; }
public string PriorityLabel => PlanType.ToLower() switch
{
"premium" => "ALTA",
"professional" => "ALTA",
"basic" => "MÉDIA",
_ => "BAIXA"
};
public string PriorityColor => PlanType.ToLower() switch
{
"premium" => "danger",
"professional" => "warning",
"basic" => "info",
_ => "secondary"
};
}
public class ModerationReviewViewModel
{
public UserPage Page { get; set; } = new();
public User User { get; set; } = new();
public string? PreviewUrl { get; set; }
public List<ModerationCriterion> ModerationCriteria { get; set; } = new();
}
public class ModerationHistoryViewModel
{
public List<ModerationPageViewModel> Pages { get; set; } = new();
public int CurrentPage { get; set; } = 1;
public int PageSize { get; set; } = 20;
public bool HasNextPage { get; set; } = false;
public bool HasPreviousPage => CurrentPage > 1;
}
public class ModerationCriterion
{
public string Category { get; set; } = string.Empty;
public List<string> Items { get; set; } = new();
}
public class PendingPageViewModel
{
public string Id { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Slug { get; set; } = "";
public string Category { get; set; } = "";
public string PlanType { get; set; } = "";
public DateTime CreatedAt { get; set; }
public int ModerationAttempts { get; set; }
public string PreviewUrl { get; set; } = "";
public string PriorityLabel { get; set; } = "";
public string PriorityColor { get; set; } = "";
}

View File

@ -548,18 +548,26 @@ function generateLinksData() {
} }
}); });
// Create hidden inputs for links // Remove existing hidden link inputs
$('#linksContainer').append('<div id="linksData"></div>'); $('input[name^="Links["]').remove();
$('#linksData').empty();
// Create hidden inputs for links directly in the form
links.forEach((link, index) => { links.forEach((link, index) => {
$('#linksData').append(` $('#createPageForm').append(`
<input type="hidden" name="Links[${index}].Title" value="${link.Title}" /> <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}].Url" value="${link.Url}" />
<input type="hidden" name="Links[${index}].Description" value="${link.Description}" /> <input type="hidden" name="Links[${index}].Description" value="${link.Description}" />
<input type="hidden" name="Links[${index}].Icon" value="${link.Icon}" /> <input type="hidden" name="Links[${index}].Icon" value="${link.Icon}" />
`); `);
}); });
// Debug: Log what we're sending
console.log('=== DEBUG GENERATELINKSDATA ===');
console.log('Links found:', links.length);
links.forEach((link, index) => {
console.log(`Link ${index}:`, link);
});
console.log('=== FIM DEBUG ===');
} }
function generatePreview() { function generatePreview() {

View File

@ -16,23 +16,43 @@
@if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage)) @if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage))
{ {
<img src="@Model.CurrentUser.ProfileImage" alt="@Model.CurrentUser.Name" <img src="@Model.CurrentUser.ProfileImage" alt="@Model.CurrentUser.Name"
class="rounded-circle" style="width: 60px; height: 60px; object-fit: cover;"> class="rounded-circle" style="width: 60px; height: 60px; object-fit: cover;">
} }
</div> </div>
</div> </div>
<!-- Lista de Páginas --> <!-- Lista de Páginas -->
<div class="row"> <div class="row">
@foreach (var page in Model.UserPages) @foreach (var pageItem in Model.UserPages)
{ {
<div class="col-md-6 col-lg-4 mb-3"> <div class="col-md-6 col-lg-6 mb-4">
<div class="card h-100 @(page.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")"> <div class="card h-100 @(pageItem.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
<div class="card-body"> <div class="card-body">
<h6 class="card-title">@(page.DisplayName)</h6> <h6 class="card-title">
<p class="text-muted small mb-2">@(page.Category)/@(page.Slug)</p> @(pageItem.DisplayName)
<form method="post" action="/Admin/DeletePage/@(pageItem.Id)" style="display: inline;" onsubmit="return confirm('Tem certeza que deseja excluir esta página?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-link text-danger p-0" title="Excluir página"
style="font-size: 12px; text-decoration: none;">
<i class="fas fa-trash"></i>
</button>
</form>
<input type="hidden" id="displayName_@pageItem.Id" value="@(pageItem.DisplayName)" />
</h6>
<p class="text-muted small mb-2">@(pageItem.Category)/@(pageItem.Slug)</p>
<div class="mb-2"> <div class="mb-2">
@switch (page.Status) @{
var pageStatus = pageItem.Status;
if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Inactive)
{
if (pageItem.LastModerationStatus.HasValue)
{
pageStatus = pageItem.LastModerationStatus.Value;
}
}
}
@switch (pageStatus)
{ {
case BCards.Web.ViewModels.PageStatus.Active: case BCards.Web.ViewModels.PageStatus.Active:
<span class="badge bg-success">Ativa</span> <span class="badge bg-success">Ativa</span>
@ -46,41 +66,181 @@
case BCards.Web.ViewModels.PageStatus.Inactive: case BCards.Web.ViewModels.PageStatus.Inactive:
<span class="badge bg-secondary">Inativa</span> <span class="badge bg-secondary">Inativa</span>
break; break;
case BCards.Web.ViewModels.PageStatus.PendingModeration:
<span class="badge bg-warning">Aguardando</span>
break;
case BCards.Web.ViewModels.PageStatus.Rejected:
<span class="badge bg-danger">Rejeitada</span>
break;
case BCards.Web.ViewModels.PageStatus.Creating:
<span class="badge bg-info">
<i class="fas fa-edit me-1"></i>Criando
</span>
break;
} }
</div> </div>
@if (Model.CurrentPlan.AllowsAnalytics) @if (Model.CurrentPlan.AllowsAnalytics)
{ {
<div class="row text-center small mb-3"> <div class="row text-center small mb-3">
<div class="col-6"> <div class="col-6">
<div class="text-primary fw-bold">@(page.TotalViews)</div> <div class="text-primary fw-bold">@(pageItem.TotalViews)</div>
<div class="text-muted">Visualizações</div> <div class="text-muted">Visualizações</div>
</div> </div>
<div class="col-6"> <div class="col-6">
<div class="text-success fw-bold">@(page.TotalClicks)</div> <div class="text-success fw-bold">@(pageItem.TotalClicks)</div>
<div class="text-muted">Cliques</div> <div class="text-muted">Cliques</div>
</div> </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>
<div class="card-footer bg-transparent"> <!-- Cards com Hover Effect -->
<small class="text-muted">Criada em @(page.CreatedAt.ToString("dd/MM/yyyy"))</small> <div class="card-footer" data-page-id="@pageItem.Id" data-status="@pageItem.Status">
<div class="d-flex gap-2">
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Active)
{
<a href="/page/@pageItem.Category/@pageItem.Slug" target="_blank"
class="btn btn-success flex-fill">
<i class="fas fa-eye me-1"></i>Ver Página
</a>
}
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating ||
pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected ||
pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
{
<button type="button"
class="btn btn-outline-info flex-fill"
onclick="openPreview('@pageItem.Id')"
data-page-category="@pageItem.Category"
data-page-slug="@pageItem.Slug">
<i class="fas fa-vial me-1"></i>Testar Página
</button>
}
else
{
<button class="btn btn-secondary flex-fill" disabled>
<i class="fas fa-ban me-1"></i>Indisponível
</button>
}
<div class="btn-group">
<button class="btn btn-outline-secondary dropdown-toggle"
type="button"
id="dropdownMenuButton@(pageItem.Id)"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Mais opções">
<i class="fas fa-cog"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="dropdownMenuButton@(pageItem.Id)">
<!-- Editar - sempre presente -->
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
{
<li>
<span class="dropdown-item disabled">
<i class="fas fa-edit me-2"></i>Editar
</span>
</li>
}
else
{
<li>
<a href="@Url.Action("ManagePage", new { id = pageItem.Id })"
class="dropdown-item">
<i class="fas fa-edit me-2"></i>Editar
</a>
</li>
}
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating ||
pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected)
{
<li><hr class="dropdown-divider"></li>
<li>
<button type="button"
class="dropdown-item"
onclick="submitForModeration('@pageItem.Id')"
data-page-name="@pageItem.DisplayName">
<i class="fas fa-paper-plane me-2"></i>Enviar para Moderação
</button>
</li>
}
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
{
<li><hr class="dropdown-divider"></li>
<li>
<span class="dropdown-item disabled">
<i class="fas fa-hourglass-half me-2"></i>Aguardando Moderação
</span>
</li>
}
</ul>
</div>
</div>
<!-- Informações da página movidas para baixo dos botões -->
<div class="px-3 pt-2 pb-1">
<small class="text-muted">Criada em @(pageItem.CreatedAt.ToString("dd/MM/yyyy"))</small>
@if ((pageItem.LastModerationStatus ?? pageItem.Status) == BCards.Web.ViewModels.PageStatus.Rejected && !string.IsNullOrEmpty(pageItem.Motive))
{
<div class="alert alert-danger alert-dismissible fade show mt-2 mb-0" role="alert">
<div class="d-flex align-items-start">
<i class="fas fa-exclamation-triangle me-2 mt-1"></i>
<div class="flex-grow-1">
<strong>Motivo da rejeição:</strong><br>
<small>@(pageItem.Motive)</small>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
else if (pageItem.LastModerationStatus == BCards.Web.ViewModels.PageStatus.Active && !string.IsNullOrEmpty(pageItem.Motive))
{
<div class="alert alert-info alert-dismissible fade show mt-2 mb-0" role="alert">
<div class="d-flex align-items-start">
<i class="fas fa-exclamation-triangle me-2 mt-1"></i>
<div class="flex-grow-1">
<strong>Motivo:</strong><br>
<small>@(pageItem.Motive)</small>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
</div>
</div> </div>
</div> </div>
</div> </div>
@if ((pageItem.LastModerationStatus ?? pageItem.Status) == BCards.Web.ViewModels.PageStatus.Creating)
{
<div class="col-12">
<div class="alert alert-secondary d-flex align-items-center alert-dismissible alert-permanent fade show">
<i class="fas fa-exclamation-triangle me-3"></i>
<div>
<strong>Página em criação!</strong>
Você pode editar e testar quantas vezes quiser. <br />
Ao terminar, clique em <i class="fas fa-ellipsis-v"></i> para enviar a página <b><span id="pageNameDisplay"></span></b> para moderação!
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
</div>
<script>
var pageNameDisplay = document.getElementById('pageNameDisplay');
var displayName = document.getElementById('displayName_@pageItem.Id');
pageNameDisplay.innerHTML = displayName.value;
</script>
}
} }
<!-- Card para Criar Nova Página --> <!-- Card para Criar Nova Página -->
@if (Model.CanCreateNewPage) @if (Model.CanCreateNewPage)
{ {
<div class="col-md-6 col-lg-4 mb-3"> <div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-dashed text-center" style="border: 2px dashed #dee2e6;"> <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 class="card-body d-flex align-items-center justify-content-center">
<div> <div>
@ -117,7 +277,7 @@
{ {
<!-- Limite atingido --> <!-- Limite atingido -->
<div class="col-12"> <div class="col-12">
<div class="alert alert-warning d-flex align-items-center"> <div class="alert alert-warning d-flex align-items-center alert-permanent">
<i class="fas fa-exclamation-triangle me-3"></i> <i class="fas fa-exclamation-triangle me-3"></i>
<div> <div>
<strong>Limite atingido!</strong> <strong>Limite atingido!</strong>
@ -127,6 +287,7 @@
</div> </div>
</div> </div>
} }
</div> </div>
</div> </div>
@ -179,7 +340,7 @@
</div> </div>
<div class="small mb-3"> <div class="small mb-3">
<i class="fas fa-palette me-2"></i> <i class="fas fa-palette me-2"></i>
Temas customizáveis: @(Model.CurrentPlan.AllowsCustomThemes ? "✅" : "❌") Temas premium: @(Model.CurrentPlan.AllowsCustomThemes ? "✅" : "❌")
</div> </div>
@if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial) @if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial)
@ -271,6 +432,140 @@
</div> </div>
</div> </div>
@section Scripts {
<script>
// Função para abrir preview com token fresh
async function openPreview(pageId) {
const button = event.target.closest('button');
const category = button.dataset.pageCategory;
const slug = button.dataset.pageSlug;
// Desabilitar botão temporariamente
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Carregando...';
try {
// Gerar novo token
const response = await fetch(`/Admin/GeneratePreviewToken/${pageId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
const result = await response.json();
if (result.success) {
// Abrir preview em nova aba com token novo
const previewUrl = `${window.location.origin}/page/${category}/${slug}?preview=${result.previewToken}`;
window.open(previewUrl, '_blank');
} else {
showToast(result.message || 'Erro ao gerar preview', 'error');
}
} catch (error) {
console.error('Erro ao gerar preview:', error);
showToast('Erro ao gerar preview. Tente novamente.', 'error');
} finally {
// Reabilitar botão
button.disabled = false;
button.innerHTML = originalText;
}
}
async function submitForModeration(pageId) {
const pageName = event.target.dataset.pageName || 'esta página';
if (!confirm(`Enviar "${pageName}" para moderação?\n\nApós enviar, você não poderá mais editá-la até receber o resultado da análise.`)) {
return;
}
// Desabilitar botão durante envio
const button = event.target;
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando...';
try {
const response = await fetch(`/Admin/SubmitForModeration/${pageId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
const result = await response.json();
if (result.success) {
// Mostrar toast de sucesso
showToast(result.message, 'success');
// Recarregar página após 2 segundos
setTimeout(() => {
location.reload();
}, 2000);
} else {
showToast(result.message || 'Erro ao enviar página', 'error');
// Reabilitar botão
button.disabled = false;
button.innerHTML = originalText;
}
} catch (error) {
console.error('Erro:', error);
showToast('Erro ao enviar página para moderação', 'error');
// Reabilitar botão
button.disabled = false;
button.innerHTML = originalText;
}
}
function showToast(message, type) {
const toastContainer = getOrCreateToastContainer();
const bgClass = type === 'success' ? 'bg-success' : type === 'error' ? 'bg-danger' : 'bg-info';
const icon = type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-triangle' : 'fa-info-circle';
const toastHtml = `
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="5000">
<div class="toast-header ${bgClass} text-white">
<i class="fas ${icon} me-2"></i>
<strong class="me-auto">${type === 'success' ? 'Sucesso' : type === 'error' ? 'Erro' : 'Informação'}</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">${message}</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const newToast = toastContainer.lastElementChild;
const toast = new bootstrap.Toast(newToast);
toast.show();
// Remover toast após ser fechado
newToast.addEventListener('hidden.bs.toast', function() {
newToast.remove();
});
}
function getOrCreateToastContainer() {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container position-fixed top-0 end-0 p-3';
container.style.zIndex = '1055';
document.body.appendChild(container);
}
return container;
}
</script>
}
@if (TempData["Success"] != null) @if (TempData["Success"] != null)
{ {
<div class="toast-container position-fixed top-0 end-0 p-3"> <div class="toast-container position-fixed top-0 end-0 p-3">
@ -301,4 +596,4 @@
</div> </div>
</div> </div>
</div> </div>
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,10 @@
@{ @{
var isPreview = ViewBag.IsPreview as bool? ?? false; //var isPreview = ViewBag.IsPreview as bool? ?? false;
ViewData["Title"] = "BCards - Crie seu LinkTree Profissional"; ViewData["Title"] = "BCards - Crie sua bio / links Profissional";
var categories = ViewBag.Categories as List<BCards.Web.Models.Category> ?? new List<BCards.Web.Models.Category>(); 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>(); var recentPages = ViewBag.RecentPages as List<BCards.Web.Models.UserPage> ?? new List<BCards.Web.Models.UserPage>();
Layout = isPreview ? "_Layout" : "_UserPageLayout"; //Layout = isPreview ? "_Layout" : "_UserPageLayout";
Layout = "_Layout";
} }
<div class="hero-section bg-primary bg-gradient text-white py-5 mb-5"> <div class="hero-section bg-primary bg-gradient text-white py-5 mb-5">
@ -14,7 +15,8 @@
Crie sua página profissional em minutos Crie sua página profissional em minutos
</h1> </h1>
<p class="lead mb-4"> <p class="lead mb-4">
A melhor alternativa ao LinkTree para profissionais e empresas no Brasil. A melhor alternativa ao para ter uma página de links simples.
Criada para profissionais e empresas no Brasil.
Organize todos os seus links em uma página única e profissional. Organize todos os seus links em uma página única e profissional.
</p> </p>
<div class="d-flex gap-3 flex-wrap"> <div class="d-flex gap-3 flex-wrap">
@ -112,16 +114,16 @@
<div class="col-md-4"> <div class="col-md-4">
<div class="card h-100 border-0 shadow-sm"> <div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center"> <div class="card-body text-center">
@if (!string.IsNullOrEmpty(page.ProfileImage)) @if (!string.IsNullOrEmpty(page.ProfileImageId))
{ {
<img src="@(page.ProfileImage)" alt="@(page.DisplayName)" <img src="@(page.ProfileImageUrl)" alt="@(page.DisplayName)"
class="rounded-circle mb-3" style="width: 60px; height: 60px; object-fit: cover;"> class="rounded-circle mb-3" style="width: 60px; height: 60px; object-fit: cover;">
} }
else else
{ {
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3" <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;"> style="width: 60px; height: 60px;">
<i class="fs-4 text-primary">👤</i> <i class="fas fa-id-card text-primary"></i>
</div> </div>
} }
<h6 class="card-title">@(page.DisplayName)</h6> <h6 class="card-title">@(page.DisplayName)</h6>

View File

@ -1,7 +1,8 @@
@{ @{
ViewData["Title"] = "Planos e Preços - BCards"; ViewData["Title"] = "Planos e Preços - BCards";
var isPreview = ViewBag.IsPreview as bool? ?? false; //var isPreview = ViewBag.IsPreview as bool? ?? false;
Layout = isPreview ? "_Layout" : "_UserPageLayout"; //Layout = isPreview ? "_Layout" : "_UserPageLayout";
Layout = "_Layout";
} }
<div class="container py-5"> <div class="container py-5">
@ -41,7 +42,7 @@
</li> </li>
<li class="mb-3"> <li class="mb-3">
<i class="text-muted me-2">✗</i> <i class="text-muted me-2">✗</i>
<span class="text-muted">Domínio personalizado</span> <span class="text-muted">Página rápida</span>
</li> </li>
</ul> </ul>
</div> </div>
@ -88,11 +89,11 @@
</li> </li>
<li class="mb-3"> <li class="mb-3">
<i class="text-muted me-2">✗</i> <i class="text-muted me-2">✗</i>
<span class="text-muted">Domínio personalizado</span> <span class="text-muted">Página rápida</span>
</li> </li>
<li class="mb-3"> <li class="mb-3">
<i class="text-muted me-2">✗</i> <i class="text-muted me-2">✗</i>
<span class="text-muted">Temas customizáveis</span> <span class="text-muted">Temas premium</span>
</li> </li>
</ul> </ul>
</div> </div>
@ -100,7 +101,7 @@
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
{ {
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post"> <form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
<input type="hidden" name="planType" value="basic" /> <input type="hidden" name="planType" value="Basic" />
<button type="submit" class="btn btn-outline-primary w-100">Escolher Básico</button> <button type="submit" class="btn btn-outline-primary w-100">Escolher Básico</button>
</form> </form>
} }
@ -154,7 +155,7 @@
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
{ {
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post"> <form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
<input type="hidden" name="planType" value="professional" /> <input type="hidden" name="planType" value="Professional" />
<button type="submit" class="btn btn-warning w-100">Escolher Profissional</button> <button type="submit" class="btn btn-warning w-100">Escolher Profissional</button>
</form> </form>
} }
@ -188,7 +189,7 @@
</li> </li>
<li class="mb-3"> <li class="mb-3">
<i class="text-success me-2">✓</i> <i class="text-success me-2">✓</i>
Temas customizáveis Temas premium
</li> </li>
<li class="mb-3"> <li class="mb-3">
<i class="text-success me-2">✓</i> <i class="text-success me-2">✓</i>
@ -212,7 +213,7 @@
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
{ {
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post"> <form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
<input type="hidden" name="planType" value="premium" /> <input type="hidden" name="planType" value="Premium" />
<button type="submit" class="btn btn-primary w-100 fw-bold">Escolher Premium</button> <button type="submit" class="btn btn-primary w-100 fw-bold">Escolher Premium</button>
</form> </form>
} }
@ -342,4 +343,52 @@
</div> </div>
</div> </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>
}
@if (TempData["Info"] != 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-primary 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["Info"]
</div>
</div>
</div>
}

View File

@ -0,0 +1,60 @@
@using BCards.Web.ViewModels
@model ModerationDashboardViewModel
@{
ViewData["Title"] = "Dashboard de Moderação";
Layout = "_Layout";
}
<div class="container">
<h1>Dashboard de Moderação</h1>
<div class="alert alert-info">
<h4>Sistema de Moderação</h4>
<p>Páginas pendentes: <strong>@Model.PendingPages.Count</strong></p>
</div>
@if (Model.PendingPages.Any())
{
<div class="card">
<div class="card-header">
<h5>Páginas Pendentes</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Nome</th>
<th>Categoria</th>
<th>Criada em</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
@foreach (var pageItem in Model.PendingPages)
{
<tr>
<td>@pageItem.DisplayName</td>
<td>@pageItem.Category</td>
<td>@pageItem.CreatedAt.ToString("dd/MM/yyyy")</td>
<td>
<a href="/moderation/review/@pageItem.Id" class="btn btn-sm btn-primary">
Moderar
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
else
{
<div class="alert alert-success">
<h4>✅ Nenhuma página pendente!</h4>
<p>Todas as páginas foram processadas.</p>
</div>
}
</div>

View File

@ -0,0 +1,83 @@
@using BCards.Web.ViewModels
@model ModerationHistoryViewModel
@{
ViewData["Title"] = "Histórico de Moderação";
Layout = "_Layout";
}
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Histórico de Moderação</h1>
<a href="/moderation/dashboard" class="btn btn-outline-primary">
<i class="fas fa-arrow-left"></i> Voltar
</a>
</div>
<div class="alert alert-info">
<h4>Histórico</h4>
<p>Páginas processadas: <strong>@Model.Pages.Count</strong></p>
</div>
@if (Model.Pages.Any())
{
<div class="card">
<div class="card-header">
<h5>Páginas Processadas</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Nome</th>
<th>Categoria</th>
<th>Processada em</th>
</tr>
</thead>
<tbody>
@foreach (var pageItem in Model.Pages)
{
<tr>
<td>
@if (pageItem.Status == "Active")
{
<span class="badge bg-success">Aprovada</span>
}
else if (pageItem.Status == "Rejected")
{
<span class="badge bg-danger">Rejeitada</span>
}
else
{
<span class="badge bg-secondary">@pageItem.Status</span>
}
</td>
<td>@pageItem.DisplayName</td>
<td>@pageItem.Category</td>
<td>
@if (pageItem.ApprovedAt.HasValue)
{
@pageItem.ApprovedAt.Value.ToString("dd/MM/yyyy")
}
else
{
<span class="text-muted">Pendente</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
else
{
<div class="alert alert-success">
<h4>📋 Nenhum histórico ainda</h4>
<p>Ainda não há páginas processadas.</p>
</div>
}
</div>

View File

@ -0,0 +1,267 @@
@using BCards.Web.ViewModels
@model ModerationReviewViewModel
@{
ViewData["Title"] = "Revisar Página";
Layout = "_Layout";
}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Moderar Página</h1>
<a href="/moderation/dashboard" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Voltar
</a>
</div>
</div>
</div>
<div class="row">
<!-- Informações da Página -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Informações da Página</h5>
</div>
<div class="card-body">
<div class="mb-3">
<strong>Nome:</strong> @Model.Page.DisplayName
</div>
<div class="mb-3">
<strong>Categoria:</strong>
<span class="badge bg-light text-dark">@Model.Page.Category</span>
</div>
<div class="mb-3">
<strong>Slug:</strong> @Model.Page.Slug
</div>
<div class="mb-3">
<strong>Tipo:</strong> @Model.Page.BusinessType
</div>
<div class="mb-3">
<strong>Plano:</strong>
<span class="badge bg-info">@Model.Page.PlanLimitations.PlanType</span>
</div>
<div class="mb-3">
<strong>Criado em:</strong> @Model.Page.CreatedAt.ToString("dd/MM/yyyy HH:mm")
</div>
<div class="mb-3">
<strong>Tentativas:</strong> @Model.Page.ModerationAttempts
</div>
<div class="mb-3">
<strong>Total de Links:</strong> @Model.Page.Links.Count
</div>
</div>
</div>
<!-- Informações do Usuário -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Informações do Usuário</h5>
</div>
<div class="card-body">
<div class="mb-3">
<strong>Nome:</strong> @Model.User.Name
</div>
<div class="mb-3">
<strong>Email:</strong> @Model.User.Email
</div>
<div class="mb-3">
<strong>Score:</strong> @Model.Page.UserScore
</div>
<div class="mb-3">
<strong>Membro desde:</strong> @Model.User.CreatedAt.ToString("dd/MM/yyyy")
</div>
</div>
</div>
<!-- Preview da Página -->
@if (!string.IsNullOrEmpty(Model.PreviewUrl))
{
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Preview da Página</h5>
</div>
<div class="card-body">
<a href="@Model.PreviewUrl" target="_blank" class="btn btn-info btn-block">
<i class="fas fa-external-link-alt"></i> Abrir Preview
</a>
<small class="text-muted mt-2 d-block">
Visualizações: @Model.Page.PreviewViewCount/50
</small>
</div>
</div>
}
</div>
<!-- Conteúdo da Página -->
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Conteúdo da Página</h5>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(Model.Page.Bio))
{
<div class="mb-3">
<strong>Biografia:</strong>
<p>@Model.Page.Bio</p>
</div>
}
<div class="mb-3">
<strong>Links (@Model.Page.Links.Count):</strong>
<div class="mt-2">
@foreach (var link in Model.Page.Links.OrderBy(l => l.Order))
{
<div class="card mb-2">
<div class="card-body py-2">
<div class="d-flex justify-content-between">
<div>
<strong>@link.Title</strong>
@if (link.Type == LinkType.Product)
{
<span class="badge bg-success ms-2">Produto</span>
}
<br>
<small class="text-muted">@link.Url</small>
@if (!string.IsNullOrEmpty(link.Description))
{
<br>
<small>@link.Description</small>
}
</div>
<div>
<a href="@link.Url" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
<!-- Critérios de Moderação -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Critérios de Moderação</h5>
</div>
<div class="card-body">
<form id="moderationForm">
@foreach (var criterion in Model.ModerationCriteria)
{
<div class="mb-4">
<h6 class="text-danger">🚫 @criterion.Category</h6>
@foreach (var item in criterion.Items)
{
<div class="form-check">
<input class="form-check-input" type="checkbox" name="issues" value="@item" id="issue_@(item.GetHashCode())">
<label class="form-check-label" for="issue_@(item.GetHashCode())">
@item
</label>
</div>
}
</div>
}
</form>
</div>
</div>
<!-- Histórico de Moderação -->
@if (Model.Page.ModerationHistory.Any())
{
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">Histórico de Moderação</h5>
</div>
<div class="card-body">
@foreach (var history in Model.Page.ModerationHistory.OrderByDescending(h => h.Date))
{
<div class="mb-3 pb-3 border-bottom">
<div class="d-flex justify-content-between">
<strong>Tentativa @history.Attempt</strong>
<small class="text-muted">@history.Date.ToString("dd/MM/yyyy HH:mm")</small>
</div>
<div>
<span class="badge bg-@(history.Status == "approved" ? "success" : "danger")">
@(history.Status == "approved" ? "Aprovada" : "Rejeitada")
</span>
</div>
@if (!string.IsNullOrEmpty(history.Reason))
{
<div class="mt-2">
<strong>Motivo:</strong> @history.Reason
</div>
}
@if (history.Issues.Any())
{
<div class="mt-2">
<strong>Problemas:</strong>
<ul class="mb-0">
@foreach (var issue in history.Issues)
{
<li>@issue</li>
}
</ul>
</div>
}
</div>
}
</div>
</div>
}
<!-- Ações de Moderação -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Ações de Moderação</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<form asp-action="Approve" asp-route-id="@Model.Page.Id" method="post">
@Html.AntiForgeryToken()
<div class="mb-3">
<label for="notes" class="form-label">Notas (opcional)</label>
<textarea class="form-control" id="notes" name="notes" rows="3"
placeholder="Observações sobre a aprovação..."></textarea>
</div>
<button type="submit" class="btn btn-success btn-block">
<i class="fas fa-check"></i> Aprovar Página
</button>
</form>
</div>
<div class="col-md-6">
<form asp-action="Reject" asp-route-id="@Model.Page.Id" method="post">
@Html.AntiForgeryToken()
<div class="mb-3">
<label for="reason" class="form-label">Motivo da Rejeição *</label>
<textarea class="form-control" id="reason" name="reason" rows="3"
placeholder="Explique o motivo da rejeição..." required></textarea>
</div>
<input type="hidden" id="selectedIssues" name="issues" value="">
<button type="submit" class="btn btn-danger btn-block">
<i class="fas fa-times"></i> Rejeitar Página
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
document.querySelector('form[asp-action="Reject"]').addEventListener('submit', function(e) {
const checkedIssues = Array.from(document.querySelectorAll('input[name="issues"]:checked'))
.map(cb => cb.value);
document.getElementById('selectedIssues').value = JSON.stringify(checkedIssues);
});
</script>
}

View File

@ -0,0 +1,291 @@
@model BCards.Web.ViewModels.ManageSubscriptionViewModel
@{
ViewData["Title"] = "Gerenciar Assinatura";
Layout = "_Layout";
}
<div class="container mt-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">
<i class="fas fa-credit-card me-2"></i>
Gerenciar Assinatura
</h2>
<a href="@Url.Action("Dashboard", "Admin")" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>
Voltar ao Dashboard
</a>
</div>
<!-- Alerts -->
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
@Model.ErrorMessage
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle me-2"></i>
@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!string.IsNullOrEmpty(TempData["Error"]?.ToString()))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!string.IsNullOrEmpty(TempData["Success"]?.ToString()))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle me-2"></i>
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<!-- Current Subscription Card -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="fas fa-star me-2"></i>
Assinatura Atual
</h5>
</div>
<div class="card-body">
@if (Model.HasActiveSubscription)
{
<div class="row">
<div class="col-md-6">
<h4 class="text-primary">Plano @Model.PlanDisplayName</h4>
<p class="text-muted mb-2">
Status: <span class="badge bg-success">@Model.StatusDisplayName</span>
</p>
@if (Model.MonthlyAmount.HasValue)
{
<p class="mb-2">
<strong>R$ @Model.MonthlyAmount.Value.ToString("F2")</strong> / mês
</p>
}
</div>
<div class="col-md-6">
@if (Model.NextBillingDate.HasValue)
{
<p class="mb-2">
<i class="fas fa-calendar me-2"></i>
Próxima cobrança: <strong>@Model.NextBillingDate.Value.ToString("dd/MM/yyyy")</strong>
</p>
}
@if (Model.WillCancelAtPeriodEnd)
{
<p class="text-warning mb-2">
<i class="fas fa-exclamation-triangle me-2"></i>
Assinatura será cancelada em @Model.CurrentPeriodEnd?.ToString("dd/MM/yyyy")
</p>
}
</div>
</div>
<div class="mt-3">
<div class="d-flex gap-2 flex-wrap">
@if (!Model.WillCancelAtPeriodEnd)
{
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#cancelModal">
<i class="fas fa-times me-1"></i>
Cancelar Assinatura
</button>
}
<form method="post" action="@Url.Action("OpenStripePortal")" class="d-inline">
<button type="submit" class="btn btn-outline-primary">
<i class="fas fa-external-link-alt me-1"></i>
Portal de Pagamento
</button>
</form>
</div>
</div>
}
else
{
<div class="text-center py-4">
<i class="fas fa-info-circle text-muted" style="font-size: 3rem;"></i>
<h5 class="mt-3">Nenhuma assinatura ativa</h5>
<p class="text-muted">Você está usando o plano gratuito. Faça upgrade para desbloquear mais recursos!</p>
<a href="@Url.Action("Pricing", "Home")" class="btn btn-primary">
<i class="fas fa-upgrade me-1"></i>
Ver Planos
</a>
</div>
}
</div>
</div>
<!-- Available Plans -->
@if (Model.HasActiveSubscription && (Model.CanUpgrade || Model.CanDowngrade))
{
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-exchange-alt me-2"></i>
Alterar Plano
</h5>
</div>
<div class="card-body">
<div class="row">
@foreach (var plan in Model.AvailablePlans.Where(p => !p.IsCurrentPlan))
{
<div class="col-md-4 mb-3">
<div class="card h-100 @(plan.IsUpgrade ? "border-success" : plan.IsDowngrade ? "border-warning" : "")">
<div class="card-body text-center">
<h5 class="card-title">@plan.DisplayName</h5>
<h4 class="text-primary mb-3">R$ @plan.Price.ToString("F2")</h4>
<ul class="list-unstyled text-start mb-3">
@foreach (var feature in plan.Features)
{
<li class="mb-1">
<i class="fas fa-check text-success me-2"></i>
@feature
</li>
}
</ul>
<form method="post" action="@Url.Action("ChangePlan")">
<input type="hidden" name="newPlanType" value="@plan.PlanType" />
<button type="submit" class="btn @(plan.IsUpgrade ? "btn-success" : "btn-warning") w-100">
<i class="fas @(plan.IsUpgrade ? "fa-arrow-up" : "fa-arrow-down") me-1"></i>
@(plan.IsUpgrade ? "Fazer Upgrade" : "Fazer Downgrade")
</button>
</form>
</div>
</div>
</div>
}
</div>
</div>
</div>
}
<!-- Payment History -->
@if (Model.PaymentHistory.Any())
{
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-history me-2"></i>
Histórico de Pagamentos
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Data</th>
<th>Descrição</th>
<th>Valor</th>
<th>Status</th>
<th>Recibo</th>
</tr>
</thead>
<tbody>
@foreach (var invoice in Model.PaymentHistory.Take(10))
{
<tr>
<td>@invoice.Created.ToString("dd/MM/yyyy")</td>
<td>
@if (!string.IsNullOrEmpty(invoice.Description))
{
@invoice.Description
}
else
{
<span>Assinatura @Model.PlanDisplayName</span>
}
</td>
<td>
<strong>R$ @((invoice.AmountPaid / 100m).ToString("F2"))</strong>
</td>
<td>
<span class="badge bg-success">Pago</span>
</td>
<td>
@if (!string.IsNullOrEmpty(invoice.HostedInvoiceUrl))
{
<a href="@invoice.HostedInvoiceUrl" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="fas fa-download me-1"></i>
Ver
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- Cancel Subscription Modal -->
<div class="modal fade" id="cancelModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
Cancelar Assinatura
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Tem certeza que deseja cancelar sua assinatura?</p>
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Sua assinatura permanecerá ativa até o final do período atual
(@Model.CurrentPeriodEnd?.ToString("dd/MM/yyyy")).
Após essa data, você retornará ao plano gratuito.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Manter Assinatura
</button>
@if (Model.StripeSubscription != null)
{
<form method="post" action="@Url.Action("CancelSubscription")" style="display: inline;">
<input type="hidden" name="subscriptionId" value="@Model.StripeSubscription.Id" />
<button type="submit" class="btn btn-danger">
<i class="fas fa-times me-1"></i>
Confirmar Cancelamento
</button>
</form>
}
</div>
</div>
</div>
</div>
@section Scripts {
<script>
// Auto-dismiss alerts after 5 seconds
setTimeout(function() {
$('.alert:not(.alert-dismissible)').fadeOut();
}, 5000);
</script>
}

View File

@ -0,0 +1,36 @@
@model bool
@if (Model)
{
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle @(ViewBag.IsHomePage == true ? "text-warning" : "text-warning") fw-bold"
href="#" id="moderationDropdown" role="button"
data-bs-toggle="dropdown" aria-expanded="false"
style="@(ViewBag.IsHomePage == true ? "color: #fbbf24 !important;" : "")">
<i class="fas fa-shield-alt me-1"></i>Moderação
</a>
<ul class="dropdown-menu" aria-labelledby="moderationDropdown">
<li>
<h6 class="dropdown-header">
<i class="fas fa-shield-alt me-2"></i>Área de Moderação
</h6>
</li>
<li>
<a class="dropdown-item" href="/moderation/dashboard">
<i class="fas fa-tachometer-alt me-2"></i>Dashboard
</a>
</li>
<li>
<a class="dropdown-item" href="/moderation/history">
<i class="fas fa-history me-2"></i>Histórico
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-muted small" href="#" onclick="return false;">
<i class="fas fa-info-circle me-2"></i>Você é um moderador
</a>
</li>
</ul>
</li>
}

View File

@ -3,22 +3,22 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(ViewData["Title"] ?? "BCards - Crie seu LinkTree Profissional")</title> <title>@(ViewData["Title"] ?? "BCards - Crie sua bios / links Profissional")</title>
@if (ViewBag.SeoSettings != null) @if (ViewBag.SeoSettings != null)
{ {
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings; var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
<meta name="description" content="@seo?.Description" /> <meta name="description" content="@seo?.Description" />
<meta name="keywords" content="@string.Join(", ", seo?.Keywords ?? new List<string>())" /> <meta name="keywords" content="@string.Join(", ", seo?.Keywords ?? new List<string>())" />
<link rel="canonical" href="@seo?.CanonicalUrl" /> <link rel="canonical" href="@seo?.CanonicalUrl" />
<!-- Open Graph --> <!-- Open Graph -->
<meta property="og:title" content="@seo?.OgTitle" /> <meta property="og:title" content="@seo?.OgTitle" />
<meta property="og:description" content="@seo?.OgDescription" /> <meta property="og:description" content="@seo?.OgDescription" />
<meta property="og:image" content="@seo?.OgImage" /> <meta property="og:image" content="@seo?.OgImage" />
<meta property="og:url" content="@seo?.CanonicalUrl" /> <meta property="og:url" content="@seo?.CanonicalUrl" />
<meta property="og:type" content="profile" /> <meta property="og:type" content="profile" />
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="@seo?.TwitterCard" /> <meta name="twitter:card" content="@seo?.TwitterCard" />
<meta name="twitter:title" content="@seo?.OgTitle" /> <meta name="twitter:title" content="@seo?.OgTitle" />
@ -27,50 +27,148 @@
} }
else 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="description" content="Crie sua página profissional com links organizados. A melhor alternativa para ter sua bio / links. Criada para profissionais e empresas no Brasil." />
<meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital" /> <meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital, bio, links, página simples" />
} }
@await RenderSectionAsync("Head", required: false)
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" /> <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="icon" type="image/x-icon" href="~/favicon.ico" /> <link rel="icon" type="image/x-icon" href="~/favicon.ico" />
@await RenderSectionAsync("Styles", required: false) @await RenderSectionAsync("Styles", required: false)
<!-- Estilos para menu ativo e barra de carregamento -->
<style>
/* Barra de carregamento moderna */
#loading-bar {
position: fixed;
top: 0;
left: 0;
width: 0%;
height: 3px;
background: linear-gradient(90deg, #007bff, #0056b3, #007bff);
background-size: 200% 100%;
animation: loading-shimmer 1.5s infinite;
z-index: 9999;
transition: width 0.3s ease, opacity 0.3s ease;
opacity: 0;
}
#loading-bar.active {
opacity: 1;
}
@@keyframes loading-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Destacar item ativo do menu */
.nav-link.active {
background-color: rgba(0, 123, 255, 0.1) !important;
border-radius: 6px !important;
font-weight: 600 !important;
}
/* Para homepage (fundo azul) */
.bg-home-blue .nav-link.active {
background-color: rgba(255, 255, 255, 0.2) !important;
}
/* Smooth transition para links */
.nav-link {
transition: all 0.2s ease;
padding: 8px 12px !important;
margin: 0 2px;
border-radius: 6px;
}
.nav-link:hover {
background-color: rgba(0, 123, 255, 0.05);
}
.bg-home-blue .nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Suavizar transições de página */
main {
opacity: 1;
transition: opacity 0.2s ease;
}
main.page-loading {
opacity: 0.7;
}
</style>
</head> </head>
<body> <body>
<!-- Barra de carregamento -->
<div id="loading-bar"></div>
<header> <header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3"> <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light fixed-top @(ViewBag.IsHomePage == true ? "bg-home-blue" : "bg-dashboard")" id="mainNavbar">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand fw-bold text-primary" asp-area="" asp-controller="Home" asp-action="Index"> <a class="navbar-brand fw-bold @(ViewBag.IsHomePage == true ? "text-white" : "text-primary")"
asp-area="" asp-controller="Home" asp-action="Index">
BCards BCards
</a> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler @(ViewBag.IsHomePage == true ? "navbar-dark" : "")"
type="button"
data-bs-toggle="collapse"
data-bs-target=".navbar-collapse"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between"> <div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1"> <ul class="navbar-nav flex-grow-1">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Início</a> <a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Home" asp-action="Index">
Início
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Pricing">Planos</a> <a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Home" asp-action="Pricing">
Planos
</a>
</li> </li>
@* Menu de Moderação via ViewComponent *@
@await Component.InvokeAsync("ModerationMenu")
</ul> </ul>
<ul class="navbar-nav"> <ul class="navbar-nav">
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
{ {
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Dashboard">Dashboard</a> <a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Admin" asp-action="Dashboard">
<i class="fas fa-user me-1"></i>Dashboard
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Logout">Sair</a> <a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Auth" asp-action="Logout">
<i class="fas fa-sign-out-alt me-1"></i>Sair
</a>
</li> </li>
} }
else else
{ {
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Login">Entrar</a> <a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Auth" asp-action="Login">
<i class="fas fa-sign-in-alt me-1"></i>Entrar
</a>
</li> </li>
} }
</ul> </ul>
@ -78,7 +176,7 @@
</div> </div>
</nav> </nav>
</header> </header>
<div class="container-fluid"> <div class="container-fluid">
<main role="main"> <main role="main">
@if (TempData["Success"] != null) @if (TempData["Success"] != null)
@ -88,7 +186,7 @@
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
} }
@if (TempData["Error"] != null) @if (TempData["Error"] != null)
{ {
<div class="alert alert-danger alert-dismissible fade show" role="alert"> <div class="alert alert-danger alert-dismissible fade show" role="alert">
@ -96,7 +194,7 @@
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
} }
@if (TempData["Info"] != null) @if (TempData["Info"] != null)
{ {
<div class="alert alert-info alert-dismissible fade show" role="alert"> <div class="alert alert-info alert-dismissible fade show" role="alert">
@ -104,7 +202,7 @@
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
} }
@RenderBody() @RenderBody()
</main> </main>
</div> </div>
@ -125,6 +223,107 @@
<script src="~/lib/jquery/jquery.min.js"></script> <script src="~/lib/jquery/jquery.min.js"></script>
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script> <script src="~/js/site.js" asp-append-version="true"></script>
<!-- Scripts para menu ativo e barra de carregamento -->
<script>
$(document).ready(function() {
// Destacar item ativo do menu baseado na URL atual
highlightActiveMenuItem();
// Interceptar cliques em links de navegação
setupLoadingBar();
});
function highlightActiveMenuItem() {
var currentPath = window.location.pathname.toLowerCase();
var currentController = '@ViewContext.RouteData.Values["Controller"]?.ToString()?.ToLower()';
var currentAction = '@ViewContext.RouteData.Values["Action"]?.ToString()?.ToLower()';
// Remover classes ativas existentes
$('.nav-link').removeClass('active');
// Lógica para destacar o item correto
$('.nav-link').each(function() {
var link = $(this);
var href = link.attr('href');
if (href) {
var linkPath = href.toLowerCase();
// Verificar correspondência exata primeiro
if (currentPath === linkPath ||
(currentPath === '/' && linkPath.includes('/home')) ||
(currentController === 'home' && currentAction === 'index' && linkPath.includes('/home')) ||
(currentController === 'home' && currentAction === 'pricing' && linkPath.includes('pricing')) ||
(currentController === 'admin' && linkPath.includes('dashboard')) ||
(currentController === 'moderation' && linkPath.includes('moderation')) ||
(currentController === 'auth' && linkPath.includes('login')) ||
(currentController === 'payment' && linkPath.includes('managesubscription'))) {
link.addClass('active');
}
}
});
}
function setupLoadingBar() {
var loadingBar = $('#loading-bar');
// Interceptar todos os cliques em links que navegam para outras páginas
$('a[href]:not([href^="#"]):not([href^="javascript:"]):not([target="_blank"]):not(.no-loading)').click(function(e) {
var href = $(this).attr('href');
// Ignorar links externos, downloads, ou âncoras
if (href && !href.startsWith('http') && !href.startsWith('mailto:') && !href.startsWith('tel:')) {
showLoadingBar();
}
});
// Interceptar submissão de formulários
$('form:not(.no-loading)').submit(function() {
showLoadingBar();
});
// Ocultar barra quando página carrega
$(window).on('load', function() {
hideLoadingBar();
});
// Ocultar barra em caso de erro ou volta do histórico
$(window).on('pageshow', function() {
hideLoadingBar();
});
}
function showLoadingBar() {
var loadingBar = $('#loading-bar');
loadingBar.addClass('active').css('width', '0%');
// Animação progressiva
setTimeout(function() { loadingBar.css('width', '20%'); }, 100);
setTimeout(function() { loadingBar.css('width', '40%'); }, 300);
setTimeout(function() { loadingBar.css('width', '60%'); }, 600);
setTimeout(function() { loadingBar.css('width', '80%'); }, 1000);
// Adicionar efeito de loading ao conteúdo
$('main').addClass('page-loading');
}
function hideLoadingBar() {
var loadingBar = $('#loading-bar');
loadingBar.css('width', '100%');
setTimeout(function() {
loadingBar.removeClass('active').css('width', '0%');
$('main').removeClass('page-loading');
}, 200);
}
// Fallback para esconder loading se demorar muito (mais de 5 segundos)
setTimeout(function() {
hideLoadingBar();
}, 5000);
</script>
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
</body> </body>
</html> </html>

View File

@ -1,12 +1,14 @@
@model BCards.Web.Models.PageTheme @model BCards.Web.Models.PageTheme
@{ @{
var theme = Model ?? new BCards.Web.Models.PageTheme var theme = Model ?? new BCards.Web.Models.PageTheme
{ {
PrimaryColor = "#2563eb", Name = "Padrão",
SecondaryColor = "#1d4ed8", PrimaryColor = "#2563eb",
BackgroundColor = "#ffffff", SecondaryColor = "#1d4ed8",
TextColor = "#1f2937" BackgroundColor = "#ffffff",
}; TextColor = "#1f2937"
};
} }
:root { :root {
@ -14,53 +16,57 @@
--secondary-color: @theme.SecondaryColor; --secondary-color: @theme.SecondaryColor;
--background-color: @theme.BackgroundColor; --background-color: @theme.BackgroundColor;
--text-color: @theme.TextColor; --text-color: @theme.TextColor;
--card-bg: rgba(255, 255, 255, 0.95);
--border-color: rgba(0, 0, 0, 0.1);
} }
.user-page { .user-page {
background-color: var(--background-color); background-color: var(--background-color);
color: var(--text-color); color: var(--text-color);
@if (!string.IsNullOrEmpty(theme.BackgroundImage)) min-height: 100vh;
{ padding: 2rem 0;
@:background-image: url('@theme.BackgroundImage');
@:background-size: cover;
@:background-position: center;
@:background-attachment: fixed;
}
} }
.profile-card { .profile-card {
background-color: rgba(255, 255, 255, 0.95); background-color: var(--card-bg);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-radius: 20px; border-radius: 20px;
padding: 2rem; padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--border-color);
max-width: 500px; max-width: 500px;
}
.profile-image {
width: 120px;
height: 120px;
border-radius: 50%;
border: 4px solid var(--primary-color);
object-fit: cover;
margin: 0 auto; margin: 0 auto;
} }
.profile-image-placeholder { .profile-image-large {
width: 120px;
height: 120px;
border: 4px solid var(--primary-color);
object-fit: cover;
display: block;
margin: 0 auto;
}
.profile-icon-placeholder {
width: 120px; width: 120px;
height: 120px; height: 120px;
border-radius: 50%; border-radius: 50%;
border: 4px solid var(--primary-color); border: 4px solid var(--primary-color);
background-color: rgba(255, 255, 255, 0.1); background-color: var(--card-bg);
color: var(--primary-color); color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin: 0 auto;
} }
.profile-name { .profile-name {
color: var(--primary-color); color: var(--primary-color);
font-size: 2rem; font-size: 2rem;
font-weight: 600; font-weight: 600;
margin-bottom: 0.5rem; margin: 1rem 0 0.5rem 0;
} }
.profile-bio { .profile-bio {
@ -70,146 +76,440 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
/* ========== LINKS CONTAINER ========== */
.links-container { .links-container {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.link-button { /* ========== UNIVERSAL LINK STYLE (TODOS OS LINKS IGUAIS) ========== */
.universal-link {
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 0.75rem;
overflow: hidden;
background-color: var(--card-bg);
transition: all 0.3s ease;
}
.universal-link:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.universal-link-header {
background-color: var(--primary-color); background-color: var(--primary-color);
color: white !important; color: white !important;
border: none; padding: 0.75rem 1rem;
padding: 1rem 2rem; display: flex;
border-radius: 50px; align-items: center;
text-decoration: none; justify-content: space-between;
display: block; cursor: pointer;
margin-bottom: 1rem; text-decoration: none !important;
text-align: center; transition: background-color 0.3s ease;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
overflow: hidden; font-weight: 500;
} }
.link-button:hover { .universal-link-header:hover {
background-color: var(--secondary-color); background-color: var(--secondary-color);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
color: white !important; color: white !important;
text-decoration: none; text-decoration: none !important;
} }
.link-button:active { .universal-link-content {
transform: translateY(0); display: flex;
align-items: center;
flex: 1;
min-width: 0;
text-align: left;
}
/* Thumbnail para produtos */
.link-thumbnail {
width: 40px;
height: 40px;
border-radius: 6px;
object-fit: cover;
margin-right: 0.75rem;
flex-shrink: 0;
background-color: rgba(255, 255, 255, 0.2);
}
/* Ícone para links normais */
.link-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 0.75rem;
flex-shrink: 0;
font-size: 1.2rem;
color: white;
}
.link-text-container {
flex: 1;
min-width: 0;
} }
.link-title { .link-title {
font-size: 1.1rem;
margin-bottom: 0.25rem;
font-weight: 600; font-weight: 600;
font-size: 1rem;
color: white;
margin: 0;
line-height: 1.2;
/* Truncate long titles */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.link-description { .link-subtitle {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.8);
margin: 0;
line-height: 1.1;
/* Truncate long subtitles */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Seta de expansão */
.expand-arrow {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
border-radius: 6px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
flex-shrink: 0;
margin-left: 0.5rem;
}
.expand-arrow:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
.expand-arrow i {
font-size: 0.9rem; font-size: 0.9rem;
opacity: 0.9; transition: transform 0.3s ease;
} }
.link-icon { .expand-arrow.expanded i {
font-size: 1.2rem; transform: rotate(180deg);
margin-right: 0.5rem;
} }
.profile-footer { /* Conteúdo expandido */
border-top: 1px solid rgba(0, 0, 0, 0.1); .universal-link-details {
padding-top: 1rem; padding: 0;
background-color: var(--card-bg);
border-top: 1px solid var(--border-color);
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out, padding 0.3s ease;
} }
.profile-footer a { .universal-link-details.show {
max-height: 400px;
padding: 1rem;
}
/* Imagem expandida para produtos */
.expanded-image {
width: 100%;
max-width: 200px;
height: auto;
border-radius: 8px;
margin-bottom: 1rem;
display: block;
margin-left: auto;
margin-right: auto;
}
.expanded-description {
color: var(--text-color);
opacity: 0.8;
font-size: 0.9rem;
line-height: 1.5;
margin-bottom: 0.75rem;
max-height: 150px; /* Ajuste conforme necessário */
overflow-y: auto;
padding-right: 0.5rem; /* Espaço para a scrollbar */
/* Styling da scrollbar (opcional) */
scrollbar-width: thin;
scrollbar-color: var(--primary-color) transparent;
}
.expanded-description::-webkit-scrollbar {
width: 6px;
}
.expanded-description::-webkit-scrollbar-track {
background: transparent;
}
.expanded-description::-webkit-scrollbar-thumb {
background-color: var(--primary-color);
border-radius: 3px;
}
.expanded-price {
color: #28a745;
font-weight: 700;
font-size: 1.1rem;
margin-bottom: 0.75rem;
text-align: center;
}
.expanded-action {
color: var(--primary-color); color: var(--primary-color);
font-size: 0.85rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
/* ========== FOOTER ========== */
.profile-footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
text-align: center;
}
.footer-promo {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
cursor: pointer;
transition: all 0.3s ease;
}
.footer-promo:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.footer-promo-header {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--primary-color);
font-weight: 600;
font-size: 0.9rem;
}
.footer-promo-header i {
transition: transform 0.3s ease;
}
.footer-promo-header.expanded i {
transform: rotate(180deg);
}
.footer-promo-content {
margin-top: 0.5rem;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out, margin-top 0.3s ease;
color: var(--text-color);
opacity: 0.8;
font-size: 0.85rem;
line-height: 1.4;
}
.footer-promo-content.show {
max-height: 200px;
margin-top: 0.75rem;
}
.footer-promo-button {
background-color: var(--primary-color);
color: white !important;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 6px;
text-decoration: none !important;
font-size: 0.8rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
transition: background-color 0.3s ease;
}
.footer-promo-button:hover {
background-color: var(--secondary-color);
color: white !important;
text-decoration: none !important;
}
.footer-credits {
font-size: 0.8rem;
color: var(--text-color);
opacity: 0.6;
}
.footer-credits a {
color: var(--primary-color);
text-decoration: none;
font-weight: 500; font-weight: 500;
} }
.profile-footer a:hover { .footer-credits a:hover {
color: var(--secondary-color); color: var(--secondary-color);
text-decoration: underline;
} }
/* Responsive Design */ /* ========== ANIMATIONS ========== */
@@keyframes slideDown {
from {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
}
to {
max-height: 400px;
padding-top: 1rem;
padding-bottom: 1rem;
opacity: 1;
}
}
/* ========== RESPONSIVE DESIGN ========== */
@@media (max-width: 768px) { @@media (max-width: 768px) {
.user-page {
padding: 1rem 0;
}
.profile-card { .profile-card {
padding: 1.5rem; padding: 1.5rem;
margin: 1rem; margin: 0 1rem;
border-radius: 15px; border-radius: 15px;
} }
.profile-image, .profile-image-large,
.profile-image-placeholder { .profile-icon-placeholder {
width: 100px; width: 100px;
height: 100px; height: 100px;
} }
.profile-name { .profile-name {
font-size: 1.75rem; font-size: 1.75rem;
} }
.profile-bio { .universal-link-header {
font-size: 1rem; padding: 0.65rem 0.8rem;
} }
.link-button { .link-title {
padding: 0.875rem 1.5rem;
font-size: 0.95rem; font-size: 0.95rem;
} }
.link-title { .link-subtitle {
font-size: 1rem; font-size: 0.8rem;
} }
.link-description { .link-thumbnail,
font-size: 0.85rem; .link-icon {
width: 36px;
height: 36px;
margin-right: 0.5rem;
}
.expand-arrow {
width: 28px;
height: 28px;
}
.expand-arrow i {
font-size: 0.8rem;
} }
} }
@@media (max-width: 480px) { @@media (max-width: 480px) {
.profile-card { .profile-card {
padding: 1rem; padding: 1rem;
margin: 0.5rem; margin: 0 0.5rem;
} }
.profile-image, .profile-image-large,
.profile-image-placeholder { .profile-icon-placeholder {
width: 80px; width: 80px;
height: 80px; height: 80px;
} }
.profile-name { .profile-name {
font-size: 1.5rem; font-size: 1.5rem;
} }
.link-button { .universal-link-header {
padding: 0.75rem 1.25rem; padding: 0.6rem 0.8rem;
}
.link-title {
font-size: 0.9rem;
}
.link-subtitle {
font-size: 0.75rem;
}
.link-thumbnail,
.link-icon {
width: 32px;
height: 32px;
margin-right: 0.5rem;
}
.expand-arrow {
width: 26px;
height: 26px;
}
.expanded-image {
max-width: 150px;
} }
} }
/* Dark theme adjustments */ /* ========== DARK THEME COMPATIBILITY ========== */
@@media (prefers-color-scheme: dark) { .user-page[data-theme="dark"] .profile-card,
.user-page[data-theme="dark"] .profile-card { .user-page[data-theme="dark"] .universal-link,
background-color: rgba(17, 24, 39, 0.95); .user-page[data-theme="dark"] .footer-promo {
color: #f9fafb; background-color: rgba(31, 41, 55, 0.95);
} border-color: rgba(255, 255, 255, 0.1);
} }
/* Animation for link buttons */ .user-page[data-theme="dark"] .universal-link-details,
.link-button::before { .user-page[data-theme="dark"] .footer-promo-content {
content: ''; background-color: rgba(31, 41, 55, 0.95);
position: absolute; border-color: rgba(255, 255, 255, 0.1);
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 { /* Accessibility */
left: 100%; .universal-link-header:focus,
.expand-arrow:focus {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}
/* Smooth scroll for mobile */
.universal-link-details {
scroll-behavior: smooth;
} }

View File

@ -41,8 +41,12 @@
</script> </script>
} }
@await RenderSectionAsync("Head", required: false)
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" /> <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" />
<link rel="icon" type="image/x-icon" href="~/favicon.ico" /> <link rel="icon" type="image/x-icon" href="~/favicon.ico" />
@await RenderSectionAsync("Styles", required: false) @await RenderSectionAsync("Styles", required: false)

View File

@ -1,48 +1,111 @@
@model BCards.Web.Models.UserPage @model BCards.Web.Models.IPageDisplay
@{ @{
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings; var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
var category = ViewBag.Category as BCards.Web.Models.Category; var category = ViewBag.Category as BCards.Web.Models.Category;
var isPreview = ViewBag.IsPreview as bool? ?? false; var isPreview = ViewBag.IsPreview as bool? ?? false;
var isLivePage = ViewBag.IsLivePage as bool? ?? false;
ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}"; ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}";
Layout = isPreview ? "_Layout" : "_UserPageLayout"; Layout = isPreview ? "_Layout" : "_UserPageLayout";
} }
@if (!isPreview) @section Head {
{ @if (isPreview)
@section Styles { {
<style> <meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
@Html.Raw(await Html.PartialAsync("_ThemeStyles", Model.Theme))
</style>
} }
else if (isLivePage)
{
<meta name="robots" content="index, follow">
@if (!string.IsNullOrEmpty(ViewBag.PageUrl as string))
{
<link rel="canonical" href="@ViewBag.PageUrl">
}
}
else
{
<meta name="robots" content="noindex, nofollow">
}
}
@section Styles {
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@{
var partialOutput = await Html.PartialAsync("_ThemeStyles", Model.Theme);
using (var writer = new System.IO.StringWriter())
{
partialOutput.WriteTo(writer, HtmlEncoder);
@Html.Raw(writer.ToString())
}
}
</style>
}
@if (isPreview)
{
<style>
/* Compensar espaço da barra de preview */
body {
padding-top: 60px !important;
}
/* Responsivo para mobile */
@@media (max-width: 768px) {
.position-fixed .container-fluid .row .col:not(.col-auto) {
display: none;
}
.position-fixed .container-fluid .row .col-auto:first-child {
flex: 1;
}
body {
padding-top: 50px !important;
}
}
/* Animação suave para o toast */
.toast {
transition: all 0.3s ease;
}
/* Melhorar aparência dos botões na barra */
.btn-outline-dark:hover {
background-color: rgba(0,0,0,0.1);
border-color: rgba(0,0,0,0.3);
}
</style>
} }
<div class="user-page min-vh-100 d-flex align-items-center py-4"> <div class="user-page min-vh-100 d-flex align-items-center py-4">
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-6 col-md-8"> <div class="col-lg-6 col-md-8">
<div class="profile-card text-center mx-auto"> <div class="profile-card mx-auto">
<!-- Profile Image --> <!-- Profile Image & Info -->
@if (!string.IsNullOrEmpty(Model.ProfileImage)) <div class="profile-header text-center mb-4">
{ @if (!string.IsNullOrEmpty(Model.ProfileImageId))
<img src="@Model.ProfileImage" alt="@Model.DisplayName" class="profile-image mb-3"> {
} <img src="@Model.ProfileImageUrl" alt="@Model.DisplayName" class="profile-image-large rounded-circle mb-3">
else }
{ else
<div class="profile-image-placeholder mb-3 mx-auto d-flex align-items-center justify-content-center"> {
<i class="fs-1">👤</i> <div class="profile-icon-placeholder mb-3">
</div> <i class="fas fa-id-card"></i>
} </div>
}
<h1 class="profile-name mb-0">@Model.DisplayName</h1>
</div>
<div class="text-center">
<!-- Profile Info -->
<h1 class="profile-name">@Model.DisplayName</h1>
@if (!string.IsNullOrEmpty(Model.Bio)) @if (!string.IsNullOrEmpty(Model.Bio))
{ {
<p class="profile-bio">@Model.Bio</p> <p class="profile-bio">@Model.Bio</p>
} }
<!-- Links --> <!-- Links Container -->
<div class="links-container"> <div class="links-container">
@if (Model.Links?.Any(l => l.IsActive) == true) @if (Model.Links?.Any(l => l.IsActive) == true)
{ {
@ -51,24 +114,122 @@
var link = Model.Links[i]; var link = Model.Links[i];
if (link.IsActive) if (link.IsActive)
{ {
<a href="@link.Url" var hasExpandableContent = (!string.IsNullOrEmpty(link.Description) ||
target="_blank" (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductDescription)));
rel="noopener noreferrer"
class="link-button" <!-- Universal Link Style (TODOS OS LINKS IGUAIS) -->
data-link-index="@i" <div class="universal-link" data-link-id="@i">
onclick="recordClick('@Model.Id', @i)"> <!-- Header clicável (vai para o link) -->
@if (!string.IsNullOrEmpty(link.Icon)) <a href="@link.Url"
{ class="universal-link-header"
<span class="link-icon me-2">@link.Icon</span> onclick="recordClick('@Model.Id', @i)"
} target="_blank"
<div> rel="noopener noreferrer">
<div class="link-title">@link.Title</div>
@if (!string.IsNullOrEmpty(link.Description)) <div class="universal-link-content">
@if (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductImage))
{
<!-- Thumbnail para produtos -->
<img src="@link.ProductImage"
alt="@(link.ProductTitle ?? link.Title)"
class="link-thumbnail"
loading="lazy"
onerror="this.style.display='none'">
}
else if (!string.IsNullOrEmpty(link.Icon))
{
<!-- Ícone para links normais -->
<div class="link-icon">
<i class="@link.Icon"></i>
</div>
}
else
{
<!-- Ícone padrão se não tiver -->
<div class="link-icon">
<i class="fas fa-link"></i>
</div>
}
<div class="link-text-container">
<div class="link-title">
@if (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductTitle))
{
@link.ProductTitle
}
else
{
@link.Title
}
</div>
@if (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductPrice))
{
<div class="link-subtitle">@link.ProductPrice</div>
}
else if (!string.IsNullOrEmpty(link.Description) && link.Description.Length > 50)
{
<div class="link-subtitle">@(link.Description.Substring(0, 50))...</div>
}
</div>
</div>
@if (hasExpandableContent)
{ {
<div class="link-description">@link.Description</div> <!-- Seta de expansão (só aparece se tem conteúdo expandível) -->
<button class="expand-arrow"
type="button"
onclick="event.preventDefault(); event.stopPropagation(); toggleLinkDetails(@i)">
<i class="fas fa-chevron-down"></i>
</button>
} }
</div> </a>
</a>
@if (hasExpandableContent)
{
<!-- Conteúdo expandível -->
<div class="universal-link-details" id="details-@i">
@if (link.Type == BCards.Web.Models.LinkType.Product)
{
<!-- Conteúdo expandido para produtos -->
@if (!string.IsNullOrEmpty(link.ProductImage))
{
<img src="@link.ProductImage"
alt="@(link.ProductTitle ?? link.Title)"
class="expanded-image"
loading="lazy">
}
@if (!string.IsNullOrEmpty(link.ProductPrice))
{
<div class="expanded-price">@link.ProductPrice</div>
}
@if (!string.IsNullOrEmpty(link.ProductDescription))
{
<div class="expanded-description">
@link.ProductDescription
</div>
}
}
else
{
<!-- Conteúdo expandido para links normais -->
@if (!string.IsNullOrEmpty(link.Description))
{
<div class="expanded-description">
@link.Description
</div>
}
}
<div class="expanded-action">
<i class="fas fa-external-link-alt"></i>
Clique no título acima para abrir
</div>
</div>
}
</div>
} }
} }
} }
@ -81,12 +242,33 @@
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="profile-footer mt-4 pt-3 border-top"> <div class="profile-footer">
<small class="text-muted">
Criado com <a href="@Url.Action("Index", "Home")" class="text-decoration-none">BCards</a> <!-- Promoção BCards -->
</small> <div class="footer-promo" onclick="togglePromo(this)">
<div class="footer-promo-header">
<span>💡 Gostou desta página?</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="footer-promo-content">
Crie a sua própria página personalizada com <strong>BCards</strong>!
É rápido, fácil e profissional. Compartilhe todos os seus links em um só lugar.
<div class="mt-2">
<a href="@Url.Action("Index", "Home")" class="footer-promo-button">
<i class="fas fa-rocket"></i>
Criar Minha Página
</a>
</div>
</div>
</div>
<div class="footer-credits">
Criado com <a href="@Url.Action("Index", "Home")">BCards</a>
</div>
</div> </div>
</div>
</div> <!-- /text-center -->
</div> <!-- /profile-card -->
</div> </div>
</div> </div>
</div> </div>
@ -94,15 +276,55 @@
@if (isPreview) @if (isPreview)
{ {
<div class="position-fixed top-0 start-0 w-100 bg-warning text-dark text-center py-2" style="z-index: 9999;"> <!-- Barra de Preview Melhorada -->
<strong>MODO PREVIEW</strong> - Esta é uma prévia da sua página <div class="position-fixed top-0 start-0 w-100 bg-warning text-dark py-2" style="z-index: 9999; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<div class="container-fluid">
<div class="row align-items-center">
<div class="col-auto">
<i class="fas fa-eye me-2"></i>
<strong>MODO PREVIEW</strong>
</div>
<div class="col text-center">
<span class="small">Esta é uma prévia da sua página</span>
</div>
<div class="col-auto">
<button type="button" class="btn btn-sm btn-outline-dark me-2" onclick="goBackToDashboard()">
<i class="fas fa-arrow-left me-1"></i>
Voltar ao Dashboard
</button>
<button type="button" class="btn btn-sm btn-dark" onclick="closePreview()">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Toast Informativo -->
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 9998;">
<div id="previewToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="toast-header bg-info text-white">
<i class="fas fa-info-circle me-2"></i>
<strong class="me-auto">Modo Preview</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">
<p class="mb-2"><strong>Você está visualizando uma prévia!</strong></p>
<p class="mb-2 small">• Para fazer alterações, volte ao Dashboard e clique <i class="fas fa-cog"></i></p>
<p class="mb-2 small">• Esta prévia não é a página final publicada</p>
<button type="button" class="btn btn-sm btn-primary w-100" onclick="goBackToDashboard()">
<i class="fas fa-cog"></i> >>
Editar Página
</button>
</div>
</div>
</div> </div>
} }
@section Scripts { @section Scripts {
<script> <script>
// Função original de rastreamento de cliques
function recordClick(pageId, linkIndex) { function recordClick(pageId, linkIndex) {
// Record click asynchronously
fetch('/click/' + pageId, { fetch('/click/' + pageId, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -113,5 +335,131 @@
console.log('Error recording click:', error); console.log('Error recording click:', error);
}); });
} }
// Toggle link details (função universal para todos os links)
function toggleLinkDetails(linkIndex) {
const currentDetails = document.getElementById('details-' + linkIndex);
const currentArrow = document.querySelector('[data-link-id="' + linkIndex + '"] .expand-arrow');
if (!currentDetails || !currentArrow) return;
const isCurrentlyExpanded = currentDetails.classList.contains('show');
// Fechar todos os outros links primeiro (auto-close)
const allDetails = document.querySelectorAll('.universal-link-details');
const allArrows = document.querySelectorAll('.expand-arrow');
allDetails.forEach(details => {
details.classList.remove('show');
});
allArrows.forEach(arrow => {
arrow.classList.remove('expanded');
const icon = arrow.querySelector('i');
if (icon) {
icon.style.transform = 'rotate(0deg)';
}
});
// Se não estava expandido, expandir este
if (!isCurrentlyExpanded) {
currentDetails.classList.add('show');
currentArrow.classList.add('expanded');
const icon = currentArrow.querySelector('i');
if (icon) {
icon.style.transform = 'rotate(180deg)';
}
}
}
// Toggle footer promo
function togglePromo(element) {
const content = element.querySelector('.footer-promo-content');
const arrow = element.querySelector('.footer-promo-header i');
if (content.classList.contains('show')) {
content.classList.remove('show');
arrow.style.transform = 'rotate(0deg)';
element.querySelector('.footer-promo-header').classList.remove('expanded');
} else {
content.classList.add('show');
arrow.style.transform = 'rotate(180deg)';
element.querySelector('.footer-promo-header').classList.add('expanded');
}
}
// Initialize page
document.addEventListener('DOMContentLoaded', function() {
// Garantir que todos os accordions comecem fechados
const allDetails = document.querySelectorAll('.universal-link-details, .footer-promo-content');
allDetails.forEach(detail => {
detail.classList.remove('show');
});
const allArrows = document.querySelectorAll('.expand-arrow i, .footer-promo-header i');
allArrows.forEach(arrow => {
arrow.style.transform = 'rotate(0deg)';
});
// Adicionar eventos de teclado para acessibilidade
const expandButtons = document.querySelectorAll('.expand-arrow');
expandButtons.forEach(button => {
button.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
button.click();
}
});
});
@if (isPreview)
{
@:// Mostrar toast informativo após carregar
@:setTimeout(function() {
@: var toastEl = document.getElementById('previewToast');
@: if (toastEl) {
@: var toast = new bootstrap.Toast(toastEl);
@: toast.show();
@: }
@:}, 1000); // Delay de 1 segundo
}
});
@if (isPreview)
{
@:// Funções específicas do modo preview
@:function goBackToDashboard() {
@: // Detectar se está em mobile ou desktop
@: if (window.opener) {
@: // Está numa nova aba/janela - fechar e focar na aba pai
@: window.opener.focus();
@: window.close();
@: } else {
@: // Navegação normal - ir para o dashboard
@: window.location.href = '@Url.Action("Dashboard", "Admin")';
@: }
@:}
@:
@:function closePreview() {
@: if (window.opener) {
@: // Está numa nova aba - fechar
@: window.close();
@: } else {
@: // Tentar voltar na história ou ir para dashboard
@: if (window.history.length > 1) {
@: window.history.back();
@: } else {
@: window.location.href = '@Url.Action("Dashboard", "Admin")';
@: }
@: }
@:}
@:
@:// Detectar tecla ESC para fechar preview
@:document.addEventListener('keydown', function(e) {
@: if (e.key === 'Escape') {
@: closePreview();
@: }
@:});
}
</script> </script>
} }

View File

@ -0,0 +1,30 @@
@{
ViewData["Title"] = "Página Rejeitada";
Layout = "_Layout";
}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-body text-center py-5">
<div class="mb-4">
<i class="fas fa-times-circle text-danger fa-4x"></i>
</div>
<h2 class="h4 mb-3">Página Rejeitada</h2>
<p class="lead mb-4">
Esta página foi rejeitada na moderação e não está disponível publicamente.
</p>
<p class="text-muted mb-4">
O conteúdo não atende aos nossos termos de uso ou padrões de qualidade.
<br>
<strong>Proprietário:</strong> Verifique seu painel para mais detalhes
</p>
<a href="/" class="btn btn-primary">
<i class="fas fa-home"></i> Voltar ao Início
</a>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,34 @@
@{
ViewData["Title"] = "Página em Análise";
Layout = "_Layout";
}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-body text-center py-5">
<div class="mb-4">
<i class="fas fa-hourglass-half text-warning fa-4x"></i>
</div>
<h2 class="h4 mb-3">Página em Análise</h2>
<p class="lead mb-4">
Esta página está sendo analisada por nossa equipe de moderação.
</p>
<p class="text-muted mb-4">
Estamos verificando se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.
<br>
<strong>Tempo estimado:</strong> 3-7 dias úteis
</p>
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
<strong>Proprietário da página:</strong> Verifique seu email para o link de preview
</div>
<a href="/" class="btn btn-primary">
<i class="fas fa-home"></i> Voltar ao Início
</a>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,38 @@
@{
ViewData["Title"] = "Preview Expirado";
Layout = "_Layout";
}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-body text-center py-5">
<div class="mb-4">
<i class="fas fa-clock text-warning fa-4x"></i>
</div>
<h2 class="h4 mb-3">Preview Expirado</h2>
<p class="lead mb-4">
O link de preview que você acessou não é mais válido.
</p>
<p class="text-muted mb-4">
Isso pode acontecer se:
<br>
• O link expirou (30 dias)
<br>
• Excedeu o limite de visualizações (50)
<br>
• A página já foi processada
</p>
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
<strong>Proprietário:</strong> Acesse seu painel para ver o status atual
</div>
<a href="/" class="btn btn-primary">
<i class="fas fa-home"></i> Voltar ao Início
</a>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,20 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "mongodb://localhost:27017/BCardsDB_Testing"
},
"AllowedHosts": "*",
"JWT": {
"Secret": "ThisIsATestSecretKeyForJWTTokenGeneration123456789",
"Issuer": "BCards-Testing",
"Audience": "BCards-Users-Testing",
"ExpiryMinutes": 60
},
"Environment": "Testing"
}

Some files were not shown because too many files have changed in this diff Show More