Compare commits

..

No commits in common. "main" and "feat/plano-rodape" have entirely different histories.

215 changed files with 16378 additions and 31496 deletions

View File

@ -20,21 +20,7 @@
"Bash(docker-compose up:*)",
"Bash(dotnet build:*)",
"Bash(chmod:*)",
"Bash(mv:*)",
"Bash(dotnet nuget locals:*)",
"Bash(/mnt/c/vscode/vcart.me.novo/clean-build.sh:*)",
"Bash(sed:*)",
"Bash(./clean-build.sh:*)",
"Bash(git add:*)",
"Bash(scp:*)",
"Bash(ssh:*)",
"Bash(cat:*)",
"Bash(dig:*)",
"Bash(git commit:*)",
"Bash(netstat:*)",
"Bash(ss:*)",
"Bash(lsof:*)",
"Bash(dotnet run:*)"
"Bash(mv:*)"
]
},
"enableAllProjectMcpServers": false

View File

@ -1,13 +0,0 @@
bin/
obj/
.git/
.gitignore
*.md
README.md
tests/
docs/
.vs/
.vscode/
**/.DS_Store
**/Thumbs.db

View File

@ -89,7 +89,7 @@ jobs:
build-and-push:
name: Build and Push Image
runs-on: [self-hosted, arm64, bcards]
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')
@ -101,7 +101,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/arm64
platforms: linux/amd64,linux/arm64
- name: Determine build settings
id: settings
@ -109,24 +109,23 @@ jobs:
BRANCH_NAME="${{ github.ref_name }}"
if [ "$BRANCH_NAME" = "main" ]; then
# Main = Produção (ARM64) - usando Dockerfile simples
# Main = Produção (ARM64) - usando Dockerfile da raiz como QRRapido
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 = Swarm tests (Orange Pi arm64) - usando Dockerfile simples também
# Release = Staging (x86)
VERSION_RAW=${BRANCH_NAME#Release/}
# Only remove V/v if it's at the start and followed by a number (like v1.0.0)
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]\([0-9]\)/\1/')
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
[ -z "$VERSION" ] && VERSION="0.0.1"
echo "tag=$VERSION" >> $GITHUB_OUTPUT
echo "platform=linux/arm64" >> $GITHUB_OUTPUT
echo "environment=Testing" >> $GITHUB_OUTPUT
echo "dockerfile=Dockerfile" >> $GITHUB_OUTPUT
echo "deploy_target=testing" >> $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
@ -162,7 +161,7 @@ jobs:
# Build para a plataforma correta
if [ "${{ steps.settings.outputs.deploy_target }}" = "production" ]; then
# Build para produção (main branch) - Usa Configuration=Release (padrão)
# Build para produção (main branch)
docker buildx build \
--platform ${{ steps.settings.outputs.platform }} \
--file ${{ steps.settings.outputs.dockerfile }} \
@ -171,13 +170,12 @@ jobs:
--progress=plain \
.
else
# Build para staging (Release branches) - Usa Configuration=Testing para habilitar código de teste
# Build para staging (Release branches)
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 BUILD_CONFIGURATION=Testing \
--build-arg VERSION=${{ steps.settings.outputs.version || 'latest' }} \
--build-arg COMMIT=${{ steps.settings.outputs.commit }} \
--progress=plain \
@ -192,235 +190,11 @@ jobs:
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create Production Configuration
- name: Deploy to Production Servers
run: |
echo "🔧 Creating appsettings.Production.json with environment variables..."
echo "🚀 Deploying to production servers (ARM64)..."
# Cria o arquivo de configuração para produção
cat > appsettings.Production.json << 'CONFIG_EOF'
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"BCards": "Information",
"BCards.Web.Services.GridFSImageStorage": "Debug"
},
"Console": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Information"
}
},
"File": {
"Path": "/app/logs/bcards-{Date}.log",
"LogLevel": {
"Default": "Information"
}
}
},
"AllowedHosts": "*",
"Stripe": {
"PublishableKey": "${{ vars.STRIPE_PUBLISHABLE_KEY }}",
"SecretKey": "${{ secrets.STRIPE_SECRET_KEY }}",
"WebhookSecret": "${{ secrets.STRIPE_WEBHOOK_SECRET }}",
"Environment": "${{ vars.STRIPE_ENVIRONMENT || 'test' }}"
},
"Authentication": {
"Google": {
"ClientId": "${{ vars.GOOGLE_CLIENT_ID }}",
"ClientSecret": "${{ secrets.GOOGLE_CLIENT_SECRET }}"
},
"Microsoft": {
"ClientId": "${{ vars.MICROSOFT_CLIENT_ID }}",
"ClientSecret": "${{ secrets.MICROSOFT_CLIENT_SECRET }}"
}
},
"SendGrid": {
"ApiKey": "${{ secrets.SENDGRID_API_KEY }}",
"FromEmail": "${{ vars.SENDGRID_FROM_EMAIL || 'ricardo.carneiro@jobmaker.com.br' }}",
"FromName": "${{ vars.SENDGRID_FROM_NAME || 'Ricardo Carneiro' }}"
},
"Plans": {
"Basic": {
"Name": "Básico",
"PriceId": "price_1RycPaBMIadsOxJVKioZZofK",
"Price": 5.90,
"MaxPages": 3,
"MaxLinks": 8,
"AllowPremiumThemes": false,
"AllowProductLinks": false,
"AllowAnalytics": true,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ],
"Interval": "month"
},
"Professional": {
"Name": "Profissional",
"PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj",
"Price": 12.90,
"MaxPages": 5,
"MaxLinks": 20,
"AllowPremiumThemes": false,
"AllowProductLinks": false,
"AllowAnalytics": true,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ],
"Interval": "month"
},
"Premium": {
"Name": "Premium",
"PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu",
"Price": 19.90,
"MaxPages": 15,
"MaxLinks": -1,
"AllowPremiumThemes": true,
"AllowProductLinks": false,
"AllowAnalytics": true,
"SpecialModeration": false,
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados" ],
"Interval": "month"
},
"PremiumAffiliate": {
"Name": "Premium+Afiliados",
"PriceId": "price_1RycTaBMIadsOxJVeDLseXQq",
"Price": 29.90,
"MaxPages": 15,
"MaxLinks": -1,
"AllowPremiumThemes": true,
"AllowProductLinks": true,
"AllowAnalytics": true,
"SpecialModeration": true,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados" ],
"Interval": "month"
},
"BasicYearly": {
"Name": "Básico Anual",
"PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS",
"Price": 59.00,
"MaxPages": 3,
"MaxLinks": 8,
"AllowPremiumThemes": false,
"AllowProductLinks": false,
"AllowAnalytics": true,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ],
"Interval": "year"
},
"ProfessionalYearly": {
"Name": "Profissional Anual",
"PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm",
"Price": 129.00,
"MaxPages": 5,
"MaxLinks": 20,
"AllowPremiumThemes": false,
"AllowProductLinks": false,
"AllowAnalytics": true,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ],
"Interval": "year"
},
"PremiumYearly": {
"Name": "Premium Anual",
"PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m",
"Price": 199.00,
"MaxPages": 15,
"MaxLinks": -1,
"AllowPremiumThemes": true,
"AllowProductLinks": false,
"AllowAnalytics": true,
"SpecialModeration": false,
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Economize R$ 39,80 (2 meses grátis)" ],
"Interval": "year"
},
"PremiumAffiliateYearly": {
"Name": "Premium+Afiliados Anual",
"PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1",
"Price": 299.00,
"MaxPages": 15,
"MaxLinks": -1,
"AllowPremiumThemes": true,
"AllowProductLinks": true,
"AllowAnalytics": true,
"SpecialModeration": true,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Economize R$ 59,80 (2 meses grátis)" ],
"Interval": "year"
}
},
"Moderation": {
"PriorityTimeframes": {
"Trial": "7.00:00:00",
"Basic": "7.00:00:00",
"Professional": "3.00:00:00",
"Premium": "1.00:00:00"
},
"MaxAttempts": 3,
"ModeratorEmail": "${{ vars.MODERATOR_EMAIL || 'ricardo.carneiro@jobmaker.com.br' }}",
"ModeratorEmails": [
"${{ vars.MODERATOR_EMAIL_1 || 'rrcgoncalves@gmail.com' }}",
"${{ vars.MODERATOR_EMAIL_2 || 'rirocarneiro@gmail.com' }}"
]
},
"MongoDb": {
"ConnectionString": "mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin",
"DatabaseName": "BCardsDB",
"MaxConnectionPoolSize": 100,
"ConnectTimeout": "30s",
"ServerSelectionTimeout": "30s",
"SocketTimeout": "30s"
},
"BaseUrl": "https://bcards.site",
"Environment": {
"Name": "Production",
"IsStagingEnvironment": false,
"AllowTestData": false,
"EnableDetailedErrors": false
},
"Performance": {
"EnableCaching": true,
"CacheExpirationMinutes": 30,
"EnableCompression": true,
"EnableResponseCaching": true
},
"Security": {
"EnableHttpsRedirection": true,
"EnableHsts": true,
"RequireHttpsMetadata": true
},
"HealthChecks": {
"Enabled": true,
"Endpoints": {
"Health": "/health",
"Ready": "/ready",
"Live": "/live"
},
"MongoDb": {
"Enabled": true,
"Timeout": "10s"
}
},
"Features": {
"EnablePreviewMode": true,
"EnableModerationWorkflow": true,
"EnableAnalytics": true,
"EnableFileUploads": true,
"MaxFileUploadSize": "5MB"
},
"Serilog": {
"OpenSearchUrl": "${{ vars.OPENSEARCH_URL || 'http://localhost:9201' }}"
}
}
CONFIG_EOF
echo "✅ Configuration file created!"
echo "🔍 File content (sensitive data masked):"
cat appsettings.Production.json | sed 's/"[^"]*_[0-9A-Za-z_]*"/"***MASKED***"/g'
- name: Deploy to Production Swarm
run: |
echo "🚀 Deploying to production Docker Swarm (ARM64)..."
# Configura SSH
# Configura SSH (igual ao QRRapido)
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
@ -432,48 +206,81 @@ jobs:
# Testa a chave SSH
ssh-add ~/.ssh/id_rsa 2>/dev/null || echo "SSH key loaded"
# Upload configuration and stack file to swarm manager
echo "📤 Uploading files to Swarm manager..."
scp -o StrictHostKeyChecking=no appsettings.Production.json ubuntu@141.148.162.114:/tmp/
scp -o StrictHostKeyChecking=no deploy/docker-stack.yml ubuntu@141.148.162.114:/tmp/
# Deploy to Docker Swarm
# Deploy no Servidor 1 (ARM - OCI)
ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 << 'EOF'
set -e
echo "🔄 Atualizando Servidor 1..."
echo "🔄 Updating Docker Swarm stack..."
# Remove containers bcards-infrastructure se existirem
docker stop bcards-infrastructure bcards-test-app || true
docker rm bcards-infrastructure bcards-test-app || true
# Update Docker config with new appsettings
echo "📝 Updating bcards-appsettings config..."
# Para o container BCards atual se existir
docker stop bcards-prod || true
docker rm bcards-prod || true
# Remove old config (will fail if in use, that's ok - swarm will use it until update)
docker config rm bcards-appsettings 2>/dev/null || echo "Config in use or doesn't exist, will create new one"
# Remove imagem antiga
docker rmi ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest || true
# Create new config with timestamp to force update
CONFIG_NAME="bcards-appsettings-$(date +%s)"
docker config create ${CONFIG_NAME} /tmp/appsettings.Production.json
# Puxa nova imagem
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
# Update stack file to use new config name
sed "s/bcards-appsettings/${CONFIG_NAME}/g" /tmp/docker-stack.yml > /tmp/docker-stack-updated.yml
# Executa novo container BCards
docker run -d \
--name bcards-prod \
--restart unless-stopped \
-p 8080:8080 \
-e ASPNETCORE_ENVIRONMENT=Production \
-e ASPNETCORE_URLS=http://+:8080 \
-e MongoDb__ConnectionString="mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin" \
-e MongoDb__DatabaseName="BCardsDB" \
-e Logging__LogLevel__Default=Debug \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
echo "🐳 Deploying stack to Swarm (rolling update, zero downtime)..."
docker stack deploy -c /tmp/docker-stack-updated.yml bcards --with-registry-auth
# Debug: verificar configuração aplicada
echo "🔍 Verificando configuração MongoDB no container..."
docker logs bcards-prod | head -20 || echo "Container ainda iniciando..."
echo "⏳ Waiting for service to update..."
sleep 10
# Recarrega NGINX
sudo nginx -t && sudo systemctl reload nginx
# Show service status
docker service ls --filter name=bcards_bcards-app
docker service ps bcards_bcards-app --no-trunc --filter "desired-state=running" | head -10
echo "✅ Servidor 1 atualizado"
EOF
echo "🧹 Cleaning up standalone containers if they exist..."
docker stop bcards-prod 2>/dev/null || echo "No standalone container to stop"
docker rm bcards-prod 2>/dev/null || echo "No standalone container to remove"
# Deploy no Servidor 2 (ARM - OCI)
ssh -o StrictHostKeyChecking=no ubuntu@129.146.116.218 << 'EOF'
echo "🔄 Atualizando Servidor 2..."
# Clean up temp files
rm -f /tmp/appsettings.Production.json /tmp/docker-stack.yml /tmp/docker-stack-updated.yml
# Remove containers bcards-infrastructure se existirem
docker stop bcards-infrastructure bcards-test-app || true
docker rm bcards-infrastructure bcards-test-app || true
echo "✅ Swarm stack updated successfully!"
# 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 8080:8080 \
-e ASPNETCORE_ENVIRONMENT=Production \
-e ASPNETCORE_URLS=http://+:8080 \
-e MongoDb__ConnectionString="mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin" \
-e MongoDb__DatabaseName="BCardsDB" \
-e Logging__LogLevel__Default=Debug \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
# Debug: verificar configuração aplicada
echo "🔍 Verificando configuração MongoDB no container..."
docker logs bcards-prod | head -20 || echo "Container ainda iniciando..."
echo "✅ Servidor 2 atualizado"
EOF
- name: Health Check Production
@ -489,104 +296,128 @@ jobs:
echo "Verificando Servidor 2 (ARM)..."
ssh -o StrictHostKeyChecking=no ubuntu@129.146.116.218 'curl -f http://localhost:8080/health || echo "⚠️ Servidor 2 pode não estar respondendo"'
deploy-test:
name: Deploy to Release Swarm (ARM)
runs-on: [self-hosted, arm64, bcards]
deploy-staging:
name: Deploy to Staging (x86 - Local)
runs-on: ubuntu-latest
needs: [build-and-push]
if: startsWith(github.ref_name, 'Release/')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version
id: version
run: |
BRANCH_NAME="${{ github.ref_name }}"
VERSION_RAW=${BRANCH_NAME#Release/}
# Only remove V/v if it's at the start and followed by a number (like v1.0.0)
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]\([0-9]\)/\1/')
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
[ -z "$VERSION" ] && VERSION="0.0.1"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "📦 Deploying version: $VERSION"
- name: Prepare release stack manifest
- name: Deploy to Staging Server
run: |
mkdir -p artifacts
BCARDS_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
echo "🚀 Deploying to staging server (x86)..."
# Replace ${BCARDS_IMAGE} with actual image name using sed
sed "s|\${BCARDS_IMAGE}|${BCARDS_IMAGE}|g" deploy/docker-stack.release.yml > artifacts/docker-stack.release.yml
# Configura SSH (igual ao QRRapido)
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
echo "🔧 Generated manifest with image: ${BCARDS_IMAGE}"
echo "📄 Manifest content:"
head -10 artifacts/docker-stack.release.yml
# Adiciona hosts conhecidos
ssh-keyscan -H 141.148.162.114 >> ~/.ssh/known_hosts
ssh-keyscan -H 129.146.116.218 >> ~/.ssh/known_hosts
- name: Deploy to release swarm
# Testa a chave SSH
ssh-add ~/.ssh/id_rsa 2>/dev/null || echo "SSH key loaded"
# 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 8080: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 "🚀 Deploying release stack to Orange Pi swarm..."
docker stack deploy -c artifacts/docker-stack.release.yml bcards-release
echo "🏥 Verificando saúde do servidor de staging..."
sleep 30
- name: Await release service readiness
run: |
echo "⏳ Aguardando serviço bcards-release estabilizar..."
ATTEMPTS=30
while [ $ATTEMPTS -gt 0 ]; do
REPLICAS=$(docker service ls --filter name=bcards-release_bcards-release --format '{{.Replicas}}')
if [ "$REPLICAS" = "1/1" ]; then
echo "✅ Serviço com $REPLICAS réplica"
break
fi
echo "Atual: ${REPLICAS:-N/A}; aguardando..."
sleep 5
ATTEMPTS=$((ATTEMPTS-1))
done
if [ "$REPLICAS" != "1/1" ]; then
echo "❌ Serviço não atingiu 1/1 réplica"
docker service ps bcards-release_bcards-release
exit 1
fi
docker service ps bcards-release_bcards-release
echo "Verificando Servidor Staging (x86)..."
ssh -o StrictHostKeyChecking=no ubuntu@192.168.0.100 'curl -f http://localhost:8080/health || echo "⚠️ Servidor staging pode não estar respondendo"'
cleanup:
name: Cleanup Old Resources
runs-on: ubuntu-latest
needs: [deploy-production, deploy-test]
if: always() && (needs.deploy-production.result == 'success' || needs.deploy-test.result == 'success')
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..."
if [ "${{ github.ref_name }}" = "main" ]; then
# Configura SSH (igual ao QRRapido)
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
# Adiciona hosts conhecidos
ssh-keyscan -H 141.148.162.114 >> ~/.ssh/known_hosts
ssh-keyscan -H 129.146.116.218 >> ~/.ssh/known_hosts
# Testa a chave SSH
ssh-add ~/.ssh/id_rsa 2>/dev/null || echo "SSH key loaded"
for server in 141.148.162.114 129.146.116.218; do
# 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
else
echo " Release branch: limpeza remota ignorada (Swarm gerencia recursos)."
fi
echo "✅ Limpeza concluída!"
deployment-summary:
name: Deployment Summary
runs-on: ubuntu-latest
needs: [deploy-production, deploy-test]
needs: [deploy-production, deploy-staging]
if: always()
steps:
@ -599,15 +430,15 @@ jobs:
echo "🏗️ Registry: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
if [ "${{ github.ref_name }}" = "main" ]; then
echo "🌍 Environment: Production (Swarm ARM)"
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: Release (Swarm ARM)"
echo "🖥️ Servers: 141.148.162.114, 129.146.116.218"
echo "📦 Branch Tag: ${{ github.ref_name }}"
echo "🔗 Status: ${{ needs.deploy-test.result }}"
echo "🌍 Environment: Staging (x86)"
echo "🖥️ Server: 192.168.0.100"
echo "📦 Tag: ${{ github.ref_name }}"
echo "🔗 Status: ${{ needs.deploy-staging.result }}"
fi
echo "===================="

View File

@ -1,36 +0,0 @@
# Repository Guidelines
## Project Structure & Module Organization
- `src/BCards.Web` is the main MVC app (Controllers, Services, Repositories, Razor Views, `wwwroot` assets).
- `src/BCards.IntegrationTests` spins up the site with fixtures for API-level checks.
- `tests/BCards.Tests` hosts xUnit + Moq unit coverage with overrides in `appsettings.Testing.json`.
- `tests.e2e` carries Playwright specs and config; utility scripts live under `scripts/`, with `clean-build.sh` mirroring CI cleanup.
## Build, Test & Development Commands
- `dotnet restore && dotnet build BCards.sln` primes dependencies and compiles.
- `dotnet run --project src/BCards.Web` launches the site (HTTPS on 5001 by default).
- `dotnet test` executes unit + integration suites; add `--collect:"XPlat Code Coverage"` to emit coverlet results.
- In `tests.e2e`, run `npm install` once and `npx playwright test` per change; append `--headed` when debugging flows.
- `./clean-build.sh` removes stale `bin/obj` output before CI or release builds.
## Coding Style & Naming Conventions
- Use 4-space indents, file-scoped namespaces, PascalCase for types, camelCase for locals, and `_camelCase` for DI fields.
- Keep Razor views presentation-only; push logic into Services, ViewModels, or TagHelpers.
- Store localization strings in `Resources/`, shared UI in `Views/Shared`, and bundle-ready assets in `wwwroot`.
- Run `dotnet format` before pushing; .NET 8 analyzers treat warnings as errors in the pipeline.
## Testing Guidelines
- Mirror namespaces when creating unit files (`FooServiceTests` for `FooService`) and favour FluentAssertions for expressiveness.
- Integration scenarios reside in `src/BCards.IntegrationTests/Tests`; use shared fixtures to mock MongoDB/Stripe without polluting global state.
- End-to-end cases focus on signup, checkout, and profile rendering; keep snapshots in `tests.e2e/debug_*`.
- Target ≥80% coverage across `Services/` and `Repositories/`; call out gaps explicitly in the PR body.
## Commit & Pull Request Guidelines
- Follow the observed `type: resumo` format (`feat: artigos & tutoriais`, `fix: checkout`); keep scopes short, Portuguese when public-facing.
- Squash WIP branches before review; one functional change per commit.
- Every PR needs a summary, verification list (`dotnet test`, Playwright when touched), related issue link, and UI artifacts when visuals change.
- Tag a module expert for review and flip the `Ready for QA` label only after E2E automation passes.
## Security & Configuration Notes
- Keep secrets out of version control; base new configs on `appsettings.Production.example.json` and document required keys.
- When callback URLs move, update both the environment files (`Dockerfile`, `docker-compose*.yml`) and external provider dashboards together.

View File

@ -18,31 +18,20 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipeline", "Pipeline", "{3F
.gitea\workflows\deploy-bcards.yml = .gitea\workflows\deploy-bcards.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
Conexoes.txt = Conexoes.txt
README.md = README.md
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
Testing|Any CPU = Testing|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Testing|Any CPU.ActiveCfg = Testing|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Testing|Any CPU.Build.0 = Testing|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Testing|Any CPU.ActiveCfg = Testing|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Testing|Any CPU.Build.0 = Testing|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -1,740 +0,0 @@
[{
"_id": {
"$oid": "685b17a70138fec28779d354"
},
"name": "Corretor de Imóveis",
"slug": "corretor",
"icon": "🏠",
"seoKeywords": [
"corretor",
"imóveis",
"casa",
"apartamento",
"venda",
"locação"
],
"description": "Profissionais especializados em compra, venda e locação de imóveis",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:24:55.336Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d355"
},
"name": "Tecnologia",
"slug": "tecnologia",
"icon": "💻",
"seoKeywords": [
"desenvolvimento",
"software",
"programação",
"tecnologia",
"TI"
],
"description": "Empresas e profissionais de tecnologia, desenvolvimento e TI",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.709Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d356"
},
"name": "Saúde",
"slug": "saude",
"icon": "🏥",
"seoKeywords": [
"médico",
"saúde",
"clínica",
"consulta",
"tratamento"
],
"description": "Profissionais da saúde, clínicas e consultórios médicos",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.713Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d357"
},
"name": "Educação",
"slug": "educacao",
"icon": "📚",
"seoKeywords": [
"educação",
"ensino",
"professor",
"curso",
"escola"
],
"description": "Professores, escolas, cursos e instituições de ensino",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.717Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d358"
},
"name": "Comércio",
"slug": "comercio",
"icon": "🛍️",
"seoKeywords": [
"loja",
"comércio",
"venda",
"produtos",
"e-commerce"
],
"description": "Lojas, e-commerce e estabelecimentos comerciais",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.720Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d359"
},
"name": "Serviços",
"slug": "servicos",
"icon": "🔧",
"seoKeywords": [
"serviços",
"prestador",
"profissional",
"especializado"
],
"description": "Prestadores de serviços gerais e especializados",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.723Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d35a"
},
"name": "Alimentação",
"slug": "alimentacao",
"icon": "🍽️",
"seoKeywords": [
"restaurante",
"comida",
"delivery",
"alimentação",
"gastronomia"
],
"description": "Restaurantes, delivery, food trucks e estabelecimentos alimentícios",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.727Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d35b"
},
"name": "Beleza",
"slug": "beleza",
"icon": "💄",
"seoKeywords": [
"beleza",
"salão",
"estética",
"cabeleireiro",
"manicure"
],
"description": "Salões de beleza, barbearias, estética e cuidados pessoais",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.731Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d35c"
},
"name": "Advocacia",
"slug": "advocacia",
"icon": "⚖️",
"seoKeywords": [
"advogado",
"jurídico",
"direito",
"advocacia",
"legal"
],
"description": "Advogados, escritórios jurídicos e consultoria legal",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.734Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d35d"
},
"name": "Arquitetura",
"slug": "arquitetura",
"icon": "🏗️",
"seoKeywords": [
"arquiteto",
"engenheiro",
"construção",
"projeto",
"reforma"
],
"description": "Arquitetos, engenheiros e profissionais da construção",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.737Z"
}
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6933"
},
"name": "Artesanato",
"slug": "artesanato",
"icon": "🎨",
"seoKeywords": [
"artesanato",
"artesão",
"feito à mão",
"personalizado",
"criativo",
"decoração"
],
"description": "Artesãos e criadores de produtos feitos à mão, decoração e arte personalizada",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6934"
},
"name": "Papelaria",
"slug": "papelaria",
"icon": "📝",
"seoKeywords": [
"papelaria",
"escritório",
"material escolar",
"impressão",
"convites",
"personalização"
],
"description": "Lojas de papelaria, material de escritório, impressão e produtos personalizados",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6935"
},
"name": "Coaching",
"slug": "coaching",
"icon": "🎯",
"seoKeywords": [
"coaching",
"mentoria",
"desenvolvimento pessoal",
"life coach",
"business coach",
"liderança"
],
"description": "Coaches, mentores e profissionais de desenvolvimento pessoal e empresarial",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6936"
},
"name": "Fitness",
"slug": "fitness",
"icon": "💪",
"seoKeywords": [
"fitness",
"academia",
"personal trainer",
"musculação",
"treinamento",
"exercícios"
],
"description": "Personal trainers, academias, estúdios de pilates e profissionais fitness",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6937"
},
"name": "Psicologia",
"slug": "psicologia",
"icon": "🧠",
"seoKeywords": [
"psicólogo",
"terapia",
"psicologia",
"saúde mental",
"consultório",
"atendimento"
],
"description": "Psicólogos, terapeutas e profissionais de saúde mental",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6938"
},
"name": "Nutrição",
"slug": "nutricao",
"icon": "🥗",
"seoKeywords": [
"nutricionista",
"dieta",
"nutrição",
"alimentação saudável",
"consultoria nutricional",
"emagrecimento"
],
"description": "Nutricionistas, consultores em alimentação e profissionais da nutrição",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6939"
},
"name": "Moda e Vestuário",
"slug": "moda",
"icon": "👗",
"seoKeywords": [
"moda",
"vestuário",
"roupas",
"fashion",
"estilista",
"costureira"
],
"description": "Lojas de roupas, estilistas, costureiras e profissionais da moda",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693a"
},
"name": "Fotografia",
"slug": "fotografia",
"icon": "📸",
"seoKeywords": [
"fotógrafo",
"fotografia",
"ensaio",
"casamento",
"eventos",
"retratos"
],
"description": "Fotógrafos profissionais, estúdios fotográficos e serviços de fotografia",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693b"
},
"name": "Marketing Digital",
"slug": "marketing-digital",
"icon": "📱",
"seoKeywords": [
"marketing digital",
"social media",
"publicidade",
"SEO",
"gestão de redes",
"digital"
],
"description": "Agências de marketing digital, gestores de redes sociais e consultores digitais",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693c"
},
"name": "Contabilidade",
"slug": "contabilidade",
"icon": "📊",
"seoKeywords": [
"contador",
"contabilidade",
"fiscal",
"imposto de renda",
"MEI",
"consultoria contábil"
],
"description": "Contadores, escritórios contábeis e consultoria fiscal",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693d"
},
"name": "Design",
"slug": "design",
"icon": "🎨",
"seoKeywords": [
"designer",
"design gráfico",
"identidade visual",
"logo",
"criativo",
"branding"
],
"description": "Designers gráficos, criativos e profissionais de identidade visual",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693e"
},
"name": "Consultoria",
"slug": "consultoria",
"icon": "🤝",
"seoKeywords": [
"consultor",
"consultoria",
"assessoria",
"especialista",
"negócios",
"estratégia"
],
"description": "Consultores especializados, assessoria empresarial e serviços de consultoria",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693f"
},
"name": "Pets",
"slug": "pets",
"icon": "🐕",
"seoKeywords": [
"veterinário",
"pet shop",
"animais",
"cuidados",
"petshop",
"adestramento"
],
"description": "Veterinários, pet shops, adestradores e serviços para animais",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6940"
},
"name": "Casa e Jardim",
"slug": "casa-jardim",
"icon": "🏡",
"seoKeywords": [
"paisagismo",
"jardinagem",
"decoração",
"casa",
"jardim",
"plantas"
],
"description": "Paisagistas, jardineiros, decoradores e serviços para casa e jardim",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6941"
},
"name": "Automóveis",
"slug": "automoveis",
"icon": "🚗",
"seoKeywords": [
"mecânico",
"automóveis",
"carros",
"oficina",
"manutenção",
"peças"
],
"description": "Mecânicos, oficinas, lojas de peças e serviços automotivos",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6942"
},
"name": "Turismo",
"slug": "turismo",
"icon": "✈️",
"seoKeywords": [
"turismo",
"viagem",
"agência",
"guia turístico",
"passeios",
"hospedagem"
],
"description": "Agências de turismo, guias, pousadas e prestadores de serviços turísticos",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6943"
},
"name": "Música",
"slug": "musica",
"icon": "🎵",
"seoKeywords": [
"músico",
"professor de música",
"instrumentos",
"aulas",
"banda",
"eventos musicais"
],
"description": "Músicos, professores de música, bandas e profissionais do entretenimento",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6944"
},
"name": "Idiomas",
"slug": "idiomas",
"icon": "🗣️",
"seoKeywords": [
"professor de idiomas",
"inglês",
"espanhol",
"tradutor",
"aulas particulares",
"curso de idiomas"
],
"description": "Professores de idiomas, tradutores e escolas de línguas",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6945"
},
"name": "Limpeza",
"slug": "limpeza",
"icon": "🧽",
"seoKeywords": [
"limpeza",
"faxina",
"diarista",
"higienização",
"empresa de limpeza",
"doméstica"
],
"description": "Empresas de limpeza, diaristas e serviços de higienização",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6946"
},
"name": "Segurança",
"slug": "seguranca",
"icon": "🛡️",
"seoKeywords": [
"segurança",
"vigilante",
"porteiro",
"alarmes",
"monitoramento",
"proteção"
],
"description": "Empresas de segurança, vigilantes e serviços de proteção",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6947"
},
"name": "Eventos",
"slug": "eventos",
"icon": "🎉",
"seoKeywords": [
"eventos",
"festa",
"casamento",
"buffet",
"decoração de festas",
"cerimonial"
],
"description": "Organizadores de eventos, buffets, decoração e cerimonial",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6948"
},
"name": "Transporte",
"slug": "transporte",
"icon": "🚐",
"seoKeywords": [
"transporte",
"frete",
"mudança",
"delivery",
"motorista",
"logística"
],
"description": "Empresas de transporte, fretes, mudanças e serviços de entrega",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6949"
},
"name": "Construção",
"slug": "construcao",
"icon": "🔨",
"seoKeywords": [
"construção",
"pedreiro",
"pintor",
"eletricista",
"encanador",
"reforma"
],
"description": "Profissionais da construção civil, reformas e manutenção predial",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694a"
},
"name": "Joias e Acessórios",
"slug": "joias",
"icon": "💎",
"seoKeywords": [
"joias",
"bijuterias",
"acessórios",
"ourives",
"relógios",
"semijoias"
],
"description": "Joalherias, bijuterias, ourives e lojas de acessórios",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694b"
},
"name": "Odontologia",
"slug": "odontologia",
"icon": "🦷",
"seoKeywords": [
"dentista",
"odontologia",
"clínica dentária",
"ortodontia",
"implante",
"oral"
],
"description": "Dentistas, clínicas odontológicas e profissionais da área bucal",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694c"
},
"name": "Fisioterapia",
"slug": "fisioterapia",
"icon": "🏥",
"seoKeywords": [
"fisioterapeuta",
"fisioterapia",
"reabilitação",
"RPG",
"massagem",
"terapia"
],
"description": "Fisioterapeutas, clínicas de reabilitação e terapias corporais",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694d"
},
"name": "Livraria",
"slug": "livraria",
"icon": "📚",
"seoKeywords": [
"livraria",
"livros",
"sebo",
"literatura",
"editora",
"publicação"
],
"description": "Livrarias, sebos, editoras e comércio de livros e publicações",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694e"
},
"name": "Floricultura",
"slug": "floricultura",
"icon": "🌸",
"seoKeywords": [
"floricultura",
"flores",
"buquê",
"plantas",
"arranjos",
"casamento"
],
"description": "Floriculturas, arranjos florais e comércio de plantas ornamentais",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694f"
},
"name": "Farmácia",
"slug": "farmacia",
"icon": "💊",
"seoKeywords": [
"farmácia",
"farmacêutico",
"medicamentos",
"drogaria",
"manipulação",
"remédios"
],
"description": "Farmácias, drogarias e farmacêuticos especializados",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6950"
},
"name": "Delivery",
"slug": "delivery",
"icon": "🛵",
"seoKeywords": [
"delivery",
"entrega",
"motoboy",
"comida",
"aplicativo",
"rápido"
],
"description": "Serviços de delivery, entregadores e aplicativos de entrega",
"isActive": true
}]

View File

@ -10,11 +10,10 @@ BCards is a professional LinkTree clone built in ASP.NET Core MVC, targeting Bra
### Build & Run
```bash
# Quick clean build (RECOMMENDED after VS 2022 updates)
./clean-build.sh
# Manual process:
# Restore dependencies
dotnet restore
# Build solution
dotnet build
# Run development server
@ -27,23 +26,6 @@ docker-compose up -d
# Access: http://localhost:8080
```
### 🚨 Known Issues After VS 2022 Updates
**Problem**: After VS 2022 updates, build cache gets corrupted causing:
- OAuth login failures (especially Google in Edge browser)
- Need for constant clean/rebuild cycles
- NuGet package resolution errors
**Solution**: Use the automated cleanup script:
```bash
./clean-build.sh
```
**Google OAuth Edge Issue**: If Google login shows "browser not secure" error:
1. Clear browser data for localhost:49178 and accounts.google.com
2. Test in incognito/private mode
3. Use Vivaldi or Chrome (Edge has known compatibility issues)
### Testing
```bash
# Run all tests

View File

@ -1,4 +0,0 @@
bcards
ssh ubuntu@141.148.162.114
convert-it
ssh ubuntu@129.146.116.218

View File

@ -1,394 +0,0 @@
---
title: "BCards vs LinkTree: A Alternativa Brasileira para Profissionais"
description: "Descubra por que o BCards é a melhor alternativa brasileira ao LinkTree para profissionais e empresas. Comparação completa de recursos, preços e diferenciais."
keywords: "linktree, link tree, alternativa ao linktree, melhor que linktree, bcards, cartão digital, bio links, página de links, linktree brasil, linktree brasileiro"
author: "BCards"
date: 2025-11-02
lastmod: 2025-11-02
image: "/images/artigos/bcards-vs-linktree-hero.jpg"
---
# BCards vs LinkTree: A Alternativa Brasileira para Profissionais
Se você está procurando uma alternativa ao LinkTree, você chegou ao lugar certo. O BCards é uma solução brasileira desenvolvida especificamente para profissionais e empresas que querem uma presença digital profissional sem depender de plataformas internacionais.
## O Que São Páginas de Bio Links?
Antes de compararmos, vamos entender o conceito. Ferramentas como LinkTree e BCards são páginas de "bio links" - uma única URL que centraliza todos os seus links importantes:
- Redes sociais (Instagram, LinkedIn, Facebook)
- WhatsApp para contato direto
- Site ou portfólio
- Loja online ou produtos
- Localização física
- E muito mais
A ideia é simples: ao invés de ter apenas um link na bio do Instagram, você tem uma página com todos os seus links organizados.
## Por Que Escolher uma Alternativa Brasileira ao LinkTree?
### 🇧🇷 1. Suporte em Português (de Verdade)
**LinkTree:**
- Suporte majoritariamente em inglês
- Fuso horário dos EUA
- Documentação traduzida automaticamente
**BCards:**
- Suporte 100% em português
- Atendimento no horário comercial brasileiro
- Documentação escrita para o mercado brasileiro
- Entendimento de necessidades locais
### 💰 2. Preços em Real (Sem Surpresas)
**LinkTree:**
- Preços em dólar (USD)
- Sujeito a variação cambial
- IOF em pagamentos internacionais (6.38%)
- Preço final imprevisível
**BCards:**
- Preços fixos em Real (R$)
- Sem variação cambial
- Sem taxas de IOF
- Pagamento via Pix, cartão brasileiro
**Exemplo Real:**
- LinkTree Pro: $9 USD/mês ≈ R$ 45-50 (variável)
- BCards Básico: R$ 12,90/mês (fixo)
### 🎯 3. URLs Profissionais e Semânticas
**LinkTree:**
```
linktr.ee/seunome
```
- Domínio genérico
- Não transmite profissionalismo
- Óbvio que é uma ferramenta de terceiros
**BCards:**
```
bcards.site/page/tecnologia/seunome
bcards.site/page/advocacia/drjoao
bcards.site/page/saude/drmaria
```
- URLs categorizadas por profissão
- SEO otimizado com categoria
- Aparência mais profissional
## Comparação Técnica: LinkTree vs BCards
### Recursos Disponíveis
| Recurso | LinkTree Free | LinkTree Pro ($9/mês) | BCards Grátis | BCards Básico (R$ 12,90/mês) |
|---------|---------------|----------------------|---------------|----------------------------|
| **Número de Links** | Ilimitado | Ilimitado | 5 | 15 |
| **Temas** | Básicos | Avançados | Básicos | Todos |
| **Analytics** | Básico | Detalhado | Básico | Detalhado |
| **Remover Logo** | ❌ | ✅ | ✅ (Grátis!) | ✅ |
| **URLs Customizáveis** | Limitado | ✅ | ✅ | ✅ |
| **Suporte em Português** | ❌ | ❌ | ✅ | ✅ |
| **Pagamento em Real** | ❌ | ❌ | ✅ | ✅ |
| **Categorização por Profissão** | ❌ | ❌ | ✅ | ✅ |
| **Moderação Humana** | ❌ | ❌ | ✅ | ✅ |
### Diferenciais Técnicos do BCards
#### 1. Sistema de Categorias Profissionais
O BCards organiza perfis por categoria profissional, o que oferece vantagens:
**SEO:**
- Google indexa melhor URLs categorizadas
- Buscar "advogado + cidade" pode trazer seu BCard
- Autoridade topical (especialização por nicho)
**Profissionalismo:**
- URL mostra sua área de atuação
- Facilita encontrar profissionais similares
- Networking dentro da categoria
**Exemplos:**
```
bcards.site/page/advocacia/dr-pedro-silva
bcards.site/page/tecnologia/joao-dev
bcards.site/page/saude/dra-ana-cardiologia
bcards.site/page/beleza/salao-mariana
```
#### 2. Moderação Humana (Sem Conteúdo Inapropriado)
**LinkTree:**
- Moderação automática (quando há)
- Permite conteúdo adulto em alguns planos
- Risco de associação com perfis inadequados
**BCards:**
- Moderação humana de todos os perfis
- Políticas claras de conteúdo profissional
- Ambiente seguro para profissionais sérios
- Rejeição de conteúdo inapropriado
**Por que isso importa?**
- Sua marca não fica ao lado de conteúdo duvidoso
- Transmite profissionalismo
- Ideal para advogados, médicos, contadores, etc.
#### 3. Integração com Mercado Brasileiro
**LinkTree:**
- Links para Venmo, CashApp (EUA)
- Integração com plataformas americanas
- Checkout Shopify (internacional)
**BCards:**
- Foco em WhatsApp Business (essencial no Brasil)
- Integração futura com Pix
- Mercado Livre, Mercado Pago, PagSeguro
- Google Maps para localização (crucial para negócios locais)
## Casos de Uso: Quando BCards é Melhor?
### ✅ Ideal para BCards:
1. **Advogados e Profissionais Liberais**
- URL profissional com categoria
- Moderação garante ambiente sério
- Preço acessível em Real
2. **Pequenos Negócios Locais**
- Integração com Google Maps
- WhatsApp Business como principal contato
- Preços em moeda local
3. **Profissionais de Saúde**
- Ambiente moderado e profissional
- Links para agendamento local
- Conformidade com políticas do CFM
4. **Prestadores de Serviços**
- Categorização por área
- Fácil de compartilhar localmente
- Suporte em português
### 🤔 Quando Considerar LinkTree:
1. **Influenciadores Internacionais**
- Audiência global
- Receita em dólar
- Integrações com plataformas americanas
2. **E-commerce Internacional**
- Vendas para fora do Brasil
- Shopify, Amazon global
- Múltiplas moedas
## Recursos Exclusivos do BCards
### 1. Links de Produtos com Preview Automático
Adicione links de produtos e o BCards captura automaticamente:
- Imagem do produto
- Preço
- Descrição
- Botão direto para compra
**Suportado:**
- Mercado Livre
- Shopee
- Amazon Brasil
- Lojas com Open Graph
### 2. Sistema de Preview para Moderação
Antes de seu perfil ficar público:
- Revisão humana do conteúdo
- Feedback se algo precisa ser ajustado
- Link de preview para você testar
- Aprovação rápida (geralmente em 24h)
### 3. Analytics Focado no Mercado Brasileiro
**Métricas relevantes:**
- Cliques por link
- Horários de maior acesso (fuso brasileiro)
- Origem do tráfego (Instagram, WhatsApp, etc.)
- Dispositivos (maioria mobile no Brasil)
## Preços Comparados (Detalhado)
### LinkTree
**Free:**
- Links ilimitados
- Temas básicos
- Analytics limitado
- Logo LinkTree presente
**Pro ($9 USD/mês ≈ R$ 45-50):**
- Temas premium
- Analytics avançado
- Remove logo
- Links prioritários
- **Problema:** Preço varia com dólar + IOF
**Premium ($24 USD/mês ≈ R$ 120-135):**
- Tudo do Pro
- Integrações avançadas
- Suporte prioritário
### BCards
**Gratuito:**
- 5 links
- Temas básicos
- Analytics básico
- **Sem logo BCards** (diferencial!)
**Básico (R$ 12,90/mês fixo):**
- 15 links
- Todos os temas
- Analytics detalhado
- Suporte em português
**Premium (R$ 29,90/mês fixo):**
- Links ilimitados
- Temas personalizados
- Logo personalizado
- Cores customizadas
- Suporte prioritário
- Upload de PDFs (até 5 arquivos)
**Economia Real:**
- LinkTree Pro anual: ~$108 + IOF = ~R$ 540-600
- BCards Básico anual: R$ 118,80
- **Economia: ~R$ 420/ano**
## Migração do LinkTree para BCards
### É Fácil Migrar?
**Sim! Processo simples:**
1. **Crie sua conta BCards** (2 minutos)
2. **Copie seus links do LinkTree** (5 minutos)
3. **Cole no BCards** (5 minutos)
4. **Ajuste tema e cores** (5 minutos)
5. **Atualize bio das redes sociais** (2 minutos)
**Total: ~20 minutos**
### Posso Testar Antes de Migrar?
Sim! O plano gratuito do BCards permite:
- Criar perfil completo
- Testar 5 links principais
- Ver como funciona
- Decidir se vale a pena
**Sem risco:**
- Não precisa cancelar LinkTree antes
- Teste paralelamente
- Migre quando se sentir confortável
## Perguntas Frequentes: BCards vs LinkTree
### O BCards funciona com Instagram, TikTok, YouTube?
Sim! Funciona com qualquer rede social que permite um link na bio:
- Instagram ✅
- TikTok ✅
- YouTube ✅
- LinkedIn ✅
- Facebook ✅
- Twitter/X ✅
### Posso usar domínio próprio?
**LinkTree:** Apenas em planos premium ($24/mês)
**BCards:** Em desenvolvimento (plano Premium futuro)
Atualmente: `bcards.site/page/categoria/seunome`
### E se eu já tenho linktr.ee nas minhas mídias impressas?
Você pode:
1. Manter LinkTree redirecionando para BCards temporariamente
2. Atualizar gradualmente materiais impressos
3. Usar QR Code do BCards (gera automaticamente)
### O BCards tem app mobile?
Não é necessário! É 100% web:
- Funciona perfeitamente no mobile
- Não ocupa espaço no celular
- Atualiza instantaneamente
- Sem necessidade de instalar nada
### Posso cancelar a qualquer momento?
Sim! Sem fidelidade:
- Cancele quando quiser
- Sem multa
- Sem burocracia
- Dados não são deletados (voltam ao plano grátis)
## Conclusão: Vale a Pena Trocar?
### ✅ Vale a pena trocar do LinkTree para BCards se:
- Você é profissional ou empresa brasileira
- Quer economia real (3-5x mais barato)
- Prefere suporte em português
- Busca URL mais profissional
- Quer ambiente moderado e sério
- Precisa de pagamento em Real sem surpresas
### 🤔 Talvez LinkTree seja melhor se:
- Sua audiência é majoritariamente internacional
- Você vende em dólar
- Precisa integrações específicas americanas
- Já tem grande investimento na marca "linktr.ee"
## Comece Agora
### Teste Gratuitamente
Não precisa acreditar na nossa palavra. Teste você mesmo:
1. **Crie conta grátis** (sem cartão de crédito)
2. **Configure seus 5 links principais**
3. **Escolha um tema profissional**
4. **Compartilhe e veja os resultados**
Se gostar, faça upgrade para ter mais links. Se não gostar, não paga nada.
**Pronto para dar o próximo passo?** [Criar meu BCard grátis](https://bcards.site)
---
## Comparação Rápida (TL;DR)
| Aspecto | LinkTree | BCards |
|---------|----------|--------|
| **Preço (plano básico)** | ~R$ 45-50/mês (varia) | R$ 12,90/mês (fixo) |
| **Suporte** | Inglês | Português |
| **Pagamento** | Dólar + IOF | Real (Pix/Cartão BR) |
| **URL** | linktr.ee/nome | bcards.site/categoria/nome |
| **Moderação** | Automática | Humana |
| **Foco** | Global | Brasil |
| **Remover logo (grátis)** | ❌ | ✅ |
**Economia anual:** ~R$ 420 escolhendo BCards
---
## Recursos Adicionais
- [Tutorial: Como Criar um BCard](/tutoriais/tecnologia/como-criar-um-bcard)
- [BCards para Advogados](/tutoriais/advocacia/como-advogados-podem-usar-bcards)
- [Transformação Digital](/artigos/transformacao-digital-pequenos-negocios)
- [Fale com nosso suporte](/support)

View File

@ -1,208 +0,0 @@
---
title: "Transformação Digital para Pequenos Negócios: Por Onde Começar?"
description: "Descubra como a transformação digital pode revolucionar seu pequeno negócio e quais são os primeiros passos para entrar no mundo digital"
keywords: "transformação digital, pequenos negócios, digitalização, empreendedorismo, tecnologia"
author: "BCards"
date: 2025-11-02
lastmod: 2025-11-02
image: "/images/artigos/transformacao-digital-hero.jpg"
---
# Transformação Digital para Pequenos Negócios: Por Onde Começar?
A transformação digital não é mais uma opção, mas uma necessidade para pequenos negócios que querem se manter competitivos. Mas o que exatamente significa "transformação digital" e como um pequeno negócio pode embarcar nessa jornada sem gastar fortunas?
## O Que É Transformação Digital?
Transformação digital é o processo de usar tecnologias digitais para criar novos processos de negócio, cultura e experiências do cliente — ou modificar os existentes — para atender às mudanças nas demandas do mercado.
Para pequenos negócios, isso pode ser tão simples quanto:
- Ter uma presença online profissional
- Aceitar pagamentos digitais
- Usar redes sociais para marketing
- Digitalizar processos internos
- Oferecer atendimento online
## Por Que Pequenos Negócios Precisam Se Digitalizar?
### 1. Alcance Maior
Com presença digital, você não está mais limitado à sua localização física. Clientes de qualquer lugar podem encontrar e conhecer seu negócio.
### 2. Custos Menores
Marketing digital é significativamente mais barato que publicidade tradicional. Uma campanha no Instagram pode custar centenas de vezes menos que um anúncio em jornal.
### 3. Competitividade
Seus concorrentes já estão online. Não estar presente digitalmente significa perder clientes para quem está.
### 4. Conveniência para o Cliente
Clientes modernos esperam poder:
- Encontrar informações online
- Fazer pedidos pelo WhatsApp
- Pagar com Pix ou cartão
- Ver avaliações de outros clientes
## Os 5 Primeiros Passos da Transformação Digital
### 1. Crie uma Presença Digital Básica
Você não precisa de um site complexo. Comece com:
**Mínimo Necessário:**
- Uma página de links profissional (como o BCards)
- Perfil no Instagram ou Facebook
- WhatsApp Business configurado
**Por que isso funciona:**
- É gratuito ou muito barato
- Pode ser feito em 1 dia
- Já coloca você no mapa digital
### 2. Organize Seus Contatos Digitais
Centralize todos os seus pontos de contato:
- WhatsApp para pedidos
- Instagram para divulgação
- Email para orçamentos
- Link para catálogo de produtos
**Solução simples:** Use um cartão digital (BCard) que concentra todos esses links em um só lugar. Assim, você compartilha um único link e o cliente escolhe como prefere contatar você.
### 3. Aceite Pagamentos Digitais
Configure:
- Pix (essencial!)
- Maquininha de cartão
- Link de pagamento (Mercado Pago, PagSeguro)
**Resultado:** Você não perde vendas porque "o cliente não tem dinheiro trocado".
### 4. Use as Redes Sociais Estrategicamente
Não precisa estar em todas. Escolha UMA ou DUAS e faça bem:
**Para produtos físicos:** Instagram + Facebook
**Para serviços profissionais:** LinkedIn + Instagram
**Para comércio local:** Facebook + Google Meu Negócio
**Dica de ouro:** Poste regularmente (mesmo que seja 2-3x por semana). Consistência > Quantidade.
### 5. Peça Avaliações e Depoimentos
Clientes satisfeitos são seu melhor marketing. Peça:
- Avaliações no Google
- Comentários no Instagram
- Depoimentos em vídeo (WhatsApp)
**Como pedir:** "Se você ficou satisfeito com nosso serviço, pode deixar uma avaliação no Google? Isso nos ajuda muito!"
## Ferramentas Essenciais (e Baratas!)
### Gratuitas
- **Google Meu Negócio** - Apareça no Google Maps
- **WhatsApp Business** - Atendimento profissional
- **Canva** - Design de posts e materiais
- **BCards** - Página de links profissional (plano grátis disponível)
### Investimento Baixo (menos de R$ 50/mês)
- **Instagram Ads** - Impulsione suas publicações
- **Hotmart/Eduzz** - Venda produtos digitais
- **Mercado Pago** - Links de pagamento
- **BCards Premium** - Cartão digital profissional completo
## Erros Comuns a Evitar
### ❌ Erro 1: Querer Fazer Tudo de Uma Vez
Não tente criar site, app, loja virtual e estar em todas as redes sociais simultaneamente. Comece pequeno e expanda gradualmente.
### ❌ Erro 2: Comprar Ferramentas Caras Demais
Você não precisa de um site de R$ 10.000 quando está começando. Use ferramentas simples primeiro e evolua conforme cresce.
### ❌ Erro 3: Não Ter um Ponto Central
Ter Instagram, Facebook, WhatsApp é ótimo. Mas onde você manda as pessoas? Tenha UM lugar que centraliza tudo (um BCard, por exemplo).
### ❌ Erro 4: Não Medir Resultados
Use as ferramentas gratuitas de analytics:
- Instagram Insights
- Google Analytics
- Relatórios do WhatsApp Business
## Casos de Sucesso Inspiradores
### Padaria da Dona Maria
**Antes:** Apenas loja física, dependia de clientes que passavam na frente
**Depois:**
- BCard com cardápio e WhatsApp
- QR Code nas embalagens
- Pedidos pelo WhatsApp aumentaram 60%
- Clientes de bairros vizinhos começaram a pedir
### Consultório do Dr. Pedro (Dentista)
**Antes:** Agendamentos apenas por telefone em horário comercial
**Depois:**
- Sistema de agendamento online
- BCard com links para WhatsApp, Google Maps e Instagram
- Reduziu trabalho da secretária em 40%
- Mais agendamentos fora do horário comercial
### Loja de Roupas da Ana
**Antes:** Vendas apenas presenciais
**Depois:**
- Catálogo no Instagram
- BCard com link para catálogo completo
- WhatsApp Business para pedidos
- Vendas online representam 35% do faturamento
## Próximos Passos Para Seu Negócio
### Semana 1: Organização
- Liste todos os seus pontos de contato atuais
- Crie um BCard com todos os links
- Configure WhatsApp Business
### Semana 2: Presença Online
- Atualize perfis de redes sociais
- Adicione o BCard na bio
- Tire fotos profissionais dos produtos/serviços
### Semana 3: Divulgação
- Crie QR Code do seu BCard
- Coloque em materiais impressos
- Peça avaliações de clientes fiéis
### Semana 4: Otimização
- Analise o que funcionou
- Ajuste e melhore
- Planeje próximos passos
## Conclusão
A transformação digital para pequenos negócios não precisa ser cara ou complicada. Comece com o básico:
1. Presença online simples (BCard + redes sociais)
2. Facilite o contato (WhatsApp Business)
3. Aceite pagamentos digitais (Pix + cartão)
4. Peça avaliações de clientes
5. Evolua gradualmente
O importante é começar. Cada pequeno passo digital que você dá coloca seu negócio à frente dos concorrentes que ainda não se moveram.
**Pronto para dar o primeiro passo?** [Crie seu BCard profissional grátis](https://bcards.site) e comece sua transformação digital hoje!
---
## Recursos Adicionais
- [Tutorial: Como Criar um BCard](/tutoriais/tecnologia/como-criar-um-bcard)
- [BCards para Advogados](/tutoriais/advocacia/como-advogados-podem-usar-bcards)
- [Fale com nosso suporte](/support)

View File

@ -1,197 +0,0 @@
---
title: "BCards para Advogados: Guia Completo"
description: "Descubra como advogados podem usar o BCards para fortalecer sua presença digital, atrair mais clientes e organizar todos os seus contatos profissionais"
keywords: "advogado, advocacia, cartão digital, marketing jurídico, presença online, bcards"
author: "BCards"
date: 2025-11-02
lastmod: 2025-11-02
image: "/images/tutoriais/advocacia/advogados-bcards-hero.jpg"
category: "advocacia"
---
# BCards para Advogados: Guia Completo
Na era digital, ter uma presença online profissional é essencial para advogados que querem se destacar e atrair mais clientes. O BCards é a solução perfeita para centralizar todos os seus contatos e informações profissionais em um único lugar.
## Por que Advogados Precisam de um BCard?
### 1. Credibilidade Profissional
Um cartão digital transmite modernidade e profissionalismo. Seus clientes verão que você está atualizado com as tecnologias atuais.
### 2. Facilidade de Contato
Centralize todas as formas de contato em um único link:
- WhatsApp para consultas rápidas
- Email profissional
- Telefone do escritório
- Endereço com localização no mapa
- Redes sociais profissionais (LinkedIn)
### 3. Marketing Jurídico Eficiente
Divulgue seus serviços de forma ética e profissional:
- Link para artigos jurídicos que você escreve
- Vídeos educativos no YouTube
- Depoimentos de clientes satisfeitos
- Áreas de atuação
## Como Configurar seu BCard Profissional
### Passo 1: Informações Essenciais
Inclua em seu perfil:
**Informações Básicas:**
- Nome completo e OAB
- Foto profissional (de terno/blazer)
- Bio concisa e profissional
- Áreas de especialização
**Exemplo de Bio:**
> "Dr. João Silva - OAB/SP 123.456
> Advogado especialista em Direito do Consumidor
> Atuação em todo território nacional
> Mais de 15 anos de experiência"
### Passo 2: Links Estratégicos
Organize seus links por ordem de importância:
1. **WhatsApp Business** - Para consultas rápidas
2. **Agendar Consulta** - Link para sistema de agendamento
3. **Áreas de Atuação** - Página explicando seus serviços
4. **Blog Jurídico** - Artigos e conteúdo educativo
5. **LinkedIn** - Networking profissional
6. **Localização** - Google Maps do seu escritório
### Passo 3: Conteúdo Educativo
Advogados podem se destacar compartilhando conhecimento:
- Artigos sobre direitos do consumidor
- Vídeos explicando processos jurídicos
- FAQ com perguntas frequentes
- Guias práticos para clientes
## Estratégias de Marketing para Advogados
### 1. Use no Cartão de Visitas
Adicione um QR Code do seu BCard no cartão de visitas físico. Assim, as pessoas podem salvar todos os seus contatos instantaneamente.
### 2. Assinatura de Email
Inclua o link do seu BCard na assinatura de todos os emails profissionais.
**Exemplo:**
```
Dr. João Silva
Advogado - OAB/SP 123.456
📱 Todos os meus contatos: bcards.site/page/advocacia/joao-silva
```
### 3. Redes Sociais
- Instagram: coloque na bio
- LinkedIn: adicione na seção "Sobre"
- Facebook: fixe uma publicação com o link
### 4. Materiais Impressos
Adicione QR Codes em:
- Folders informativos
- Cartazes do escritório
- Apresentações em palestras
- Documentos entregues a clientes
## Links Recomendados para Advogados
### Essenciais
- ✅ WhatsApp Business (atendimento)
- ✅ Email profissional
- ✅ Telefone do escritório
- ✅ Localização (Google Maps)
- ✅ LinkedIn
### Opcionais mas Recomendados
- 📝 Blog jurídico
- 📺 Canal no YouTube (vídeos educativos)
- 📅 Sistema de agendamento online
- 📄 Portfólio de casos (quando permitido)
- 💬 Depoimentos de clientes
- 📖 E-books e materiais gratuitos
## Ética e Boas Práticas
### O que Fazer ✅
- Mantenha uma apresentação sóbria e profissional
- Use foto com traje formal
- Divulgue conteúdo educativo
- Informe número da OAB sempre
- Seja transparente sobre áreas de atuação
### O que Evitar ❌
- Promessas de resultado garantido
- Comparações com outros advogados
- Preços divulgados publicamente (consulte a OAB)
- Linguagem sensacionalista
- Imagens inadequadas
## Casos de Uso Reais
### Caso 1: Dra. Maria - Direito de Família
A Dra. Maria aumentou suas consultas em 40% após criar seu BCard. Ela incluiu:
- Link para agendar consulta online
- Artigos sobre divórcio e pensão alimentícia
- Depoimentos de clientes (com autorização)
- WhatsApp exclusivo para novos casos
### Caso 2: Dr. Pedro - Direito Trabalhista
O Dr. Pedro usa seu BCard para:
- Compartilhar guias sobre direitos trabalhistas
- Vídeos curtos explicando rescisão, FGTS, etc.
- Formulário para pré-avaliação de casos
- Links para suas redes sociais educativas
## Planos Recomendados
### Para Advogados Iniciantes
**Plano Básico (R$ 12,90/mês)**
- Até 15 links
- Temas profissionais
- Analytics para acompanhar acessos
### Para Escritórios Estabelecidos
**Plano Premium (R$ 29,90/mês)**
- Links ilimitados
- Logo do escritório
- Cores personalizadas
- Suporte prioritário
- Upload de PDFs para contratos, petições e materiais exclusivos
## Conclusão
O BCards é uma ferramenta essencial para advogados modernos que querem:
- Facilitar o contato com clientes
- Apresentar-se de forma profissional
- Centralizar todas as informações em um só lugar
- Aumentar a captação de clientes
A melhor parte? É rápido de configurar e totalmente compatível com as regras da OAB para marketing jurídico.
**Pronto para modernizar sua presença digital?** [Crie seu BCard profissional agora!](https://bcards.site)
---
## Recursos Adicionais
- [Tutorial: Como Criar um BCard](/tutoriais/tecnologia/como-criar-um-bcard)
- [Suporte BCards](/support)
- [Políticas de Privacidade](/privacidade)

View File

@ -1,148 +0,0 @@
---
title: "Como Criar um BCard Profissional em 5 Minutos"
description: "Aprenda passo a passo como criar seu cartão digital profissional com BCards e centralize todos os seus links em um único lugar"
keywords: "bcards, cartão digital, tutorial, tecnologia, links, página profissional"
author: "BCards"
date: 2025-11-02
lastmod: 2025-11-02
image: "/images/tutoriais/tecnologia/criar-bcard-hero.jpg"
category: "tecnologia"
---
# Como Criar um BCard Profissional em 5 Minutos
Criar um cartão digital profissional nunca foi tão fácil! Neste tutorial, vamos mostrar como você pode ter sua página de links profissional em apenas 5 minutos.
## O que é um BCard?
O BCard é sua página profissional na internet, onde você centraliza todos os seus links importantes:
- Redes sociais (Instagram, LinkedIn, Facebook)
- WhatsApp para contato
- Site ou portfólio
- Links de produtos ou serviços
- E muito mais!
## Passo 1: Faça seu Cadastro
1. Acesse [bcards.site](https://bcards.site)
2. Clique em "Criar meu BCard grátis"
3. Escolha fazer login com Google ou Microsoft
4. Pronto! Sua conta está criada
> **Dica:** Use o mesmo email que você usa profissionalmente para facilitar o gerenciamento
## Passo 2: Escolha seu Tema
Temos diversos temas profissionais disponíveis:
- **Minimalista**: Clean e elegante
- **Moderno**: Cores vibrantes e design atual
- **Profissional**: Sóbrio e corporativo
- **Criativo**: Para quem quer se destacar
Escolha o tema que mais combina com sua personalidade ou marca!
## Passo 3: Adicione seus Links
Agora é hora de adicionar os links mais importantes:
1. Clique em "Adicionar Link"
2. Escolha o tipo (Instagram, WhatsApp, Site, etc.)
3. Cole o link
4. Dê um título descritivo
5. Salve!
### Tipos de Links Suportados
- Redes Sociais (Instagram, Facebook, LinkedIn, TikTok)
- Contato (WhatsApp, Email, Telefone)
- Sites e Portfólios
- Vídeos (YouTube, Vimeo)
- Documentos e PDFs
- Links personalizados
## Passo 4: Personalize seu Perfil
Deixe seu BCard com a sua cara:
- Adicione uma foto de perfil profissional
- Escreva uma bio atrativa
- Configure cores personalizadas (plano Premium)
- Adicione seu logo (plano Premium)
## Passo 5: Compartilhe!
Agora que está pronto, é hora de compartilhar:
- Seu link será: `bcards.site/page/{categoria}/{seu-nome}`
- Adicione na bio do Instagram
- Coloque na assinatura de email
- Compartilhe em grupos de WhatsApp
- Adicione ao cartão de visitas físico com QR Code
## Dicas Profissionais
### 1. Mantenha Atualizado
Revise seus links regularmente e remova os que não são mais relevantes.
### 2. Use Descrições Claras
Ao invés de "Clique aqui", use "Veja meu portfólio completo" ou "Agende uma consulta".
### 3. Priorize os Links Mais Importantes
Coloque os links mais relevantes no topo. As pessoas geralmente clicam nos primeiros links.
### 4. Teste Regularmente
Clique em todos os seus links periodicamente para garantir que estão funcionando.
## Planos e Recursos
### Plano Gratuito
- Até 5 links
- Temas básicos
- Analytics básico
### Plano Básico (R$ 12,90/mês)
- Até 15 links
- Todos os temas
- Analytics detalhado
### Plano Premium (R$ 29,90/mês)
- Links ilimitados
- Temas personalizados
- Logo personalizado
- Suporte prioritário
- Upload de PDFs para materiais extras
## Conclusão
Criar seu BCard profissional é rápido, fácil e pode transformar a forma como você se apresenta online. Em apenas 5 minutos, você tem uma página profissional que centraliza todos os seus contatos e links importantes.
**Pronto para começar?** [Crie seu BCard grátis agora!](https://bcards.site)
---
## Perguntas Frequentes
### Posso mudar meu tema depois?
Sim! Você pode mudar o tema quantas vezes quiser, a qualquer momento.
### Como faço para adicionar um QR Code?
No dashboard, clique em "Baixar QR Code" para fazer download do código QR do seu BCard.
### Posso ter mais de um BCard?
No momento, cada conta pode ter apenas um BCard ativo.
### Precisa de mais ajuda?
Entre em contato com nosso [suporte](https://bcards.site/support) ou confira outros tutoriais!

View File

@ -5,17 +5,15 @@ EXPOSE 8080
EXPOSE 8443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"]
RUN dotnet restore "src/BCards.Web/BCards.Web.csproj"
COPY . .
WORKDIR "/src/src/BCards.Web"
RUN dotnet build "BCards.Web.csproj" -c ${BUILD_CONFIGURATION} -o /app/build
RUN dotnet build "BCards.Web.csproj" -c Release -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "BCards.Web.csproj" -c ${BUILD_CONFIGURATION} -o /app/publish /p:UseAppHost=false
RUN dotnet publish "BCards.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app

View File

@ -1,178 +0,0 @@
# 🔧 **CONFIGURAÇÃO DE VARIÁVEIS NO GITEA**
## 📋 **VISÃO GERAL**
Agora o deploy é **100% automatizado**! O YAML cria o `appsettings.Production.json` dinamicamente usando variáveis do Gitea.
---
## ⚙️ **COMO CONFIGURAR NO GITEA**
### **1. Acessar Configurações**
```bash
1. Vá para: https://seu-gitea.com/seu-usuario/vcart.me.novo
2. Clique em: Settings (Configurações)
3. Na sidebar: Secrets and variables → Actions
```
### **2. Criar SECRETS (dados sensíveis)**
Clique em **"New repository secret"** para cada um:
```bash
# STRIPE (obrigatório)
STRIPE_SECRET_KEY = sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO
STRIPE_WEBHOOK_SECRET = whsec_SEU_WEBHOOK_SECRET_AQUI
# OAUTH (obrigatório)
GOOGLE_CLIENT_SECRET = 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
MICROSOFT_CLIENT_SECRET = T0.8Q~an.51iW1H0DVjL2i1bmSK_qTgVQOuEmapK
# SENDGRID (obrigatório)
SENDGRID_API_KEY = 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
```
### **3. Criar VARIABLES (dados não-sensíveis)**
Clique em **"New repository variable"** para cada um:
```bash
# STRIPE
STRIPE_PUBLISHABLE_KEY = pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS
STRIPE_ENVIRONMENT = test
# OAUTH
GOOGLE_CLIENT_ID = 472850008574-nmeepbdt4hunsk5c8krpbdmd3olc4jv6.apps.googleusercontent.com
MICROSOFT_CLIENT_ID = b411606a-e574-4f59-b7cd-10dd941b9fa3
# SENDGRID
SENDGRID_FROM_EMAIL = ricardo.carneiro@jobmaker.com.br
SENDGRID_FROM_NAME = Ricardo Carneiro
# MODERAÇÃO
MODERATOR_EMAIL = ricardo.carneiro@jobmaker.com.br
MODERATOR_EMAIL_1 = rrcgoncalves@gmail.com
MODERATOR_EMAIL_2 = rirocarneiro@gmail.com
```
---
## 🎯 **PASSO-A-PASSO COMPLETO**
### **FASE 1: TESTE ONLINE (ATUAL)**
#### **1.1 Configure Webhook no Stripe**
1. **Stripe Dashboard****Developers** → **Webhooks**
2. **Add endpoint**: `https://bcards.site/api/stripe/webhook`
3. **Select events**:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
4. **Copiar**: Signing secret (`whsec_...`)
#### **1.2 Desativar Modo Restrito no Stripe**
1. **Stripe Dashboard****Settings** → **Account settings**
2. **Business settings****"Restricted mode"** → **DESATIVAR**
#### **1.3 Configurar Variáveis no Gitea**
Use os valores atuais (modo teste):
```bash
# Para TESTE ONLINE
STRIPE_ENVIRONMENT = test
STRIPE_PUBLISHABLE_KEY = pk_test_... (sua chave atual)
STRIPE_SECRET_KEY = sk_test_... (sua chave atual)
STRIPE_WEBHOOK_SECRET = whsec_... (do webhook configurado)
```
#### **1.4 Commit e Deploy**
```bash
git add .
git commit -m "feat: deploy automatizado com configuração dinâmica via Gitea
- YAML cria appsettings.Production.json automaticamente
- Configuração via variáveis/secrets do Gitea
- Mount do arquivo no container Docker
- Suporte para teste online e produção
🔧 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>"
git push origin main
```
---
### **FASE 2: MIGRAÇÃO PARA LIVE (QUANDO PRONTO)**
#### **2.1 Obter Chaves Live**
```bash
1. Stripe Dashboard → Toggle "Test mode" OFF
2. Developers → API Keys
3. Copiar: pk_live_... e sk_live_...
```
#### **2.2 Configurar Webhook Live**
```bash
1. Stripe Dashboard (Live) → Webhooks
2. Add endpoint: https://bcards.site/api/stripe/webhook
3. Copiar: whsec_live_...
```
#### **2.3 Criar Produtos Live**
```bash
# No Stripe Dashboard (Live mode):
Products → Create products para cada plano
Copiar todos os price_live_xxx IDs
```
#### **2.4 Atualizar Variáveis no Gitea**
```bash
# Mudar apenas estas variáveis:
STRIPE_ENVIRONMENT = live
STRIPE_PUBLISHABLE_KEY = pk_live_... (nova)
STRIPE_SECRET_KEY = sk_live_... (nova)
STRIPE_WEBHOOK_SECRET = whsec_live_... (nova)
```
#### **2.5 Atualizar Price IDs**
Editar `appsettings.json` com os novos price IDs live e fazer commit.
---
## ✅ **VANTAGENS DESTA ABORDAGEM**
### **🔒 Segurança:**
- ✅ Secrets nunca vão para o git
- ✅ Variables ficam na interface do Gitea
- ✅ Deploy totalmente automatizado
### **🚀 Produtividade:**
- ✅ Mudança de ambiente: apenas update de variáveis
- ✅ Sem arquivos manuais nos servidores
- ✅ Rollback fácil
- ✅ Configuração versionada via interface
### **🔧 Manutenibilidade:**
- ✅ Uma única fonte de verdade
- ✅ Fácil adicionar novos ambientes
- ✅ Logs claros no deploy
- ✅ Validação automática
---
## 📊 **FLUXO FINAL**
```mermaid
graph TD
A[git push main] --> B[Gitea Actions]
B --> C[Create appsettings.Production.json]
C --> D[Build Docker Image]
D --> E[Upload Config to Servers]
E --> F[Deploy with Volume Mount]
F --> G[✅ Site Online]
```
**Resultado**: Configuração 100% automatizada, segura e versionada! 🎉

View File

@ -5,7 +5,7 @@ Um clone profissional do LinkTree desenvolvido em ASP.NET Core MVC, focado no me
## 🚀 Características Principais
### ✨ Funcionalidades
- **URLs Hierárquicas**: Organização por categoria (ex: `bcards.site/corretor/jose-silva`)
- **URLs Hierárquicas**: Organização por categoria (ex: `vcart.me/corretor/jose-silva`)
- **Sistema de Pagamentos**: Integração completa com Stripe
- **3 Planos com Efeito Decoy**: Estratégia psicológica de pricing
- **Autenticação OAuth**: Google e Microsoft
@ -16,9 +16,9 @@ Um clone profissional do LinkTree desenvolvido em ASP.NET Core MVC, focado no me
- **Renderização SSR**: SEO-friendly
### 🎯 Planos e Pricing (Estratégia Decoy)
- **Básico** (R$ 12,90/mês): 5 links, temas básicos, analytics simples
- **Profissional** (R$ 25,90/mês): 15 links, todos os temas, analytics avançado, domínio personalizado *(DECOY)*
- **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios, upload de PDFs
- **Básico** (R$ 9,90/mês): 5 links, temas básicos, analytics simples
- **Profissional** (R$ 24,90/mês): 15 links, todos os temas, analytics avançado, domínio personalizado *(DECOY)*
- **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios
## 🛠️ Tecnologias
@ -86,7 +86,7 @@ Edite `appsettings.json` ou `appsettings.Development.json`:
"ClientSecret": "seu_microsoft_client_secret"
}
},
"BaseUrl": "https://bcards.site"
"BaseUrl": "https://vcart.me"
}
```
@ -94,8 +94,8 @@ Edite `appsettings.json` ou `appsettings.Development.json`:
1. Crie uma conta no [Stripe](https://stripe.com)
2. Configure os produtos e preços:
- Básico: R$ 12,90/mês
- Profissional: R$ 25,90/mês
- Básico: R$ 9,90/mês
- Profissional: R$ 24,90/mês
- Premium: R$ 29,90/mês
3. Configure webhooks para: `/webhook/stripe`
4. Eventos necessários:
@ -254,7 +254,7 @@ Sistema de analytics integrado que rastreia:
"SecretKey": "sk_live_seu_secret_key",
"WebhookSecret": "whsec_seu_webhook_secret_producao"
},
"BaseUrl": "https://bcards.site"
"BaseUrl": "https://vcart.me"
}
```
@ -303,14 +303,14 @@ Este projeto está sob a licença MIT. Veja o arquivo [LICENSE](LICENSE) para de
## 🆘 Suporte
Para suporte técnico, entre em contato:
- Email: suporte@bcards.site
- Email: suporte@vcart.me
- Discord: [Servidor da Comunidade]
- Issues: [GitHub Issues](https://github.com/seuusuario/bcards/issues)
## 📞 Contato
- **Website**: https://bcards.site
- **Email**: contato@bcards.site
- **Website**: https://vcart.me
- **Email**: contato@vcart.me
- **LinkedIn**: [Seu LinkedIn]
- **Twitter**: [@vcartme]

File diff suppressed because it is too large Load Diff

View File

@ -1,219 +0,0 @@
# 🎯 **GUIA COMPLETO: STRIPE TESTE → PRODUÇÃO**
## 📋 **DIFERENÇAS IMPORTANTES**
### **MODO DE TESTE vs MODO RESTRITO**
| Aspecto | **Modo de Teste** | **Modo Restrito** |
|---------|-------------------|-------------------|
| 🎯 **Propósito** | Simular pagamentos | Limitar acesso público |
| 💳 **Cartões** | Apenas cartões de teste (`4242...`) | Cartões reais OU teste |
| 💰 **Dinheiro** | Não cobra dinheiro real | Pode cobrar (se live mode) |
| 🌐 **Acesso Online** | ✅ Funciona perfeitamente | ❌ Bloqueia usuários não autorizados |
| 🧪 **Para Testes** | ✅ **IDEAL** | ❌ Impede testes públicos |
### **🚨 PROBLEMA ATUAL**
Seu site provavelmente está em **Modo Restrito**, impedindo pagamentos online mesmo com cartões de teste.
---
## 🛠️ **PASSO-A-PASSO COMPLETO**
### **📝 FASE 1: PREPARAÇÃO LOCAL**
#### **1.1 Criar appsettings.Production.json**
```bash
# No seu ambiente local, crie:
cp appsettings.Production.example.json src/BCards.Web/appsettings.Production.json
```
#### **1.2 Para TESTE ONLINE (Recomendado primeiro)**
Edite `appsettings.Production.json`:
```json
{
"Stripe": {
"PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS",
"SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO",
"WebhookSecret": "SEU_WEBHOOK_PRODUÇÃO_AQUI",
"Environment": "test"
}
}
```
---
### **🔧 FASE 2: CONFIGURAÇÃO DO STRIPE**
#### **2.1 Desativar Modo Restrito (CRÍTICO)**
1. **Acesse**: https://dashboard.stripe.com
2. **Certifique-se**: Test mode **LIGADO** (toggle azul no topo)
3. **Settings** → **Account settings**
4. **Business settings** → **Restrictions**
5. **"Restricted mode"** → **DESATIVAR**
#### **2.2 Configurar Webhook de Produção**
1. **Stripe Dashboard****Developers** → **Webhooks**
2. **Add endpoint**: `https://bcards.site/api/stripe/webhook`
3. **Select events**:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
4. **Copiar**: Signing secret → `whsec_...`
5. **Colar**: no `WebhookSecret` do seu `appsettings.Production.json`
---
### **📤 FASE 3: COMMIT E DEPLOY**
#### **3.1 Commit (SEGURO)**
```bash
# Seus arquivos de configuração agora estão seguros:
git add .
git commit -m "feat: configuração segura do Stripe para teste online
- appsettings.json: chaves removidas (seguro para git)
- appsettings.Development.json: chaves teste para desenvolvimento
- appsettings.Production.json: ignorado pelo git (chaves produção)
- Validação de ambiente Stripe adicionada
- Endpoint /stripe-info para debugging
🔧 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>"
# Push para main (deploy automático)
git push origin main
```
#### **3.2 Verificar Deploy**
```bash
# Aguardar deploy completar, então testar:
curl https://bcards.site/health
curl https://bcards.site/stripe-info
```
---
### **🧪 FASE 4: TESTES ONLINE**
#### **4.1 Testar Informações do Stripe**
```bash
# Verificar configuração:
https://bcards.site/stripe-info
# Deve mostrar:
{
"Environment": "test",
"IsTestMode": true,
"WebhookConfigured": true
}
```
#### **4.2 Testar Pagamento**
1. **Cadastre-se** no site online
2. **Escolha um plano**
3. **Use cartão teste**: `4242 4242 4242 4242`
4. **Data**: Qualquer data futura (ex: 12/25)
5. **CVC**: Qualquer 3 dígitos (ex: 123)
#### **4.3 Verificar Logs**
```bash
# Monitorar webhooks no Stripe Dashboard
# Developers → Webhooks → Seu endpoint → Recent deliveries
```
---
### **🚀 FASE 5: MIGRAÇÃO PARA LIVE (QUANDO PRONTO)**
#### **5.1 Obter Chaves Live**
1. **Stripe Dashboard** → Toggle "Test mode" **OFF**
2. **Developers****API Keys**
3. **Copiar**: `pk_live_...` e `sk_live_...`
#### **5.2 Criar Produtos Live**
```bash
# No Stripe Dashboard (Live mode):
# Products → Create products para cada plano
# Copiar todos os price_live_xxx
```
#### **5.3 Atualizar Produção**
```json
// appsettings.Production.json
{
"Stripe": {
"PublishableKey": "pk_live_...",
"SecretKey": "sk_live_...",
"WebhookSecret": "whsec_live_...",
"Environment": "live"
},
"Plans": {
"Basic": {
"PriceId": "price_live_basic_xxx" // ← ATUALIZAR TODOS
}
// ... outros planos
}
}
```
---
## 🔍 **DEBUGGING & MONITORAMENTO**
### **Endpoints Úteis:**
- `https://bcards.site/health` - Status da aplicação
- `https://bcards.site/stripe-info` - Configuração Stripe (apenas logados)
### **Logs Importantes:**
```bash
# Ao iniciar a aplicação, você verá:
🔧 Stripe Environment: TEST | Test Mode: True
⚠️ STRIPE TEST MODE ENABLED - Only test payments will work
```
### **Cartões de Teste:**
- **Sucesso**: `4242 4242 4242 4242`
- **Falha**: `4000 0000 0000 0002`
- **3D Secure**: `4000 0025 0000 3155`
---
## ✅ **CHECKLIST FINAL**
### **Para Teste Online:**
```bash
□ Modo Restrito DESATIVADO no Stripe
□ Webhook configurado para bcards.site
□ appsettings.Production.json criado (não no git)
□ Commit realizado (configurações seguras)
□ Deploy executado com sucesso
□ /stripe-info mostra Environment: "test"
□ Pagamento teste funciona online
```
### **Para Produção (Depois):**
```bash
□ Chaves LIVE obtidas
□ Produtos LIVE criados no Stripe
□ Price IDs atualizados
□ Webhook LIVE configurado
□ appsettings.Production.json atualizado
□ Environment: "live" configurado
□ Testes com cartões reais (pequenos valores)
□ Monitoramento ativo
```
---
## 🎯 **RESULTADO ESPERADO**
**Agora** você pode:
- ✅ **Testar online** com cartões de teste
- ✅ **Manter segurança** (chaves fora do git)
- ✅ **Monitorar** facilmente via endpoints
- ✅ **Migrar para live** quando pronto
**O site funcionará online em modo teste, permitindo que qualquer pessoa teste com cartões fake!** 🎉

View File

@ -1,27 +0,0 @@
{
"// EXEMPLO - RENOMEAR PARA appsettings.Production.json": "Este arquivo mostra como configurar produção",
"Stripe": {
"// PARA TESTE ONLINE": {
"PublishableKey": "pk_test_SUA_CHAVE_PUBLICA_AQUI",
"SecretKey": "sk_test_SUA_CHAVE_SECRETA_AQUI",
"WebhookSecret": "whsec_WEBHOOK_DO_SEU_SITE_AQUI",
"Environment": "test"
},
"// PARA PRODUÇÃO REAL": {
"PublishableKey": "pk_live_SUA_CHAVE_LIVE_AQUI",
"SecretKey": "sk_live_SUA_CHAVE_LIVE_AQUI",
"WebhookSecret": "whsec_WEBHOOK_LIVE_AQUI",
"Environment": "live"
}
},
"// INSTRUCOES": [
"1. Copie este arquivo para appsettings.Production.json",
"2. Para TESTE ONLINE: use chaves pk_test_ e sk_test_",
"3. Para PRODUÇÃO REAL: use chaves pk_live_ e sk_live_",
"4. Configure webhook para https://bcards.site/api/stripe/webhook",
"5. Modo Restrito no Stripe: desativar para permitir pagamentos públicos"
]
}

View File

@ -1,452 +0,0 @@
[
{
"name": "Artesanato",
"slug": "artesanato",
"icon": "🎨",
"seoKeywords": [
"artesanato",
"artesão",
"feito à mão",
"personalizado",
"criativo",
"decoração"
],
"description": "Artesãos e criadores de produtos feitos à mão, decoração e arte personalizada",
"isActive": true
},
{
"name": "Papelaria",
"slug": "papelaria",
"icon": "📝",
"seoKeywords": [
"papelaria",
"escritório",
"material escolar",
"impressão",
"convites",
"personalização"
],
"description": "Lojas de papelaria, material de escritório, impressão e produtos personalizados",
"isActive": true
},
{
"name": "Coaching",
"slug": "coaching",
"icon": "🎯",
"seoKeywords": [
"coaching",
"mentoria",
"desenvolvimento pessoal",
"life coach",
"business coach",
"liderança"
],
"description": "Coaches, mentores e profissionais de desenvolvimento pessoal e empresarial",
"isActive": true
},
{
"name": "Fitness",
"slug": "fitness",
"icon": "💪",
"seoKeywords": [
"fitness",
"academia",
"personal trainer",
"musculação",
"treinamento",
"exercícios"
],
"description": "Personal trainers, academias, estúdios de pilates e profissionais fitness",
"isActive": true
},
{
"name": "Psicologia",
"slug": "psicologia",
"icon": "🧠",
"seoKeywords": [
"psicólogo",
"terapia",
"psicologia",
"saúde mental",
"consultório",
"atendimento"
],
"description": "Psicólogos, terapeutas e profissionais de saúde mental",
"isActive": true
},
{
"name": "Nutrição",
"slug": "nutricao",
"icon": "🥗",
"seoKeywords": [
"nutricionista",
"dieta",
"nutrição",
"alimentação saudável",
"consultoria nutricional",
"emagrecimento"
],
"description": "Nutricionistas, consultores em alimentação e profissionais da nutrição",
"isActive": true
},
{
"name": "Moda e Vestuário",
"slug": "moda",
"icon": "👗",
"seoKeywords": [
"moda",
"vestuário",
"roupas",
"fashion",
"estilista",
"costureira"
],
"description": "Lojas de roupas, estilistas, costureiras e profissionais da moda",
"isActive": true
},
{
"name": "Fotografia",
"slug": "fotografia",
"icon": "📸",
"seoKeywords": [
"fotógrafo",
"fotografia",
"ensaio",
"casamento",
"eventos",
"retratos"
],
"description": "Fotógrafos profissionais, estúdios fotográficos e serviços de fotografia",
"isActive": true
},
{
"name": "Marketing Digital",
"slug": "marketing-digital",
"icon": "📱",
"seoKeywords": [
"marketing digital",
"social media",
"publicidade",
"SEO",
"gestão de redes",
"digital"
],
"description": "Agências de marketing digital, gestores de redes sociais e consultores digitais",
"isActive": true
},
{
"name": "Contabilidade",
"slug": "contabilidade",
"icon": "📊",
"seoKeywords": [
"contador",
"contabilidade",
"fiscal",
"imposto de renda",
"MEI",
"consultoria contábil"
],
"description": "Contadores, escritórios contábeis e consultoria fiscal",
"isActive": true
},
{
"name": "Design",
"slug": "design",
"icon": "🎨",
"seoKeywords": [
"designer",
"design gráfico",
"identidade visual",
"logo",
"criativo",
"branding"
],
"description": "Designers gráficos, criativos e profissionais de identidade visual",
"isActive": true
},
{
"name": "Consultoria",
"slug": "consultoria",
"icon": "🤝",
"seoKeywords": [
"consultor",
"consultoria",
"assessoria",
"especialista",
"negócios",
"estratégia"
],
"description": "Consultores especializados, assessoria empresarial e serviços de consultoria",
"isActive": true
},
{
"name": "Pets",
"slug": "pets",
"icon": "🐕",
"seoKeywords": [
"veterinário",
"pet shop",
"animais",
"cuidados",
"petshop",
"adestramento"
],
"description": "Veterinários, pet shops, adestradores e serviços para animais",
"isActive": true
},
{
"name": "Casa e Jardim",
"slug": "casa-jardim",
"icon": "🏡",
"seoKeywords": [
"paisagismo",
"jardinagem",
"decoração",
"casa",
"jardim",
"plantas"
],
"description": "Paisagistas, jardineiros, decoradores e serviços para casa e jardim",
"isActive": true
},
{
"name": "Automóveis",
"slug": "automoveis",
"icon": "🚗",
"seoKeywords": [
"mecânico",
"automóveis",
"carros",
"oficina",
"manutenção",
"peças"
],
"description": "Mecânicos, oficinas, lojas de peças e serviços automotivos",
"isActive": true
},
{
"name": "Turismo",
"slug": "turismo",
"icon": "✈️",
"seoKeywords": [
"turismo",
"viagem",
"agência",
"guia turístico",
"passeios",
"hospedagem"
],
"description": "Agências de turismo, guias, pousadas e prestadores de serviços turísticos",
"isActive": true
},
{
"name": "Música",
"slug": "musica",
"icon": "🎵",
"seoKeywords": [
"músico",
"professor de música",
"instrumentos",
"aulas",
"banda",
"eventos musicais"
],
"description": "Músicos, professores de música, bandas e profissionais do entretenimento",
"isActive": true
},
{
"name": "Idiomas",
"slug": "idiomas",
"icon": "🗣️",
"seoKeywords": [
"professor de idiomas",
"inglês",
"espanhol",
"tradutor",
"aulas particulares",
"curso de idiomas"
],
"description": "Professores de idiomas, tradutores e escolas de línguas",
"isActive": true
},
{
"name": "Limpeza",
"slug": "limpeza",
"icon": "🧽",
"seoKeywords": [
"limpeza",
"faxina",
"diarista",
"higienização",
"empresa de limpeza",
"doméstica"
],
"description": "Empresas de limpeza, diaristas e serviços de higienização",
"isActive": true
},
{
"name": "Segurança",
"slug": "seguranca",
"icon": "🛡️",
"seoKeywords": [
"segurança",
"vigilante",
"porteiro",
"alarmes",
"monitoramento",
"proteção"
],
"description": "Empresas de segurança, vigilantes e serviços de proteção",
"isActive": true
},
{
"name": "Eventos",
"slug": "eventos",
"icon": "🎉",
"seoKeywords": [
"eventos",
"festa",
"casamento",
"buffet",
"decoração de festas",
"cerimonial"
],
"description": "Organizadores de eventos, buffets, decoração e cerimonial",
"isActive": true
},
{
"name": "Transporte",
"slug": "transporte",
"icon": "🚐",
"seoKeywords": [
"transporte",
"frete",
"mudança",
"delivery",
"motorista",
"logística"
],
"description": "Empresas de transporte, fretes, mudanças e serviços de entrega",
"isActive": true
},
{
"name": "Construção",
"slug": "construcao",
"icon": "🔨",
"seoKeywords": [
"construção",
"pedreiro",
"pintor",
"eletricista",
"encanador",
"reforma"
],
"description": "Profissionais da construção civil, reformas e manutenção predial",
"isActive": true
},
{
"name": "Joias e Acessórios",
"slug": "joias",
"icon": "💎",
"seoKeywords": [
"joias",
"bijuterias",
"acessórios",
"ourives",
"relógios",
"semijoias"
],
"description": "Joalherias, bijuterias, ourives e lojas de acessórios",
"isActive": true
},
{
"name": "Odontologia",
"slug": "odontologia",
"icon": "🦷",
"seoKeywords": [
"dentista",
"odontologia",
"clínica dentária",
"ortodontia",
"implante",
"oral"
],
"description": "Dentistas, clínicas odontológicas e profissionais da área bucal",
"isActive": true
},
{
"name": "Fisioterapia",
"slug": "fisioterapia",
"icon": "🏥",
"seoKeywords": [
"fisioterapeuta",
"fisioterapia",
"reabilitação",
"RPG",
"massagem",
"terapia"
],
"description": "Fisioterapeutas, clínicas de reabilitação e terapias corporais",
"isActive": true
},
{
"name": "Livraria",
"slug": "livraria",
"icon": "📚",
"seoKeywords": [
"livraria",
"livros",
"sebo",
"literatura",
"editora",
"publicação"
],
"description": "Livrarias, sebos, editoras e comércio de livros e publicações",
"isActive": true
},
{
"name": "Floricultura",
"slug": "floricultura",
"icon": "🌸",
"seoKeywords": [
"floricultura",
"flores",
"buquê",
"plantas",
"arranjos",
"casamento"
],
"description": "Floriculturas, arranjos florais e comércio de plantas ornamentais",
"isActive": true
},
{
"name": "Farmácia",
"slug": "farmacia",
"icon": "💊",
"seoKeywords": [
"farmácia",
"farmacêutico",
"medicamentos",
"drogaria",
"manipulação",
"remédios"
],
"description": "Farmácias, drogarias e farmacêuticos especializados",
"isActive": true
},
{
"name": "Delivery",
"slug": "delivery",
"icon": "🛵",
"seoKeywords": [
"delivery",
"entrega",
"motoboy",
"comida",
"aplicativo",
"rápido"
],
"description": "Serviços de delivery, entregadores e aplicativos de entrega",
"isActive": true
}
]

View File

@ -1,34 +0,0 @@
#!/bin/bash
echo "🧹 Iniciando limpeza completa do projeto BCards..."
# 1. Limpar todos os caches NuGet
echo "📦 Limpando cache NuGet..."
dotnet nuget locals all --clear
# 2. Remover pastas bin/obj recursivamente
echo "🗑️ Removendo pastas bin/obj..."
find . -name "bin" -type d -exec rm -rf {} + 2>/dev/null || true
find . -name "obj" -type d -exec rm -rf {} + 2>/dev/null || true
# 3. Limpar solution
echo "🧽 Executando dotnet clean..."
dotnet clean --verbosity quiet
# 4. Restaurar packages sem cache
echo "📥 Restaurando packages..."
dotnet restore --no-cache --force --verbosity quiet
# 5. Build completo
echo "🔨 Executando build..."
dotnet build --no-restore --verbosity quiet
if [ $? -eq 0 ]; then
echo "✅ Build concluído com sucesso!"
echo "🚀 Pronto para executar: dotnet run"
else
echo "❌ Build falhou! Verifique os erros acima."
exit 1
fi
echo "🎉 Limpeza completa finalizada!"

View File

@ -1,68 +0,0 @@
# Deploy manual corrigido (sem problemas de quebra de linha)
param(
[string]$Tag = "latest-manual"
)
Write-Host "🏗️ Building and deploying BCards..." -ForegroundColor Green
try {
# 1. Build da imagem
Write-Host "📦 Building Docker image..." -ForegroundColor Yellow
# Build e push da imagem
docker buildx build --platform linux/arm64 --tag "registry.redecarneir.us/bcards:$Tag" --tag "registry.redecarneir.us/bcards:latest" --push --no-cache .
if ($LASTEXITCODE -ne 0) {
throw "Build failed"
}
Write-Host "✅ Image built and pushed successfully!" -ForegroundColor Green
# 2. Deploy no servidor 1
Write-Host "🚀 Deploying to server 1 (141.148.162.114)..." -ForegroundColor Yellow
# Usar script inline mais simples
$deployScript1 = @'
echo "🔄 Updating server 1..."
docker stop bcards-prod || true
docker rm bcards-prod || true
docker rmi registry.redecarneir.us/bcards:latest || true
docker pull registry.redecarneir.us/bcards:latest
docker run -d --name bcards-prod --restart unless-stopped --network host -e ASPNETCORE_ENVIRONMENT=Production -e ASPNETCORE_URLS=http://+:8080 -e "MongoDb__ConnectionString=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin" -e MongoDb__DatabaseName=BCardsDB -e Logging__LogLevel__Default=Debug -e Serilog__SeqUrl=http://localhost:5343 registry.redecarneir.us/bcards:latest
echo "✅ Server 1 updated"
'@
$deployScript1 | ssh ubuntu@141.148.162.114 'bash -s'
# 3. Deploy no servidor 2
Write-Host "🚀 Deploying to server 2 (129.146.116.218)..." -ForegroundColor Yellow
$deployScript2 = @'
echo "🔄 Updating server 2..."
docker stop bcards-prod || true
docker rm bcards-prod || true
docker rmi registry.redecarneir.us/bcards:latest || true
docker pull registry.redecarneir.us/bcards:latest
docker run -d --name bcards-prod --restart unless-stopped --network host -e ASPNETCORE_ENVIRONMENT=Production -e ASPNETCORE_URLS=http://+:8080 -e "MongoDb__ConnectionString=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin" -e MongoDb__DatabaseName=BCardsDB -e Logging__LogLevel__Default=Debug -e Serilog__SeqUrl=http://localhost:5342 registry.redecarneir.us/bcards:latest
echo "✅ Server 2 updated"
'@
$deployScript2 | ssh ubuntu@129.146.116.218 'bash -s'
# 4. Health check
Write-Host "🏥 Running health checks..." -ForegroundColor Yellow
Start-Sleep 30
Write-Host "Testing server 1..." -ForegroundColor Cyan
ssh ubuntu@141.148.162.114 'curl -f http://localhost:8080/health || echo "Server 1 health check failed"'
Write-Host "Testing server 2..." -ForegroundColor Cyan
ssh ubuntu@129.146.116.218 'curl -f http://localhost:8080/health || echo "Server 2 health check failed"'
Write-Host "✅ Manual deploy completed successfully!" -ForegroundColor Green
Write-Host "🌐 Test your CSS changes now!" -ForegroundColor Magenta
} catch {
Write-Host "❌ Deploy failed: $_" -ForegroundColor Red
exit 1
}

View File

@ -1,88 +0,0 @@
#!/bin/bash
# Script para limpar containers standalone do BCards
# Deve ser executado DEPOIS que o Swarm estiver rodando corretamente
set -e
echo "🔍 Verificando containers standalone do BCards..."
# Lista de possíveis nomes de containers standalone
STANDALONE_CONTAINERS=(
"bcards-prod"
"bcards-release"
"bcards-app"
)
# Verifica se o Swarm está rodando
echo "✅ Verificando status do Docker Swarm..."
if ! docker info | grep -q "Swarm: active"; then
echo "❌ ERRO: Docker Swarm não está ativo neste servidor!"
echo "Este script só deve ser executado em servidores do Swarm."
exit 1
fi
# Verifica se o serviço do Swarm está rodando
echo "✅ Verificando serviço bcards_bcards-app no Swarm..."
if ! docker service ls | grep -q "bcards_bcards-app"; then
echo "❌ ERRO: Serviço bcards_bcards-app não encontrado no Swarm!"
echo "Certifique-se de que o deploy do Swarm foi feito antes de executar este script."
exit 1
fi
# Mostra status do serviço Swarm
echo ""
echo "📊 Status atual do serviço Swarm:"
docker service ls --filter name=bcards_bcards-app
echo ""
docker service ps bcards_bcards-app --filter "desired-state=running" | head -10
echo ""
# Verifica se há containers standalone rodando
FOUND_CONTAINERS=false
for container_name in "${STANDALONE_CONTAINERS[@]}"; do
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
FOUND_CONTAINERS=true
break
fi
done
if [ "$FOUND_CONTAINERS" = false ]; then
echo "✅ Nenhum container standalone encontrado. Sistema está OK!"
exit 0
fi
# Lista containers encontrados
echo "⚠️ Containers standalone encontrados:"
for container_name in "${STANDALONE_CONTAINERS[@]}"; do
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
echo " - $container_name"
docker ps -a --filter "name=^${container_name}$" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}"
fi
done
echo ""
# Pergunta confirmação
read -p "🗑️ Deseja remover estes containers standalone? (sim/não): " -r
echo
if [[ ! $REPLY =~ ^[Ss][Ii][Mm]$ ]]; then
echo "❌ Operação cancelada pelo usuário."
exit 0
fi
# Remove os containers
echo "🧹 Removendo containers standalone..."
for container_name in "${STANDALONE_CONTAINERS[@]}"; do
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
echo " Parando e removendo: $container_name"
docker stop "$container_name" 2>/dev/null || true
docker rm "$container_name" 2>/dev/null || true
fi
done
echo ""
echo "✅ Limpeza concluída!"
echo ""
echo "📊 Status final do serviço Swarm:"
docker service ls --filter name=bcards_bcards-app
echo ""
docker service ps bcards_bcards-app --filter "desired-state=running" | head -10

View File

@ -1,52 +0,0 @@
version: '3.8'
services:
bcards-release:
image: ${BCARDS_IMAGE}
networks:
- bcards-net
deploy:
replicas: 1
update_config:
parallelism: 1
delay: 10s
order: start-first
monitor: 60s
failure_action: rollback
rollback_config:
parallelism: 0
delay: 5s
environment:
ASPNETCORE_ENVIRONMENT: Release
ASPNETCORE_URLS: http://+:8080
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
# MongoDB local (Core i5)
MongoDb__ConnectionString: mongodb://192.168.0.100:27017/BCardsDB
MongoDb__DatabaseName: BCardsDB
DataProtection__Mongo__ConnectionString: mongodb://192.168.0.100:27017/BCardsDB
DataProtection__Mongo__DatabaseName: BCardsDB
DataProtection__Mongo__CollectionName: DataProtectionKeys
# OpenSearch local (Core i5)
Serilog__OpenSearchUrl: http://192.168.0.100:9200
Serilog__OpenSearchFallback: http://192.168.0.100:9200
# Stripe test keys (same as development)
Stripe__PublishableKey: pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS
Stripe__SecretKey: sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO
Stripe__WebhookSecret: whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543
Stripe__Environment: test
Logging__LogLevel__Default: Debug
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
ports:
- published: 28080
target: 8080
protocol: tcp
mode: ingress
networks:
bcards-net:
external: true

View File

@ -1,52 +0,0 @@
version: '3.8'
configs:
bcards-appsettings:
external: true
services:
bcards-app:
image: registry.redecarneir.us/bcards:latest
networks:
- bcards-net
deploy:
replicas: 4
placement:
max_replicas_per_node: 2
update_config:
parallelism: 1
order: stop-first
delay: 10s
monitor: 60s
failure_action: rollback
rollback_config:
parallelism: 0
delay: 5s
configs:
- source: bcards-appsettings
target: /app/appsettings.Production.json
mode: 0444
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
MongoDb__ConnectionString: mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin
MongoDb__DatabaseName: BCardsDB
Serilog__OpenSearchUrl: http://141.148.162.114:19201
Serilog__OpenSearchFallback: http://129.146.116.218:19202
Logging__LogLevel__Default: Information
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
ports:
- published: 8080
target: 8080
protocol: tcp
mode: ingress
networks:
bcards-net:
external: true

View File

@ -196,7 +196,7 @@ deploy_new_version() {
cat > "$DEPLOY_DIR/.env" << EOF
IMAGE_TAG=$image_tag
REGISTRY=registry.redecarneir.us
MONGODB_CONNECTION_STRING=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin
MONGODB_CONNECTION_STRING=mongodb://192.168.0.100:27017/BCardsDB
ASPNETCORE_ENVIRONMENT=Release
CERT_PASSWORD=
EOF

View File

@ -1,84 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 4 ]]; then
echo "Usage: $0 <stack-name> <service-name> <stack-file> <health-url> [expected-replicas]" >&2
exit 1
fi
STACK_NAME="$1"
SERVICE_NAME="$2"
STACK_FILE="$3"
HEALTH_URL="$4"
EXPECTED_REPLICAS="${5:-4}"
SERVICE_FQDN="${STACK_NAME}_${SERVICE_NAME}"
LOG_PREFIX="[swarm-deploy]"
log() {
printf '%s %s %s\n' "$(date --iso-8601=seconds)" "$LOG_PREFIX" "$*"
}
retry() {
local attempts=$1; shift
local delay=$1; shift
local n=1
while true; do
if "$@"; then
return 0
fi
if (( n == attempts )); then
return 1
fi
((n++))
sleep "$delay"
done
}
log "Deploying stack '${STACK_NAME}' using ${STACK_FILE}"
docker stack deploy --compose-file "$STACK_FILE" "$STACK_NAME"
log "Waiting for service ${SERVICE_FQDN} to reach ${EXPECTED_REPLICAS} replicas"
retries=24
while (( retries > 0 )); do
replicas_raw=$(docker service ls --filter "name=${SERVICE_FQDN}" --format '{{.Replicas}}' || true)
if [[ -z "$replicas_raw" ]]; then
log "Service ${SERVICE_FQDN} not found yet; retrying"
sleep 5
((retries--))
continue
fi
replicas_clean=${replicas_raw%% (*}
running=${replicas_clean%%/*}
desired=${replicas_clean##*/}
if [[ "$running" == "$desired" && "$running" == "$EXPECTED_REPLICAS" ]]; then
log "Service reached desired replica count: ${running}/${desired}"
break
fi
log "Current replicas ${running}/${desired}; waiting..."
sleep 5
((retries--))
done
if (( retries == 0 )); then
log "Timed out waiting for replicas"
docker service ps "$SERVICE_FQDN"
exit 1
fi
log "Checking task states"
if ! docker service ps "$SERVICE_FQDN" --no-trunc --filter 'desired-state=Running' --format '{{.CurrentState}}' | grep -q '^Running '; then
log "Some tasks are not running"
docker service ps "$SERVICE_FQDN"
exit 1
fi
log "Running health check against ${HEALTH_URL}"
if ! retry 3 5 curl -fsS "$HEALTH_URL"; then
log "Health check failed; rolling back service"
docker service update --rollback "$SERVICE_FQDN" || true
exit 1
fi
log "Health check succeeded"
log "Deployment finished successfully"

View File

@ -6,7 +6,6 @@
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<Configurations>Debug;Release;Testing</Configurations>
</PropertyGroup>
<ItemGroup>

View File

@ -12,7 +12,7 @@ namespace BCards.IntegrationTests.Helpers;
public static class AuthenticationHelper
{
public static Task<HttpClient> CreateAuthenticatedClientAsync(
public static async Task<HttpClient> CreateAuthenticatedClientAsync(
WebApplicationFactory<Program> factory,
User testUser)
{
@ -34,7 +34,7 @@ public static class AuthenticationHelper
client.DefaultRequestHeaders.Add("TestUserEmail", testUser.Email);
client.DefaultRequestHeaders.Add("TestUserName", testUser.Name);
return Task.FromResult(client);
return client;
}
public static ClaimsPrincipal CreateTestClaimsPrincipal(User user)

View File

@ -161,9 +161,9 @@ public class PuppeteerTestHelper : IAsyncDisposable
await Page.ScreenshotAsync(fileName);
}
public Task<string> GetCurrentUrlAsync()
public async Task<string> GetCurrentUrlAsync()
{
return Task.FromResult(Page.Url);
return Page.Url;
}
public async Task<List<string>> GetAllElementTextsAsync(string selector)

View File

@ -1,67 +0,0 @@
using BCards.Web.Areas.Tutoriais.Models.ViewModels;
using BCards.Web.Areas.Tutoriais.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Areas.Artigos.Controllers;
[Area("Artigos")]
public class ArtigosController : Controller
{
private readonly IMarkdownService _markdownService;
private readonly ILogger<ArtigosController> _logger;
public ArtigosController(
IMarkdownService markdownService,
ILogger<ArtigosController> logger)
{
_markdownService = markdownService;
_logger = logger;
}
// GET /artigos
public async Task<IActionResult> Index()
{
var artigos = await _markdownService
.GetAllArticlesAsync("Artigos", "pt-BR");
return View(artigos);
}
// GET /artigos/{slug}
public async Task<IActionResult> Article(string slug)
{
// Sanitização
slug = slug.Replace("..", "").Replace("/", "").Replace("\\", "");
try
{
var article = await _markdownService.GetArticleAsync(
$"Artigos/{slug}",
"pt-BR"
);
if (article == null)
{
_logger.LogWarning("Artigo não encontrado: {Slug}", slug);
return NotFound();
}
// Buscar outros artigos para "Leia também"
article.RelatedArticles = await _markdownService
.GetAllArticlesAsync("Artigos", "pt-BR");
article.RelatedArticles = article.RelatedArticles
.Where(a => a.Slug != slug)
.OrderByDescending(a => a.Date)
.Take(3)
.ToList();
return View(article);
}
catch (FileNotFoundException)
{
_logger.LogWarning("Arquivo markdown não encontrado: {Slug}", slug);
return NotFound();
}
}
}

View File

@ -1,253 +0,0 @@
@model BCards.Web.Areas.Tutoriais.Models.ViewModels.ArticleViewModel
@{
ViewData["Title"] = Model.Metadata.Title;
}
@section Head {
<!-- Meta Tags SEO -->
<meta name="description" content="@Model.Metadata.Description">
<meta name="keywords" content="@Model.Metadata.Keywords">
<meta name="author" content="@Model.Metadata.Author">
<meta name="robots" content="index, follow">
<link rel="canonical" href="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = Model.Slug }, Context.Request.Scheme)">
<!-- Open Graph -->
<meta property="og:type" content="article">
<meta property="og:title" content="@Model.Metadata.Title">
<meta property="og:description" content="@Model.Metadata.Description">
<meta property="og:image" content="@Model.Metadata.Image">
<meta property="og:url" content="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = Model.Slug }, Context.Request.Scheme)">
<meta property="article:published_time" content="@Model.Metadata.Date.ToString("yyyy-MM-ddTHH:mm:ssZ")">
<meta property="article:modified_time" content="@Model.Metadata.LastMod.ToString("yyyy-MM-ddTHH:mm:ssZ")">
<meta property="article:author" content="@Model.Metadata.Author">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="@Model.Metadata.Title">
<meta name="twitter:description" content="@Model.Metadata.Description">
<meta name="twitter:image" content="@Model.Metadata.Image">
<!-- Schema.org JSON-LD -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "Article",
"headline": "@Model.Metadata.Title",
"description": "@Model.Metadata.Description",
"image": "@Model.Metadata.Image",
"datePublished": "@Model.Metadata.Date.ToString("yyyy-MM-dd")",
"dateModified": "@Model.Metadata.LastMod.ToString("yyyy-MM-dd")",
"author": {
"@@type": "Person",
"name": "@Model.Metadata.Author"
},
"publisher": {
"@@type": "Organization",
"name": "BCards",
"logo": {
"@@type": "ImageObject",
"url": "https://bcards.site/logo.png"
}
}
}
</script>
<!-- BreadcrumbList Schema -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{
"@@type": "ListItem",
"position": 1,
"name": "Início",
"item": "https://bcards.site"
},
{
"@@type": "ListItem",
"position": 2,
"name": "Artigos",
"item": "https://bcards.site/artigos"
},
{
"@@type": "ListItem",
"position": 3,
"name": "@Model.Metadata.Title"
}
]
}
</script>
}
<div class="container py-5">
<div class="row">
<div class="col-lg-8">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Início</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Artigos", new { area = "Artigos" })">Artigos</a></li>
<li class="breadcrumb-item active" aria-current="page">@Model.Metadata.Title</li>
</ol>
</nav>
<!-- Article Header -->
<article>
<header class="mb-4">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-success me-2">✨ Artigo</span>
<span class="text-muted small"><i class="far fa-clock me-1"></i> @Model.Metadata.ReadingTimeMinutes min de leitura</span>
</div>
<h1 class="display-5 mb-3">@Model.Metadata.Title</h1>
<p class="lead text-muted">@Model.Metadata.Description</p>
<div class="d-flex align-items-center text-muted small mb-3">
<span class="me-3"><i class="fas fa-user me-1"></i> @Model.Metadata.Author</span>
<span class="me-3"><i class="fas fa-calendar me-1"></i> @Model.Metadata.Date.ToString("dd/MM/yyyy")</span>
<span><i class="fas fa-sync me-1"></i> Atualizado em @Model.Metadata.LastMod.ToString("dd/MM/yyyy")</span>
</div>
</header>
@if (!string.IsNullOrEmpty(Model.Metadata.Image))
{
<img src="@Model.Metadata.Image" class="img-fluid rounded mb-4" alt="@Model.Metadata.Title">
}
<!-- Article Content -->
<div class="article-content">
@Html.Raw(Model.HtmlContent)
</div>
</article>
<!-- CTA -->
<div class="alert alert-primary mt-5" role="alert">
<h4 class="alert-heading"><i class="fas fa-rocket me-2"></i> Pronto para transformar seu negócio?</h4>
<p class="mb-3">Crie seu cartão digital profissional e comece a atrair mais clientes hoje mesmo!</p>
<a href="/" class="btn btn-primary">Criar meu BCard grátis</a>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="sticky-top" style="top: 20px;">
<!-- Related Articles -->
@if (Model.RelatedArticles.Any())
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title mb-3"><i class="fas fa-newspaper me-2"></i> Leia Também</h5>
<div class="list-group list-group-flush">
@foreach (var related in Model.RelatedArticles)
{
<a href="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = related.Slug })" class="list-group-item list-group-item-action border-0 px-0">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">@related.Title</h6>
</div>
<small class="text-muted"><i class="far fa-clock me-1"></i> @related.ReadingTimeMinutes min</small>
</a>
}
</div>
</div>
</div>
}
<!-- Tutoriais CTA -->
<div class="card border-0 shadow-sm bg-light mb-4">
<div class="card-body text-center">
<i class="fas fa-book-open fa-3x text-primary mb-3"></i>
<h5 class="card-title">Quer aprender mais?</h5>
<p class="card-text small text-muted">Acesse nossos tutoriais práticos</p>
<a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })" class="btn btn-sm btn-primary">Ver Tutoriais</a>
</div>
</div>
<!-- Help Card -->
<div class="card border-0 shadow-sm bg-light">
<div class="card-body text-center">
<i class="fas fa-question-circle fa-3x text-primary mb-3"></i>
<h5 class="card-title">Precisa de ajuda?</h5>
<p class="card-text small text-muted">Entre em contato com nosso suporte</p>
<a href="/Support" class="btn btn-sm btn-outline-primary">Falar com suporte</a>
</div>
</div>
</div>
</div>
</div>
</div>
@section Styles {
<style>
.article-content {
font-size: 1.1rem;
line-height: 1.8;
}
.article-content h1,
.article-content h2,
.article-content h3,
.article-content h4,
.article-content h5,
.article-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
}
.article-content h2 {
font-size: 1.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #dee2e6;
}
.article-content h3 {
font-size: 1.5rem;
}
.article-content p {
margin-bottom: 1.5rem;
}
.article-content img {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
margin: 1.5rem 0;
}
.article-content ul,
.article-content ol {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.article-content li {
margin-bottom: 0.5rem;
}
.article-content code {
background-color: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.9em;
}
.article-content pre {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.375rem;
overflow-x: auto;
}
.article-content blockquote {
border-left: 4px solid #198754;
padding-left: 1rem;
margin-left: 0;
font-style: italic;
color: #6c757d;
}
.article-content table {
width: 100%;
margin-bottom: 1.5rem;
border-collapse: collapse;
}
.article-content table th,
.article-content table td {
padding: 0.75rem;
border: 1px solid #dee2e6;
}
.article-content table th {
background-color: #f8f9fa;
font-weight: 600;
}
</style>
}

View File

@ -1,77 +0,0 @@
@model List<BCards.Web.Areas.Tutoriais.Models.ArticleMetadata>
@{
ViewData["Title"] = "Artigos BCards - Inspiração e Conhecimento";
}
<div class="container py-5">
<div class="row mb-5">
<div class="col-lg-8 mx-auto text-center">
<h1 class="display-4 mb-3">✨ Artigos BCards</h1>
<p class="lead text-muted">Insights, tendências e inspiração para transformar sua presença digital</p>
</div>
</div>
@if (Model.Any())
{
<div class="row g-4">
@foreach (var artigo in Model)
{
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm hover-shadow">
@if (!string.IsNullOrEmpty(artigo.Image))
{
<img src="@artigo.Image" class="card-img-top" alt="@artigo.Title" style="height: 200px; object-fit: cover;">
}
<div class="card-body">
<h5 class="card-title">@artigo.Title</h5>
<p class="card-text text-muted small">@artigo.Description</p>
<div class="d-flex justify-content-between align-items-center text-muted small">
<span><i class="far fa-clock me-1"></i> @artigo.ReadingTimeMinutes min</span>
<span>@artigo.Date.ToString("dd/MM/yyyy")</span>
</div>
</div>
<div class="card-footer bg-transparent border-0">
<a href="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = artigo.Slug })" class="btn btn-sm btn-primary w-100">
Ler artigo <i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
}
</div>
}
else
{
<div class="text-center py-5">
<i class="fas fa-newspaper fa-4x text-muted mb-3"></i>
<h3 class="text-muted">Nenhum artigo disponível ainda</h3>
<p class="text-muted">Em breve teremos conteúdo inspirador para você!</p>
</div>
}
<!-- CTA -->
<div class="row mt-5">
<div class="col-lg-8 mx-auto">
<div class="card bg-primary text-white border-0 shadow">
<div class="card-body text-center p-5">
<h3 class="mb-3">Quer ver tutoriais práticos?</h3>
<p class="mb-4">Acesse nossa seção de tutoriais e aprenda passo a passo como usar o BCards</p>
<a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })" class="btn btn-light btn-lg">
<i class="fas fa-book-open me-2"></i> Ver Tutoriais
</a>
</div>
</div>
</div>
</div>
</div>
@section Styles {
<style>
.hover-shadow {
transition: box-shadow 0.3s ease-in-out;
}
.hover-shadow:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
}

View File

@ -1,3 +0,0 @@
@{
Layout = "_Layout";
}

View File

@ -1,86 +0,0 @@
using BCards.Web.Areas.Support.Models;
using BCards.Web.Areas.Support.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Areas.Support.Controllers;
[Area("Support")]
[Route("api/ratings")]
[ApiController]
public class RatingsController : ControllerBase
{
private readonly IRatingService _ratingService;
private readonly ILogger<RatingsController> _logger;
public RatingsController(IRatingService ratingService, ILogger<RatingsController> logger)
{
_ratingService = ratingService;
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> SubmitRating([FromBody] RatingSubmissionDto dto)
{
if (!ModelState.IsValid)
{
_logger.LogWarning("Rating inválido submetido: {Errors}", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
return BadRequest(ModelState);
}
try
{
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var success = await _ratingService.SubmitRatingAsync(dto, userId, HttpContext);
if (success)
{
_logger.LogInformation("Rating de {Stars} estrelas submetido com sucesso", dto.RatingValue);
return Ok(new { message = "Avaliação enviada com sucesso! Obrigado pelo feedback." });
}
_logger.LogError("Falha ao submeter rating");
return StatusCode(503, new { message = "Erro ao processar sua avaliação. Tente novamente mais tarde." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao processar rating");
return StatusCode(500, new { message = "Erro interno ao processar sua avaliação." });
}
}
[HttpGet("average")]
public async Task<IActionResult> GetAverageRating()
{
try
{
var average = await _ratingService.GetAverageRatingAsync();
var total = await _ratingService.GetTotalCountAsync();
return Ok(new { average = Math.Round(average, 2), total });
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar média de ratings");
return StatusCode(500, new { message = "Erro ao buscar avaliações" });
}
}
[HttpGet("recent")]
public async Task<IActionResult> GetRecentRatings([FromQuery] int limit = 10)
{
try
{
if (limit < 1 || limit > 50)
limit = 10;
var ratings = await _ratingService.GetRecentRatingsAsync(limit);
return Ok(ratings);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar ratings recentes");
return StatusCode(500, new { message = "Erro ao buscar avaliações recentes" });
}
}
}

View File

@ -1,46 +0,0 @@
using BCards.Web.Areas.Support.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Areas.Support.Controllers;
[Area("Support")]
[Authorize]
public class SupportController : Controller
{
private readonly ISupportService _supportService;
private readonly ILogger<SupportController> _logger;
public SupportController(ISupportService supportService, ILogger<SupportController> logger)
{
_supportService = supportService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> ContactForm()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var options = await _supportService.GetAvailableOptionsAsync(userId);
if (!options.CanUseContactForm)
{
_logger.LogWarning("Usuário {UserId} tentou acessar formulário sem permissão", userId);
TempData["Error"] = "Seu plano atual não tem acesso ao formulário de contato. Faça upgrade para o plano Básico ou superior.";
return RedirectToAction("Index", "Home", new { area = "" });
}
return View(options);
}
[HttpGet]
[Route("Support/Index")]
public async Task<IActionResult> Index()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var options = await _supportService.GetAvailableOptionsAsync(userId);
return View(options);
}
}

View File

@ -1,24 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Areas.Support.Models;
public class Rating
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
public int RatingValue { get; set; } // 1-5 stars
public string? Name { get; set; }
public string? Email { get; set; }
public string? Comment { get; set; }
public string? UserId { get; set; } // null para anônimos
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? Culture { get; set; }
public string? Url { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace BCards.Web.Areas.Support.Models;
public class RatingSubmissionDto
{
[Required]
[Range(1, 5, ErrorMessage = "A avaliação deve ser entre 1 e 5 estrelas")]
public int RatingValue { get; set; }
[StringLength(100, ErrorMessage = "O nome deve ter no máximo 100 caracteres")]
public string? Name { get; set; }
[EmailAddress(ErrorMessage = "Email inválido")]
public string? Email { get; set; }
[StringLength(500, ErrorMessage = "O comentário deve ter no máximo 500 caracteres")]
public string? Comment { get; set; }
}

View File

@ -1,11 +0,0 @@
namespace BCards.Web.Areas.Support.Models;
public class SupportOptions
{
public bool CanRate { get; set; }
public bool CanUseContactForm { get; set; }
public bool CanAccessTelegram { get; set; }
public string? TelegramUrl { get; set; }
public string? FormspreeUrl { get; set; }
public string UserPlan { get; set; } = "Trial";
}

View File

@ -1,12 +0,0 @@
using BCards.Web.Areas.Support.Models;
namespace BCards.Web.Areas.Support.Repositories;
public interface IRatingRepository
{
Task<Rating> CreateAsync(Rating rating);
Task<List<Rating>> GetRecentAsync(int limit = 10);
Task<double> GetAverageRatingAsync();
Task<int> GetTotalCountAsync();
Task<List<Rating>> GetByUserIdAsync(string userId);
}

View File

@ -1,123 +0,0 @@
using BCards.Web.Areas.Support.Models;
using MongoDB.Driver;
namespace BCards.Web.Areas.Support.Repositories;
public class RatingRepository : IRatingRepository
{
private readonly IMongoCollection<Rating> _ratings;
private readonly ILogger<RatingRepository> _logger;
public RatingRepository(IMongoDatabase database, ILogger<RatingRepository> logger)
{
_ratings = database.GetCollection<Rating>("ratings");
_logger = logger;
// Criar índices
CreateIndexes();
}
private void CreateIndexes()
{
try
{
var indexKeysDefinition = Builders<Rating>.IndexKeys.Descending(r => r.CreatedAt);
var indexModel = new CreateIndexModel<Rating>(indexKeysDefinition);
_ratings.Indexes.CreateOne(indexModel);
var userIdIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.UserId);
var userIdIndexModel = new CreateIndexModel<Rating>(userIdIndexKeys);
_ratings.Indexes.CreateOne(userIdIndexModel);
var ratingValueIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.RatingValue);
var ratingValueIndexModel = new CreateIndexModel<Rating>(ratingValueIndexKeys);
_ratings.Indexes.CreateOne(ratingValueIndexModel);
var cultureIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.Culture);
var cultureIndexModel = new CreateIndexModel<Rating>(cultureIndexKeys);
_ratings.Indexes.CreateOne(cultureIndexModel);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Não foi possível criar índices para a collection ratings");
}
}
public async Task<Rating> CreateAsync(Rating rating)
{
try
{
await _ratings.InsertOneAsync(rating);
_logger.LogInformation("Rating criado com sucesso: {RatingId}", rating.Id);
return rating;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao criar rating");
throw;
}
}
public async Task<List<Rating>> GetRecentAsync(int limit = 10)
{
try
{
return await _ratings
.Find(_ => true)
.SortByDescending(r => r.CreatedAt)
.Limit(limit)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar ratings recentes");
return new List<Rating>();
}
}
public async Task<double> GetAverageRatingAsync()
{
try
{
var ratings = await _ratings.Find(_ => true).ToListAsync();
if (ratings.Count == 0)
return 0;
return ratings.Average(r => r.RatingValue);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao calcular média de ratings");
return 0;
}
}
public async Task<int> GetTotalCountAsync()
{
try
{
return (int)await _ratings.CountDocumentsAsync(_ => true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao contar ratings");
return 0;
}
}
public async Task<List<Rating>> GetByUserIdAsync(string userId)
{
try
{
return await _ratings
.Find(r => r.UserId == userId)
.SortByDescending(r => r.CreatedAt)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar ratings do usuário {UserId}", userId);
return new List<Rating>();
}
}
}

View File

@ -1,11 +0,0 @@
using BCards.Web.Areas.Support.Models;
namespace BCards.Web.Areas.Support.Services;
public interface IRatingService
{
Task<bool> SubmitRatingAsync(RatingSubmissionDto dto, string? userId, HttpContext httpContext);
Task<double> GetAverageRatingAsync();
Task<int> GetTotalCountAsync();
Task<List<Rating>> GetRecentRatingsAsync(int limit = 10);
}

View File

@ -1,8 +0,0 @@
using BCards.Web.Areas.Support.Models;
namespace BCards.Web.Areas.Support.Services;
public interface ISupportService
{
Task<SupportOptions> GetAvailableOptionsAsync(string? userId);
}

View File

@ -1,61 +0,0 @@
using BCards.Web.Areas.Support.Models;
using BCards.Web.Areas.Support.Repositories;
using System.Globalization;
namespace BCards.Web.Areas.Support.Services;
public class RatingService : IRatingService
{
private readonly IRatingRepository _repository;
private readonly ILogger<RatingService> _logger;
public RatingService(IRatingRepository repository, ILogger<RatingService> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> SubmitRatingAsync(RatingSubmissionDto dto, string? userId, HttpContext httpContext)
{
try
{
var rating = new Rating
{
RatingValue = dto.RatingValue,
Name = dto.Name,
Email = dto.Email,
Comment = dto.Comment,
UserId = userId,
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = httpContext.Request.Headers["User-Agent"].ToString(),
Culture = CultureInfo.CurrentCulture.Name,
Url = httpContext.Request.Headers["Referer"].ToString(),
CreatedAt = DateTime.UtcNow
};
await _repository.CreateAsync(rating);
_logger.LogInformation("Rating submetido com sucesso por usuário {UserId}", userId ?? "anônimo");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao submeter rating");
return false;
}
}
public async Task<double> GetAverageRatingAsync()
{
return await _repository.GetAverageRatingAsync();
}
public async Task<int> GetTotalCountAsync()
{
return await _repository.GetTotalCountAsync();
}
public async Task<List<Rating>> GetRecentRatingsAsync(int limit = 10)
{
return await _repository.GetRecentAsync(limit);
}
}

View File

@ -1,101 +0,0 @@
using BCards.Web.Areas.Support.Models;
using BCards.Web.Configuration;
using BCards.Web.Models;
using BCards.Web.Repositories;
using Microsoft.Extensions.Options;
namespace BCards.Web.Areas.Support.Services;
public class SupportService : ISupportService
{
private readonly IUserRepository _userRepository;
private readonly IOptions<SupportSettings> _settings;
private readonly ILogger<SupportService> _logger;
public SupportService(
IUserRepository userRepository,
IOptions<SupportSettings> settings,
ILogger<SupportService> logger)
{
_userRepository = userRepository;
_settings = settings;
_logger = logger;
}
public async Task<SupportOptions> GetAvailableOptionsAsync(string? userId)
{
var options = new SupportOptions
{
CanRate = _settings.Value.EnableRatingForAllUsers,
CanUseContactForm = false,
CanAccessTelegram = false,
TelegramUrl = _settings.Value.TelegramUrl,
FormspreeUrl = _settings.Value.FormspreeUrl,
UserPlan = "Trial"
};
// Usuário não autenticado ou trial
if (string.IsNullOrEmpty(userId))
{
_logger.LogDebug("Usuário não autenticado - apenas rating disponível");
return options;
}
try
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
_logger.LogWarning("Usuário {UserId} não encontrado", userId);
return options;
}
var planName = user.CurrentPlan?.ToLower() ?? "trial";
options.UserPlan = planName;
_logger.LogDebug("Verificando opções de suporte para usuário {UserId} com plano {Plan}", userId, planName);
// Trial: apenas rating
if (planName == "trial")
{
_logger.LogDebug("Plano Trial - apenas rating disponível");
return options;
}
// Básico: rating + formulário
if (planName == "basic" || planName == "básico")
{
options.CanUseContactForm = true;
options.UserPlan = "Básico";
_logger.LogDebug("Plano Básico - rating + formulário disponíveis");
return options;
}
// Profissional: rating + formulário (sem telegram)
if (planName == "professional" || planName == "profissional")
{
options.CanUseContactForm = true;
options.UserPlan = "Profissional";
_logger.LogDebug("Plano Profissional - rating + formulário disponíveis");
return options;
}
// Premium e PremiumAffiliate: tudo
if (planName == "premium" || planName == "premiumaffiliate" || planName == "premium+afiliados")
{
options.CanUseContactForm = true;
options.CanAccessTelegram = true;
options.UserPlan = planName.Contains("affiliate") || planName.Contains("afiliados") ? "Premium+Afiliados" : "Premium";
_logger.LogDebug("Plano {Plan} - todas as opções disponíveis", planName);
return options;
}
return options;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao verificar opções de suporte para usuário {UserId}", userId);
return options;
}
}
}

View File

@ -1,36 +0,0 @@
using BCards.Web.Areas.Support.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Areas.Support.ViewComponents;
public class SupportFabViewComponent : ViewComponent
{
private readonly ISupportService _supportService;
private readonly ILogger<SupportFabViewComponent> _logger;
public SupportFabViewComponent(ISupportService supportService, ILogger<SupportFabViewComponent> logger)
{
_supportService = supportService;
_logger = logger;
}
public async Task<IViewComponentResult> InvokeAsync()
{
try
{
var userId = UserClaimsPrincipal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var options = await _supportService.GetAvailableOptionsAsync(userId);
_logger.LogDebug("SupportFab invocado para usuário {UserId} - Opções: Rating={CanRate}, Form={CanUseContactForm}, Telegram={CanAccessTelegram}",
userId ?? "anônimo", options.CanRate, options.CanUseContactForm, options.CanAccessTelegram);
return View(options);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao carregar SupportFab ViewComponent");
return Content(string.Empty);
}
}
}

View File

@ -1,134 +0,0 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
<!-- Support FAB (Floating Action Button) -->
<div class="support-fab-container">
<button class="support-fab-trigger" id="supportFabTrigger" aria-label="Precisa de Ajuda?">
<i class="fas fa-question-circle"></i>
</button>
<div class="support-fab-menu" id="supportFabMenu" style="display: none;">
<div class="support-fab-header">
<h6>Precisa de Ajuda?</h6>
<p class="small text-muted mb-0">Plano: @Model.UserPlan</p>
</div>
@if (Model.CanAccessTelegram)
{
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="support-fab-option support-fab-telegram">
<div class="support-fab-option-icon">
<i class="fab fa-telegram"></i>
</div>
<div class="support-fab-option-content">
<strong>Falar no Telegram</strong>
<small>Suporte prioritário</small>
</div>
</a>
}
@if (Model.CanUseContactForm)
{
<a href="@Url.Action("ContactForm", "Support", new { area = "Support" })" class="support-fab-option support-fab-form">
<div class="support-fab-option-icon">
<i class="fas fa-envelope"></i>
</div>
<div class="support-fab-option-content">
<strong>Enviar Mensagem</strong>
<small>Formulário de contato</small>
</div>
</a>
}
@if (Model.CanRate)
{
<button type="button" class="support-fab-option support-fab-rating" data-bs-toggle="modal" data-bs-target="#ratingModal">
<div class="support-fab-option-icon">
<i class="fas fa-star"></i>
</div>
<div class="support-fab-option-content">
<strong>Avaliar Serviço</strong>
<small>Conte sua experiência</small>
</div>
</button>
}
@if (!Model.CanUseContactForm && !Model.CanAccessTelegram)
{
<div class="support-fab-upgrade">
<p class="small mb-2">
<i class="fas fa-lock text-warning"></i>
Faça upgrade para acessar mais opções de suporte!
</p>
<a href="@Url.Action("Index", "Payment", new { area = "" })" class="btn btn-sm btn-primary">Ver Planos</a>
</div>
}
</div>
</div>
<!-- Rating Modal -->
<div class="modal fade" id="ratingModal" tabindex="-1" aria-labelledby="ratingModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ratingModalLabel">Avalie nossa plataforma</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<form id="ratingForm">
<div class="mb-3">
<label class="form-label">Sua avaliação: <span class="text-danger">*</span></label>
<div class="star-rating" id="starRating">
<i class="far fa-star" data-rating="1"></i>
<i class="far fa-star" data-rating="2"></i>
<i class="far fa-star" data-rating="3"></i>
<i class="far fa-star" data-rating="4"></i>
<i class="far fa-star" data-rating="5"></i>
</div>
<input type="hidden" id="ratingValue" name="ratingValue" required>
<div class="invalid-feedback d-block" id="ratingError" style="display: none !important;">
Por favor, selecione uma avaliação
</div>
</div>
<div class="mb-3">
<label for="ratingName" class="form-label">Nome (opcional)</label>
<input type="text" class="form-control" id="ratingName" name="name" maxlength="100" placeholder="Seu nome">
</div>
<div class="mb-3">
<label for="ratingEmail" class="form-label">Email (opcional)</label>
<input type="email" class="form-control" id="ratingEmail" name="email" placeholder="seu@email.com">
</div>
<div class="mb-3">
<label for="ratingComment" class="form-label">Comentário (opcional)</label>
<textarea class="form-control" id="ratingComment" name="comment" rows="3" maxlength="500" placeholder="Conte-nos sobre sua experiência..."></textarea>
<small class="form-text text-muted">
<span id="commentCounter">0</span>/500 caracteres
</small>
</div>
<div class="alert alert-success d-none" id="ratingSuccessAlert" role="alert">
<i class="fas fa-check-circle"></i> Avaliação enviada com sucesso! Obrigado pelo feedback.
</div>
<div class="alert alert-danger d-none" id="ratingErrorAlert" role="alert">
<i class="fas fa-exclamation-triangle"></i> <span id="ratingErrorMessage">Erro ao enviar avaliação. Tente novamente.</span>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary" id="submitRatingBtn">
<i class="fas fa-paper-plane"></i> Enviar Avaliação
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<link rel="stylesheet" href="~/css/support-fab.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/rating.css" asp-append-version="true" />
<script src="~/js/support-fab.js" asp-append-version="true"></script>
<script src="~/js/rating.js" asp-append-version="true"></script>
}

View File

@ -1,155 +0,0 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
@{
ViewData["Title"] = "Formulário de Contato";
Layout = "_Layout";
}
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-body p-4">
<h2 class="card-title mb-4">
<i class="fas fa-envelope text-primary"></i> Fale Conosco
</h2>
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle"></i>
<strong>Tempo de resposta:</strong> Normalmente respondemos em até 24-48 horas.
</div>
<form action="@Model.FormspreeUrl" method="POST">
<div class="mb-3">
<label for="name" class="form-label">
Nome <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="name" name="name" required maxlength="100">
</div>
<div class="mb-3">
<label for="email" class="form-label">
Email <span class="text-danger">*</span>
</label>
<input type="email" class="form-control" id="email" name="email" required>
<small class="form-text text-muted">Usaremos este email para responder sua mensagem.</small>
</div>
<div class="mb-3">
<label for="subject" class="form-label">
Assunto <span class="text-danger">*</span>
</label>
<select class="form-select" id="subject" name="subject" required>
<option value="">Selecione um assunto</option>
<option value="Suporte Técnico">Suporte Técnico</option>
<option value="Dúvida sobre Planos">Dúvida sobre Planos</option>
<option value="Problema de Pagamento">Problema de Pagamento</option>
<option value="Sugestão de Melhoria">Sugestão de Melhoria</option>
<option value="Reportar Bug">Reportar Bug</option>
<option value="Outro">Outro</option>
</select>
</div>
@if (Model.CanAccessTelegram)
{
<div class="mb-3">
<label for="preferredContact" class="form-label">
Canal de contato preferido
</label>
<select class="form-select" id="preferredContact" name="preferredContact">
<option value="email" selected>Email</option>
<option value="telegram">Telegram</option>
</select>
</div>
}
else
{
<input type="hidden" name="preferredContact" value="email">
}
<div class="mb-3">
<label for="message" class="form-label">
Mensagem <span class="text-danger">*</span>
</label>
<textarea class="form-control" id="message" name="message" rows="6" required maxlength="2000" placeholder="Descreva sua dúvida ou problema em detalhes..."></textarea>
<small class="form-text text-muted">
<span id="messageCounter">0</span>/2000 caracteres
</small>
</div>
<input type="hidden" name="_language" value="pt-BR">
<input type="hidden" name="_subject" value="Novo contato BCards">
<input type="hidden" name="userPlan" value="@Model.UserPlan">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-paper-plane"></i> Enviar Mensagem
</button>
</div>
</form>
@if (Model.CanAccessTelegram)
{
<hr class="my-4">
<div class="text-center">
<p class="text-muted mb-3">Ou se preferir, entre em contato direto via Telegram:</p>
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="btn btn-outline-primary">
<i class="fab fa-telegram"></i> Abrir Telegram
</a>
</div>
}
</div>
</div>
<div class="mt-4">
<h4 class="mb-3">Perguntas Frequentes</h4>
<div class="accordion" id="faqAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq1">
Como faço upgrade do meu plano?
</button>
</h2>
<div id="faq1" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Você pode fazer upgrade a qualquer momento através da página de <a href="@Url.Action("Index", "Payment", new { area = "" })">Planos e Preços</a>.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq2">
Posso cancelar minha assinatura a qualquer momento?
</button>
</h2>
<div id="faq2" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Sim! Você pode cancelar sua assinatura a qualquer momento sem multas ou taxas adicionais.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq3">
Qual o tempo de resposta do suporte?
</button>
</h2>
<div id="faq3" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Respondemos em até 24-48h para planos Básico. Usuários Premium têm suporte prioritário com resposta em até 12h.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
// Character counter for message
document.getElementById('message').addEventListener('input', function() {
document.getElementById('messageCounter').textContent = this.value.length;
});
</script>
}

View File

@ -1,90 +0,0 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
@{
ViewData["Title"] = "Central de Suporte";
Layout = "_Layout";
}
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-lg-10">
<h1 class="mb-4">
<i class="fas fa-headset text-primary"></i> Central de Suporte
</h1>
<div class="alert alert-info">
<strong>Plano Atual:</strong> @Model.UserPlan
</div>
<div class="row g-4 mt-3">
@if (Model.CanAccessTelegram)
{
<div class="col-md-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="display-4 text-primary mb-3">
<i class="fab fa-telegram"></i>
</div>
<h5 class="card-title">Telegram</h5>
<p class="card-text">Fale conosco diretamente pelo Telegram para suporte prioritário.</p>
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
Abrir Telegram
</a>
</div>
</div>
</div>
}
@if (Model.CanUseContactForm)
{
<div class="col-md-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="display-4 text-success mb-3">
<i class="fas fa-envelope"></i>
</div>
<h5 class="card-title">Formulário de Contato</h5>
<p class="card-text">Envie sua dúvida ou problema através do nosso formulário.</p>
<a href="@Url.Action("ContactForm", "Support", new { area = "Support" })" class="btn btn-success">
Enviar Mensagem
</a>
</div>
</div>
</div>
}
@if (Model.CanRate)
{
<div class="col-md-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="display-4 text-warning mb-3">
<i class="fas fa-star"></i>
</div>
<h5 class="card-title">Avalie-nos</h5>
<p class="card-text">Conte-nos sobre sua experiência com a plataforma.</p>
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#ratingModal">
Avaliar Agora
</button>
</div>
</div>
</div>
}
</div>
@if (!Model.CanUseContactForm && !Model.CanAccessTelegram)
{
<div class="alert alert-warning mt-4">
<h5><i class="fas fa-lock"></i> Desbloqueie Mais Recursos de Suporte!</h5>
<p class="mb-2">Seu plano atual tem acesso limitado. Faça upgrade para:</p>
<ul>
<li><strong>Plano Básico:</strong> Formulário de contato + Avaliações</li>
<li><strong>Plano Premium:</strong> Telegram + Formulário + Suporte Prioritário</li>
</ul>
<a href="@Url.Action("Index", "Payment", new { area = "" })" class="btn btn-primary mt-2">
Ver Planos e Preços
</a>
</div>
}
</div>
</div>
</div>

View File

@ -1,111 +0,0 @@
using BCards.Web.Areas.Tutoriais.Models;
using BCards.Web.Areas.Tutoriais.Services;
using BCards.Web.Repositories;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Areas.Tutoriais.Controllers;
[Area("Tutoriais")]
public class TutoriaisController : Controller
{
private readonly IMarkdownService _markdownService;
private readonly ICategoryRepository _categoryRepository;
private readonly ILogger<TutoriaisController> _logger;
public TutoriaisController(
IMarkdownService markdownService,
ICategoryRepository categoryRepository,
ILogger<TutoriaisController> logger)
{
_markdownService = markdownService;
_categoryRepository = categoryRepository;
_logger = logger;
}
// GET /tutoriais
public async Task<IActionResult> Index()
{
var categories = await _categoryRepository.GetAllActiveAsync();
var tutoriaisPorCategoria = new Dictionary<string, List<ArticleMetadata>>();
foreach (var category in categories)
{
var artigos = await _markdownService
.GetArticlesByCategoryAsync(category.Slug, "pt-BR");
if (artigos.Any())
{
tutoriaisPorCategoria[category.Slug] = artigos;
}
}
ViewBag.Categories = categories;
return View(tutoriaisPorCategoria);
}
// GET /tutoriais/{categoria}
public async Task<IActionResult> Category(string categoria)
{
// Validar categoria existe
var category = await _categoryRepository.GetBySlugAsync(categoria);
if (category == null)
{
_logger.LogWarning("Categoria não encontrada: {Categoria}", categoria);
return NotFound();
}
var artigos = await _markdownService
.GetArticlesByCategoryAsync(categoria, "pt-BR");
ViewBag.Category = category;
return View(artigos);
}
// GET /tutoriais/{categoria}/{slug}
public async Task<IActionResult> Article(string categoria, string slug)
{
// Sanitização (segurança contra path traversal)
categoria = categoria.Replace("..", "").Replace("/", "").Replace("\\", "");
slug = slug.Replace("..", "").Replace("/", "").Replace("\\", "");
// Validar categoria existe
var category = await _categoryRepository.GetBySlugAsync(categoria);
if (category == null)
{
_logger.LogWarning("Categoria não encontrada: {Categoria}", categoria);
return NotFound();
}
try
{
var article = await _markdownService.GetArticleAsync(
$"Tutoriais/{categoria}/{slug}",
"pt-BR"
);
if (article == null)
{
_logger.LogWarning("Artigo não encontrado: {Categoria}/{Slug}", categoria, slug);
return NotFound();
}
// Buscar artigos relacionados da mesma categoria
article.RelatedArticles = await _markdownService
.GetArticlesByCategoryAsync(categoria, "pt-BR");
// Remover o artigo atual dos relacionados
article.RelatedArticles = article.RelatedArticles
.Where(a => a.Slug != slug)
.Take(3)
.ToList();
ViewBag.Category = category;
return View(article);
}
catch (FileNotFoundException)
{
_logger.LogWarning("Arquivo markdown não encontrado: {Categoria}/{Slug}", categoria, slug);
return NotFound();
}
}
}

View File

@ -1,16 +0,0 @@
namespace BCards.Web.Areas.Tutoriais.Models;
public class ArticleMetadata
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Keywords { get; set; } = string.Empty;
public string Author { get; set; } = "BCards";
public DateTime Date { get; set; }
public DateTime LastMod { get; set; }
public string Image { get; set; } = string.Empty;
public string Culture { get; set; } = "pt-BR";
public string? Category { get; set; } // Apenas para tutoriais
public int ReadingTimeMinutes { get; set; }
public string Slug { get; set; } = string.Empty;
}

View File

@ -1,10 +0,0 @@
namespace BCards.Web.Areas.Tutoriais.Models.ViewModels;
public class ArticleViewModel
{
public ArticleMetadata Metadata { get; set; } = new();
public string HtmlContent { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public DateTime LastModified { get; set; }
public List<ArticleMetadata> RelatedArticles { get; set; } = new();
}

View File

@ -1,11 +0,0 @@
using BCards.Web.Areas.Tutoriais.Models;
using BCards.Web.Areas.Tutoriais.Models.ViewModels;
namespace BCards.Web.Areas.Tutoriais.Services;
public interface IMarkdownService
{
Task<ArticleViewModel?> GetArticleAsync(string relativePath, string culture);
Task<List<ArticleMetadata>> GetArticlesByCategoryAsync(string category, string culture);
Task<List<ArticleMetadata>> GetAllArticlesAsync(string baseFolder, string culture);
}

View File

@ -1,240 +0,0 @@
using BCards.Web.Areas.Tutoriais.Models;
using BCards.Web.Areas.Tutoriais.Models.ViewModels;
using Markdig;
using Microsoft.Extensions.Caching.Memory;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace BCards.Web.Areas.Tutoriais.Services;
public class MarkdownService : IMarkdownService
{
private readonly IMemoryCache _cache;
private readonly ILogger<MarkdownService> _logger;
private readonly string _contentBasePath;
private readonly MarkdownPipeline _markdownPipeline;
private readonly IDeserializer _yamlDeserializer;
public MarkdownService(
IMemoryCache cache,
ILogger<MarkdownService> logger,
IWebHostEnvironment environment)
{
_cache = cache;
_logger = logger;
_contentBasePath = Path.Combine(environment.ContentRootPath, "Content");
// Pipeline Markdig com extensões avançadas
_markdownPipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions() // Tables, footnotes, etc.
.UseAutoLinks() // Auto-link URLs
.UseEmphasisExtras() // ~~strikethrough~~
.UseGenericAttributes() // {#id .class}
.DisableHtml() // Segurança: bloqueia HTML inline
.Build();
// Deserializador YAML
_yamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
}
public async Task<ArticleViewModel?> GetArticleAsync(string relativePath, string culture)
{
var cacheKey = $"Article_{relativePath}_{culture}";
// Verificar cache
if (_cache.TryGetValue(cacheKey, out ArticleViewModel? cachedArticle))
{
_logger.LogDebug("Artigo encontrado no cache: {Path}", relativePath);
return cachedArticle;
}
// Construir caminho completo
var fullPath = Path.Combine(_contentBasePath, $"{relativePath}.{culture}.md");
if (!File.Exists(fullPath))
{
_logger.LogWarning("Arquivo não encontrado: {Path}", fullPath);
return null;
}
try
{
var content = await File.ReadAllTextAsync(fullPath);
var (metadata, markdownContent) = ExtractFrontmatter(content);
if (metadata == null)
{
_logger.LogError("Frontmatter inválido em: {Path}", fullPath);
return null;
}
// Processar markdown → HTML
var htmlContent = Markdown.ToHtml(markdownContent, _markdownPipeline);
// Calcular tempo de leitura (200 palavras/minuto)
var wordCount = markdownContent.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
metadata.ReadingTimeMinutes = Math.Max(1, wordCount / 200);
// Extrair slug do path
metadata.Slug = Path.GetFileNameWithoutExtension(
Path.GetFileNameWithoutExtension(relativePath.Split('/').Last())
);
metadata.Culture = culture;
var article = new ArticleViewModel
{
Metadata = metadata,
HtmlContent = htmlContent,
Slug = metadata.Slug,
LastModified = File.GetLastWriteTimeUtc(fullPath)
};
// Cache por 1 hora
_cache.Set(cacheKey, article, TimeSpan.FromHours(1));
_logger.LogInformation("Artigo processado e cacheado: {Path}", relativePath);
return article;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao processar artigo: {Path}", fullPath);
return null;
}
}
public async Task<List<ArticleMetadata>> GetArticlesByCategoryAsync(string category, string culture)
{
var cacheKey = $"CategoryArticles_{category}_{culture}";
if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cached))
{
return cached ?? new List<ArticleMetadata>();
}
var categoryPath = Path.Combine(_contentBasePath, "Tutoriais", category);
if (!Directory.Exists(categoryPath))
{
_logger.LogWarning("Diretório de categoria não encontrado: {Path}", categoryPath);
return new List<ArticleMetadata>();
}
var articles = new List<ArticleMetadata>();
var files = Directory.GetFiles(categoryPath, $"*.{culture}.md");
foreach (var file in files)
{
try
{
var content = await File.ReadAllTextAsync(file);
var (metadata, _) = ExtractFrontmatter(content);
if (metadata != null)
{
var slug = Path.GetFileNameWithoutExtension(
Path.GetFileNameWithoutExtension(file)
);
metadata.Slug = slug;
metadata.Culture = culture;
metadata.Category = category;
articles.Add(metadata);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao processar arquivo: {File}", file);
}
}
// Ordenar por data (mais recentes primeiro)
articles = articles.OrderByDescending(a => a.Date).ToList();
_cache.Set(cacheKey, articles, TimeSpan.FromHours(1));
return articles;
}
public async Task<List<ArticleMetadata>> GetAllArticlesAsync(string baseFolder, string culture)
{
var cacheKey = $"AllArticles_{baseFolder}_{culture}";
if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cached))
{
return cached ?? new List<ArticleMetadata>();
}
var folderPath = Path.Combine(_contentBasePath, baseFolder);
if (!Directory.Exists(folderPath))
{
_logger.LogWarning("Pasta não encontrada: {Path}", folderPath);
return new List<ArticleMetadata>();
}
var articles = new List<ArticleMetadata>();
var files = Directory.GetFiles(folderPath, $"*.{culture}.md", SearchOption.AllDirectories);
foreach (var file in files)
{
try
{
var content = await File.ReadAllTextAsync(file);
var (metadata, _) = ExtractFrontmatter(content);
if (metadata != null)
{
var slug = Path.GetFileNameWithoutExtension(
Path.GetFileNameWithoutExtension(file)
);
metadata.Slug = slug;
metadata.Culture = culture;
articles.Add(metadata);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao processar arquivo: {File}", file);
}
}
articles = articles.OrderByDescending(a => a.Date).ToList();
_cache.Set(cacheKey, articles, TimeSpan.FromHours(1));
return articles;
}
private (ArticleMetadata? metadata, string content) ExtractFrontmatter(string fileContent)
{
var lines = fileContent.Split('\n');
if (lines.Length < 3 || !lines[0].Trim().Equals("---"))
{
_logger.LogWarning("Frontmatter não encontrado (deve começar com ---)");
return (null, fileContent);
}
var endIndex = Array.FindIndex(lines, 1, line => line.Trim().Equals("---"));
if (endIndex == -1)
{
_logger.LogWarning("Frontmatter mal formatado (falta --- de fechamento)");
return (null, fileContent);
}
try
{
var yamlContent = string.Join('\n', lines[1..endIndex]);
var metadata = _yamlDeserializer.Deserialize<ArticleMetadata>(yamlContent);
var markdownContent = string.Join('\n', lines[(endIndex + 1)..]);
return (metadata, markdownContent);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao deserializar YAML frontmatter");
return (null, fileContent);
}
}
}

View File

@ -1,251 +0,0 @@
@model BCards.Web.Areas.Tutoriais.Models.ViewModels.ArticleViewModel
@{
var category = ViewBag.Category as BCards.Web.Models.Category;
ViewData["Title"] = Model.Metadata.Title;
}
@section Head {
<!-- Meta Tags SEO -->
<meta name="description" content="@Model.Metadata.Description">
<meta name="keywords" content="@Model.Metadata.Keywords">
<meta name="author" content="@Model.Metadata.Author">
<meta name="robots" content="index, follow">
<link rel="canonical" href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = Model.Slug }, Context.Request.Scheme)">
<!-- Open Graph -->
<meta property="og:type" content="article">
<meta property="og:title" content="@Model.Metadata.Title">
<meta property="og:description" content="@Model.Metadata.Description">
<meta property="og:image" content="@Model.Metadata.Image">
<meta property="og:url" content="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = Model.Slug }, Context.Request.Scheme)">
<meta property="article:published_time" content="@Model.Metadata.Date.ToString("yyyy-MM-ddTHH:mm:ssZ")">
<meta property="article:modified_time" content="@Model.Metadata.LastMod.ToString("yyyy-MM-ddTHH:mm:ssZ")">
<meta property="article:author" content="@Model.Metadata.Author">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="@Model.Metadata.Title">
<meta name="twitter:description" content="@Model.Metadata.Description">
<meta name="twitter:image" content="@Model.Metadata.Image">
<!-- Schema.org JSON-LD -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "Article",
"headline": "@Model.Metadata.Title",
"description": "@Model.Metadata.Description",
"image": "@Model.Metadata.Image",
"datePublished": "@Model.Metadata.Date.ToString("yyyy-MM-dd")",
"dateModified": "@Model.Metadata.LastMod.ToString("yyyy-MM-dd")",
"author": {
"@@type": "Person",
"name": "@Model.Metadata.Author"
},
"publisher": {
"@@type": "Organization",
"name": "BCards",
"logo": {
"@@type": "ImageObject",
"url": "https://bcards.site/logo.png"
}
}
}
</script>
<!-- BreadcrumbList Schema -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{
"@@type": "ListItem",
"position": 1,
"name": "Início",
"item": "https://bcards.site"
},
{
"@@type": "ListItem",
"position": 2,
"name": "Tutoriais",
"item": "https://bcards.site/tutoriais"
},
{
"@@type": "ListItem",
"position": 3,
"name": "@category?.Name",
"item": "https://bcards.site/tutoriais/@category?.Slug"
},
{
"@@type": "ListItem",
"position": 4,
"name": "@Model.Metadata.Title"
}
]
}
</script>
}
<div class="container py-5">
<div class="row">
<div class="col-lg-8">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Início</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })">Tutoriais</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Category", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug })">@category?.Name</a></li>
<li class="breadcrumb-item active" aria-current="page">@Model.Metadata.Title</li>
</ol>
</nav>
<!-- Article Header -->
<article>
<header class="mb-4">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-primary me-2">@category?.Icon @category?.Name</span>
<span class="text-muted small"><i class="far fa-clock me-1"></i> @Model.Metadata.ReadingTimeMinutes min de leitura</span>
</div>
<h1 class="display-5 mb-3">@Model.Metadata.Title</h1>
<p class="lead text-muted">@Model.Metadata.Description</p>
<div class="d-flex align-items-center text-muted small mb-3">
<span class="me-3"><i class="fas fa-user me-1"></i> @Model.Metadata.Author</span>
<span class="me-3"><i class="fas fa-calendar me-1"></i> @Model.Metadata.Date.ToString("dd/MM/yyyy")</span>
<span><i class="fas fa-sync me-1"></i> Atualizado em @Model.Metadata.LastMod.ToString("dd/MM/yyyy")</span>
</div>
</header>
@if (!string.IsNullOrEmpty(Model.Metadata.Image))
{
<img src="@Model.Metadata.Image" class="img-fluid rounded mb-4" alt="@Model.Metadata.Title">
}
<!-- Article Content -->
<div class="article-content">
@Html.Raw(Model.HtmlContent)
</div>
</article>
<!-- CTA -->
<div class="alert alert-primary mt-5" role="alert">
<h4 class="alert-heading"><i class="fas fa-rocket me-2"></i> Pronto para criar seu BCard?</h4>
<p class="mb-3">Agora que você aprendeu como funciona, que tal criar seu próprio cartão digital profissional?</p>
<a href="/" class="btn btn-primary">Criar meu BCard grátis</a>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="sticky-top" style="top: 20px;">
<!-- Related Articles -->
@if (Model.RelatedArticles.Any())
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title mb-3"><i class="fas fa-book-open me-2"></i> Tutoriais Relacionados</h5>
<div class="list-group list-group-flush">
@foreach (var related in Model.RelatedArticles)
{
<a href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = related.Slug })" class="list-group-item list-group-item-action border-0 px-0">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">@related.Title</h6>
</div>
<small class="text-muted"><i class="far fa-clock me-1"></i> @related.ReadingTimeMinutes min</small>
</a>
}
</div>
</div>
</div>
}
<!-- Help Card -->
<div class="card border-0 shadow-sm bg-light">
<div class="card-body text-center">
<i class="fas fa-question-circle fa-3x text-primary mb-3"></i>
<h5 class="card-title">Precisa de ajuda?</h5>
<p class="card-text small text-muted">Entre em contato com nosso suporte se tiver dúvidas</p>
<a href="/Support" class="btn btn-sm btn-outline-primary">Falar com suporte</a>
</div>
</div>
</div>
</div>
</div>
</div>
@section Styles {
<style>
.article-content {
font-size: 1.1rem;
line-height: 1.8;
}
.article-content h1,
.article-content h2,
.article-content h3,
.article-content h4,
.article-content h5,
.article-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
}
.article-content h2 {
font-size: 1.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #dee2e6;
}
.article-content h3 {
font-size: 1.5rem;
}
.article-content p {
margin-bottom: 1.5rem;
}
.article-content img {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
margin: 1.5rem 0;
}
.article-content ul,
.article-content ol {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.article-content li {
margin-bottom: 0.5rem;
}
.article-content code {
background-color: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.9em;
}
.article-content pre {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.375rem;
overflow-x: auto;
}
.article-content blockquote {
border-left: 4px solid #0d6efd;
padding-left: 1rem;
margin-left: 0;
font-style: italic;
color: #6c757d;
}
.article-content table {
width: 100%;
margin-bottom: 1.5rem;
border-collapse: collapse;
}
.article-content table th,
.article-content table td {
padding: 0.75rem;
border: 1px solid #dee2e6;
}
.article-content table th {
background-color: #f8f9fa;
font-weight: 600;
}
</style>
}

View File

@ -1,74 +0,0 @@
@model List<BCards.Web.Areas.Tutoriais.Models.ArticleMetadata>
@{
var category = ViewBag.Category as BCards.Web.Models.Category;
ViewData["Title"] = $"Tutoriais de {category?.Name} - BCards";
}
<div class="container py-5">
<div class="row mb-5">
<div class="col-lg-8 mx-auto text-center">
<span class="display-1">@category?.Icon</span>
<h1 class="display-5 mb-3">Tutoriais de @category?.Name</h1>
<p class="lead text-muted">@category?.Description</p>
<nav aria-label="breadcrumb">
<ol class="breadcrumb justify-content-center">
<li class="breadcrumb-item"><a href="/">Início</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })">Tutoriais</a></li>
<li class="breadcrumb-item active" aria-current="page">@category?.Name</li>
</ol>
</nav>
</div>
</div>
@if (Model.Any())
{
<div class="row g-4">
@foreach (var artigo in Model)
{
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm hover-shadow">
@if (!string.IsNullOrEmpty(artigo.Image))
{
<img src="@artigo.Image" class="card-img-top" alt="@artigo.Title" style="height: 200px; object-fit: cover;">
}
<div class="card-body">
<h5 class="card-title">@artigo.Title</h5>
<p class="card-text text-muted small">@artigo.Description</p>
<div class="d-flex justify-content-between align-items-center text-muted small">
<span><i class="far fa-clock me-1"></i> @artigo.ReadingTimeMinutes min</span>
<span>@artigo.Date.ToString("dd/MM/yyyy")</span>
</div>
</div>
<div class="card-footer bg-transparent border-0">
<a href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = artigo.Slug })" class="btn btn-sm btn-primary w-100">
Ler tutorial <i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
}
</div>
}
else
{
<div class="text-center py-5">
<i class="fas fa-book-open fa-4x text-muted mb-3"></i>
<h3 class="text-muted">Nenhum tutorial disponível nesta categoria</h3>
<p class="text-muted">Em breve teremos tutoriais para @category?.Name!</p>
<a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })" class="btn btn-primary mt-3">
<i class="fas fa-arrow-left me-2"></i> Voltar para tutoriais
</a>
</div>
}
</div>
@section Styles {
<style>
.hover-shadow {
transition: box-shadow 0.3s ease-in-out;
}
.hover-shadow:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
}

View File

@ -1,84 +0,0 @@
@model Dictionary<string, List<BCards.Web.Areas.Tutoriais.Models.ArticleMetadata>>
@{
ViewData["Title"] = "Tutoriais BCards - Aprenda a usar o BCards";
var categories = ViewBag.Categories as List<BCards.Web.Models.Category>;
}
<div class="container py-5">
<div class="row mb-5">
<div class="col-lg-8 mx-auto text-center">
<h1 class="display-4 mb-3">📚 Tutoriais BCards</h1>
<p class="lead text-muted">Aprenda a usar o BCards e maximize seus resultados com guias práticos por categoria</p>
</div>
</div>
@if (Model.Any())
{
@foreach (var categorySlug in Model.Keys)
{
var category = categories?.FirstOrDefault(c => c.Slug == categorySlug);
var artigos = Model[categorySlug];
if (category != null && artigos.Any())
{
<div class="mb-5">
<div class="d-flex align-items-center mb-3">
<span class="fs-2 me-2">@category.Icon</span>
<h2 class="mb-0">@category.Name</h2>
<a href="@Url.Action("Category", "Tutoriais", new { area = "Tutoriais", categoria = category.Slug })" class="ms-auto btn btn-sm btn-outline-primary">
Ver todos <i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
<p class="text-muted mb-4">@category.Description</p>
<div class="row g-4">
@foreach (var artigo in artigos.Take(3))
{
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm hover-shadow">
@if (!string.IsNullOrEmpty(artigo.Image))
{
<img src="@artigo.Image" class="card-img-top" alt="@artigo.Title" style="height: 200px; object-fit: cover;">
}
<div class="card-body">
<h5 class="card-title">@artigo.Title</h5>
<p class="card-text text-muted small">@artigo.Description</p>
<div class="d-flex justify-content-between align-items-center text-muted small">
<span><i class="far fa-clock me-1"></i> @artigo.ReadingTimeMinutes min</span>
<span>@artigo.Date.ToString("dd/MM/yyyy")</span>
</div>
</div>
<div class="card-footer bg-transparent border-0">
<a href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category.Slug, slug = artigo.Slug })" class="btn btn-sm btn-primary w-100">
Ler tutorial <i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
}
</div>
</div>
<hr class="my-5">
}
}
}
else
{
<div class="text-center py-5">
<i class="fas fa-book-open fa-4x text-muted mb-3"></i>
<h3 class="text-muted">Nenhum tutorial disponível ainda</h3>
<p class="text-muted">Em breve teremos tutoriais incríveis para você!</p>
</div>
}
</div>
@section Styles {
<style>
.hover-shadow {
transition: box-shadow 0.3s ease-in-out;
}
.hover-shadow:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
}

View File

@ -1,3 +0,0 @@
@{
Layout = "_Layout";
}

View File

@ -6,11 +6,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
<RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
<Configurations>Debug;Release;Testing</Configurations>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.43.0" />
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
<PackageReference Include="Stripe.net" Version="48.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
@ -22,31 +20,10 @@
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.OpenSearch" Version="1.3.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="7.0.0" />
<PackageReference Include="AspNetCore.DataProtection.MongoDB" Version="8.0.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\**\*.resx" />
</ItemGroup>
<ItemGroup>
<None Update="Content\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)'=='Testing'">
<DefineConstants>$(DefineConstants);TESTING</DefineConstants>
</PropertyGroup>
</Project>

View File

@ -5,8 +5,4 @@ public class StripeSettings
public string PublishableKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public string WebhookSecret { get; set; } = string.Empty;
public string Environment { get; set; } = "test";
public bool IsTestMode => Environment.ToLowerInvariant() == "test";
public bool IsLiveMode => Environment.ToLowerInvariant() == "live";
}

View File

@ -1,10 +0,0 @@
namespace BCards.Web.Configuration;
public class SupportSettings
{
public string TelegramUrl { get; set; } = string.Empty;
public string FormspreeUrl { get; set; } = string.Empty;
public List<string> EnableTelegramForPlans { get; set; } = new();
public List<string> EnableFormForPlans { get; set; } = new();
public bool EnableRatingForAllUsers { get; set; } = true;
}

View File

@ -1,395 +0,0 @@
---
title: "BCards vs LinkTree: Compare e Escolha a Melhor Alternativa Brasileira"
description: "Comparação completa entre BCards e LinkTree. Descubra qual plataforma oferece melhor custo-benefício, mais funcionalidades e suporte em português para sua página de links profissional."
keywords: "linktree, alternativa ao linktree, bcards, página de links, linktree brasil, comparação linktree, melhor que linktree"
author: "Equipe BCards"
date: 2025-01-15
lastMod: 2025-01-15
image: "/images/artigos/bcards-vs-linktree.jpg"
culture: "pt-BR"
---
# BCards vs LinkTree: Compare e Escolha a Melhor Alternativa Brasileira
Se você está procurando uma alternativa ao LinkTree, provavelmente já percebeu que existem diversas opções no mercado. Mas qual delas oferece o melhor custo-benefício para profissionais e empresas brasileiras?
Neste artigo, vamos fazer uma comparação honesta e detalhada entre **BCards** e **LinkTree**, analisando funcionalidades, preços, suporte e muito mais para ajudá-lo a tomar a melhor decisão.
## O Que São Plataformas de Bio Links?
Antes de mergulharmos na comparação, vamos entender o conceito. Plataformas como LinkTree e BCards permitem que você crie uma página única com múltiplos links, perfeita para compartilhar em suas redes sociais (especialmente Instagram, TikTok e Twitter, onde você tem espaço limitado para links).
Em vez de escolher apenas um link na sua bio, você direciona seus seguidores para uma página centralizada com todos os seus links importantes: site, blog, produtos, redes sociais, portfólio e muito mais.
## Visão Geral: BCards vs LinkTree
### LinkTree: O Pioneer Global
O LinkTree foi uma das primeiras plataformas de bio links a ganhar popularidade mundial. Fundado na Austrália, hoje é usado por milhões de criadores de conteúdo, influenciadores e empresas ao redor do mundo.
**Principais Características:**
- Interface simples e intuitiva
- Grande reconhecimento de marca internacional
- Diversas integrações com plataformas globais
- Suporte em inglês
### BCards: A Alternativa Brasileira
O BCards é uma plataforma brasileira desenvolvida especificamente para atender às necessidades do mercado nacional. Criada por profissionais que entendem os desafios locais, oferece funcionalidades pensadas para o público brasileiro.
**Principais Características:**
- Totalmente em português
- Suporte local e personalizado
- Preços em reais (sem variação cambial)
- URLs organizadas por categoria profissional
- Foco no mercado brasileiro e latino-americano
## Comparação Detalhada de Funcionalidades
### 1. Estrutura de URLs
**LinkTree:**
- Formato: `linktr.ee/seuusuario`
- URL genérica para todos os usuários
**BCards:**
- Formato: `bcards.site/page/{categoria}/{seu-slug}`
- Exemplos:
- `bcards.site/page/advocacia/maria-silva`
- `bcards.site/page/tecnologia/joao-dev`
- `bcards.site/page/saude/dra-ana-cardiologista`
**Vantagem BCards:** URLs hierárquicas melhoram significativamente o SEO e a credibilidade profissional. Quando alguém visita seu link, já sabe qual é sua área de atuação antes mesmo de abrir a página.
### 2. Personalização Visual
**LinkTree:**
- Plano gratuito: Temas básicos limitados
- Planos pagos: Mais opções de personalização
- Temas pré-definidos
**BCards:**
- Plano Básico: 5+ temas profissionais
- Plano Profissional: 10+ temas premium
- Plano Premium: Temas customizáveis + editor CSS
**Vantagem BCards:** Maior flexibilidade de personalização mesmo nos planos mais acessíveis, permitindo que sua página reflita sua identidade visual.
### 3. Quantidade de Links
**LinkTree:**
- Plano gratuito: Links ilimitados
- Destaque para 1 link por vez (feature paga)
**BCards:**
- Plano Básico (R$ 12,90): 5 links
- Plano Profissional (R$ 25,90): 15 links
- Plano Premium (R$ 29,90): Links ilimitados
**Empate:** LinkTree oferece links ilimitados gratuitamente, mas limita recursos de destaque. BCards oferece estrutura mais organizada com categorias, mas limita quantidade nos planos básicos.
### 4. Analytics e Métricas
**LinkTree:**
- Plano gratuito: Métricas básicas de cliques
- Planos pagos: Analytics avançado, integração com Google Analytics, rastreamento de conversões
**BCards:**
- Todos os planos: Contadores de cliques por link
- Dashboards com métricas de desempenho
- Relatórios de visitantes
**Empate:** Ambas as plataformas oferecem analytics suficientes para a maioria dos usuários. LinkTree tem vantagem em integrações avançadas, BCards oferece simplicidade e clareza nos dados.
### 5. Integrações
**LinkTree:**
- Integrações com diversas plataformas globais
- Facebook Pixel, Google Analytics, TikTok, Spotify, Apple Music
- Mais de 100 integrações
**BCards:**
- Integrações com principais plataformas brasileiras
- Google Analytics
- Redes sociais principais (Instagram, Facebook, WhatsApp)
- Foco em ferramentas relevantes para o mercado brasileiro
**Vantagem LinkTree:** Maior quantidade de integrações com plataformas internacionais, ideal para criadores de conteúdo global.
### 6. Sistema de Moderação e Qualidade
**LinkTree:**
- Moderação automatizada
- Políticas de uso global
**BCards:**
- Sistema de moderação humanizada
- Análise de conteúdo antes da ativação
- Garantia de qualidade das páginas públicas
**Vantagem BCards:** A moderação manual garante que todas as páginas ativas mantenham um padrão de qualidade, protegendo a reputação da plataforma e dos usuários.
## Comparação de Preços (Janeiro 2025)
### LinkTree
**Free (Gratuito):**
- Links ilimitados
- Temas básicos
- Métricas limitadas
- Branding LinkTree visível
**Starter (US$ 5/mês = ~R$ 25-30/mês*):**
- Remove branding
- Mais opções de temas
- Agendamento de links
- Analytics básico
**Pro (US$ 9/mês = ~R$ 45-55/mês*):**
- Analytics avançado
- Priorização de links
- Vídeos de fundo
- Integrações avançadas
**Premium (US$ 24/mês = ~R$ 120-145/mês*):**
- Todas as features Pro
- Suporte prioritário
- Features de e-commerce
- Mais integrações
*Valores aproximados sujeitos à variação cambial
### BCards
**Básico (R$ 12,90/mês):**
- 5 links organizados
- Temas básicos
- Analytics essenciais
- URL categorizada
- Suporte em português
**Profissional (R$ 25,90/mês):**
- 15 links organizados
- Todos os temas premium
- Analytics completo
- Suporte prioritário
**Premium (R$ 29,90/mês):**
- Links ilimitados
- Temas customizáveis
- Editor CSS avançado
- Analytics detalhado
- Suporte VIP
### Análise de Custo-Benefício
Para usuários brasileiros, o BCards oferece vantagens significativas:
1. **Sem variação cambial**: Preços fixos em reais
2. **Custo inicial menor**: R$ 12,90 vs ~R$ 25-30 (Starter do LinkTree)
3. **Plano Premium mais acessível**: R$ 29,90 vs ~R$ 120-145 (Premium do LinkTree)
4. **Suporte em português**: Sem barreira linguística
**Exemplo prático:**
Um advogado que precisa de uma página profissional com 10 links:
- **LinkTree Pro**: ~R$ 45-55/mês (R$ 540-660/ano)
- **BCards Profissional**: R$ 25,90/mês (R$ 310,80/ano)
- **Economia**: R$ 241-361/ano (44-55% de economia)
## Quando Escolher LinkTree?
O LinkTree pode ser a melhor opção se você:
1. **Precisa de visibilidade internacional**: Marca reconhecida globalmente
2. **Cria conteúdo em inglês**: Audiência internacional
3. **Necessita de integrações específicas**: Plataformas não populares no Brasil
4. **Quer começar gratuitamente**: Plano free com links ilimitados
5. **Trabalha com e-commerce global**: Integrações avançadas de vendas
## Quando Escolher BCards?
O BCards é ideal se você:
1. **Atua no mercado brasileiro**: Profissionais liberais, empresas locais
2. **Valoriza suporte em português**: Comunicação clara e rápida
3. **Busca melhor custo-benefício**: Preços competitivos em reais
4. **Quer URLs profissionais**: Estrutura categorizada melhora SEO
5. **Precisa de personalização**: Temas customizáveis por preço acessível
6. **Valoriza qualidade**: Sistema de moderação garante padrão elevado
## Casos de Uso Práticos
### Caso 1: Advogada Especializada em Direito de Família
**Escolha: BCards**
Maria Silva, advogada em São Paulo, escolheu BCards porque:
- URL profissional: `bcards.site/page/advocacia/maria-silva-direito-familia`
- Preço fixo em reais (sem surpresas)
- Suporte em português para ajustar sua página
- Sistema de moderação garante credibilidade profissional
**Resultado:** Aumento de 40% em consultas via página de bio nos primeiros 3 meses.
### Caso 2: Influenciador de Tecnologia Global
**Escolha: LinkTree**
João Tech, criador de conteúdo com audiência internacional, escolheu LinkTree porque:
- Marca reconhecida globalmente
- Integrações com Patreon, Ko-fi, e outras plataformas internacionais
- Audiência em múltiplos países
- Necessidade de features de e-commerce global
**Resultado:** Facilidade para monetização internacional e reconhecimento da marca LinkTree entre seguidores estrangeiros.
### Caso 3: Personal Trainer Local
**Escolha: BCards**
Ana Fitness, personal trainer em Belo Horizonte, escolheu BCards porque:
- Atende apenas clientes locais
- Precisava de página profissional sem custo alto
- URL categorizada: `bcards.site/page/saude/ana-personal-trainer-bh`
- Todos os clientes falam português
**Resultado:** Redução de 60% no custo mensal comparado ao LinkTree Pro, mantendo todas as funcionalidades necessárias.
## Fatores Técnicos: SEO e Performance
### SEO (Otimização para Motores de Busca)
**BCards vantagens:**
- URLs hierárquicas descritivas
- Estrutura de categorias melhora indexação
- Conteúdo em português nativo
- Menor concorrência em buscas locais
**LinkTree vantagens:**
- Autoridade de domínio global mais alta
- Maior reconhecimento de marca
- Backlinks naturais de usuários internacionais
### Performance e Velocidade
Ambas as plataformas oferecem:
- Carregamento rápido (< 2 segundos)
- Responsividade mobile
- Uptime confiável (99%+)
## Suporte ao Cliente
### LinkTree
- Suporte em inglês
- Base de conhecimento extensa
- Comunidade global ativa
- Suporte prioritário apenas em planos premium
### BCards
- Suporte em português
- Atendimento personalizado
- Suporte prioritário desde plano Profissional
- Compreensão do contexto local brasileiro
**Vantagem BCards:** Para usuários que não dominam inglês ou preferem suporte local, o BCards oferece experiência significativamente melhor.
## Segurança e Privacidade
### LinkTree
- Certificado SSL
- Conformidade com GDPR (Europa)
- Políticas de privacidade internacionais
### BCards
- Certificado SSL
- Políticas alinhadas com LGPD (Brasil)
- Dados hospedados no Brasil
**Vantagem BCards:** Para empresas que precisam estar em conformidade com LGPD, ter dados hospedados no Brasil pode ser uma vantagem regulatória.
## Limitações de Cada Plataforma
### LinkTree Limitações
- Custos em dólar (variação cambial)
- Suporte não é em português
- Alguns recursos avançados são muito caros
- Foco global pode não atender necessidades locais
### BCards Limitações
- Menor reconhecimento internacional
- Menos integrações com plataformas globais
- Comunidade menor (plataforma mais nova)
- Não tem plano gratuito com links ilimitados
## Tabela Comparativa Resumida
| Característica | LinkTree | BCards |
|---------------|----------|---------|
| **Preço inicial** | Gratuito (limitado) | R$ 12,90/mês |
| **Plano Pro** | ~R$ 45-55/mês | R$ 25,90/mês |
| **Plano Premium** | ~R$ 120-145/mês | R$ 29,90/mês |
| **Moeda** | Dólar (USD) | Real (BRL) |
| **Links ilimitados** | Grátis | R$ 29,90/mês |
| **Suporte** | Inglês | Português |
| **URL** | linktr.ee/usuario | bcards.site/page/categoria/usuario |
| **Moderação** | Automatizada | Humanizada |
| **Foco** | Global | Brasil/América Latina |
| **Integrações** | 100+ | Principais |
| **Personalização** | Boa (paga) | Excelente (todos planos) |
| **SEO** | Autoridade global | URLs categorizadas |
## Migração: Como Trocar do LinkTree para BCards
Se você já usa LinkTree e está considerando migrar para BCards, o processo é simples:
1. **Exporte seus links**: Copie títulos e URLs
2. **Crie conta no BCards**: Escolha sua categoria profissional
3. **Configure sua página**: Adicione links, escolha tema
4. **Submeta para moderação**: Aguarde aprovação (24-48h)
5. **Atualize suas redes sociais**: Troque o link da bio
**Dica:** Mantenha ambas as páginas ativas durante 1-2 semanas de transição para garantir que todos os seguidores vejam o novo link.
## Conclusão: Qual Escolher?
Não existe resposta única. A melhor escolha depende do seu contexto:
**Escolha LinkTree se:**
- Você tem audiência internacional significativa
- Cria conteúdo em inglês
- Necessita de integrações específicas globais
- Quer começar gratuitamente (com limitações)
- Reconhecimento de marca internacional é importante
**Escolha BCards se:**
- Você atua principalmente no Brasil
- Valoriza suporte em português
- Busca melhor custo-benefício
- Quer URL profissional categorizada
- Prefere moderação humanizada
- Deseja evitar variação cambial
Para a maioria dos **profissionais brasileiros, pequenas empresas e criadores de conteúdo local**, o **BCards oferece melhor custo-benefício, suporte mais personalizado e funcionalidades adequadas às necessidades do mercado nacional**.
Para **criadores de conteúdo global, influenciadores internacionais e empresas que atuam em múltiplos países**, o **LinkTree pode ser a escolha mais adequada** devido ao reconhecimento internacional da marca.
## Próximos Passos
Pronto para criar sua página de links profissional?
1. **Defina suas necessidades**: Quantos links? Qual seu público?
2. **Avalie seu orçamento**: Preço fixo ou variável?
3. **Considere suporte**: Português ou inglês?
4. **Teste a plataforma**: Crie uma página e veja qual interface prefere
5. **Decida e comece**: Ambas são boas opções, escolha a melhor para você
**Experimente BCards gratuitamente** - [Criar conta agora](https://bcards.site/)
---
**Última atualização:** Janeiro 2025
*Este artigo é baseado em informações públicas das plataformas e nossa análise independente. Os preços podem variar. Consulte os sites oficiais para informações atualizadas.*
*Disclaimer: Somos a equipe BCards, mas nos esforçamos para apresentar uma comparação honesta e imparcial. Apresentamos vantagens e limitações de ambas as plataformas para ajudá-lo a tomar a melhor decisão para seu caso específico.*

View File

@ -1,339 +0,0 @@
---
title: "Transformação Digital para Pequenos Negócios: Comece Hoje Mesmo"
description: "Guia prático sobre como pequenos negócios podem iniciar sua jornada de transformação digital sem grandes investimentos. Aprenda estratégias simples e eficazes."
keywords: "transformação digital, pequenos negócios, digitalização, presença online, marketing digital"
author: "Equipe BCards"
date: 2025-01-10
lastMod: 2025-01-10
image: "/images/artigos/transformacao-digital.jpg"
culture: "pt-BR"
---
# Transformação Digital para Pequenos Negócios: Comece Hoje Mesmo
A transformação digital não é mais um luxo reservado apenas para grandes empresas. Hoje, pequenos negócios podem (e devem) aproveitar as ferramentas digitais para crescer, alcançar novos clientes e competir no mercado moderno.
Neste artigo, você vai descobrir como iniciar sua jornada digital com investimento mínimo e resultados máximos.
## O Que É Transformação Digital?
Transformação digital é o processo de integrar tecnologias digitais em todas as áreas do seu negócio, mudando fundamentalmente como você opera e entrega valor aos clientes.
Para pequenos negócios, isso não significa gastar milhões em sistemas complexos. Significa:
- Ter presença online profissional
- Facilitar o contato com clientes
- Organizar informações de forma acessível
- Automatizar processos simples
- Usar dados para tomar decisões melhores
## Por Que Pequenos Negócios Precisam se Digitalizar?
### 1. Seus Clientes Estão Online
Mais de 70% dos brasileiros usam internet diariamente. Quando precisam de um produto ou serviço, a primeira ação é pesquisar online. Se você não está lá, está invisível.
### 2. Competitividade
Seus concorrentes já estão se digitalizando. Ficar de fora significa perder espaço no mercado.
### 3. Redução de Custos
Ferramentas digitais frequentemente custam menos que métodos tradicionais e alcançam mais pessoas.
### 4. Melhor Experiência do Cliente
Clientes valorizam conveniência: encontrar informações rapidamente, entrar em contato facilmente, agendar serviços online.
## Os 5 Pilares da Transformação Digital para Pequenos Negócios
### 1. Presença Online Profissional
**Problema:** Você não tem site, ou seu site está desatualizado.
**Solução Simples:**
- Crie uma página profissional de links (bio link)
- Organize todos seus canais em um único lugar
- Facilite o acesso a WhatsApp, Instagram, serviços
**Custo:** A partir de R$ 12,90/mês
**Resultado:** Clientes encontram suas informações facilmente, você parece mais profissional.
### 2. Relacionamento com Clientes
**Problema:** Dificuldade em manter contato constante com clientes.
**Solução Simples:**
- Use WhatsApp Business (gratuito)
- Organize contatos com etiquetas
- Configure mensagens automáticas
- Crie catálogo de produtos
**Custo:** Gratuito
**Resultado:** Comunicação mais profissional e organizada.
### 3. Divulgação Estratégica
**Problema:** Propaganda cara e ineficiente.
**Solução Simples:**
- Crie perfis profissionais em redes sociais
- Poste conteúdo relevante regularmente
- Use Instagram e Facebook Ads (começando com R$ 5/dia)
- Peça avaliações no Google Meu Negócio
**Custo:** Pode começar com R$ 0 (orgânico) ou R$ 150/mês (anúncios básicos)
**Resultado:** Mais visibilidade, novos clientes, crescimento constante.
### 4. Gestão e Organização
**Problema:** Controle manual de vendas, estoque, finanças.
**Solução Simples:**
- Use planilhas Google (gratuito)
- Adote um sistema de gestão simples (muitos têm versão gratuita)
- Organize documentos na nuvem (Google Drive/Dropbox)
**Custo:** Gratuito a R$ 30/mês
**Resultado:** Menos tempo perdido, mais controle, decisões baseadas em dados.
### 5. Pagamentos Digitais
**Problema:** Perder vendas por aceitar apenas dinheiro.
**Solução Simples:**
- Aceite Pix (gratuito)
- Use maquininha de cartão mobile
- Crie links de pagamento online
**Custo:** Taxas apenas sobre vendas (2-5%)
**Resultado:** Venda mais, facilite a vida dos clientes, profissionalize seu negócio.
## Passo a Passo: Comece Sua Transformação Digital Hoje
### Semana 1: Presença Online Básica
**Dia 1-2: Configure WhatsApp Business**
- Baixe o app WhatsApp Business
- Configure perfil profissional com horário de atendimento
- Crie mensagens automáticas
- Adicione catálogo de produtos/serviços
**Dia 3-4: Crie Perfis Profissionais**
- Instagram Business
- Facebook Page
- Google Meu Negócio
**Dia 5-7: Organize Seus Links**
- Crie página profissional de links (ex: BCards)
- Adicione todos os canais de contato
- Coloque o link na bio de todas as redes sociais
### Semana 2: Conteúdo e Engajamento
**Dias 8-10: Planeje Conteúdo**
- Defina 3 tipos de conteúdo para postar
- Exemplos: Dicas, bastidores, promoções
- Crie calendário simples (3 posts por semana)
**Dias 11-14: Comece a Postar**
- Publique primeiro conteúdo
- Responda todos os comentários
- Peça para amigos e clientes seguirem
### Semana 3: Organização Interna
**Dias 15-17: Digitalize Processos**
- Crie planilha de vendas no Google Sheets
- Liste todos os clientes em planilha de contatos
- Configure backup de fotos importantes na nuvem
**Dias 18-21: Configure Pagamentos**
- Cadastre Pix
- Avalie opções de maquininha mobile
- Crie links de pagamento
### Semana 4: Primeiras Campanhas
**Dias 22-25: Marketing Digital Básico**
- Poste sobre promoção especial
- Impulsione post no Instagram/Facebook (R$ 20)
- Monitore resultados
**Dias 26-30: Analise e Ajuste**
- Veja quais posts tiveram mais engajamento
- Identifique de onde vieram novos clientes
- Planeje próximo mês
## Ferramentas Essenciais (Maioria Gratuitas)
### Comunicação
- **WhatsApp Business**: Grátis
- **Google Workspace** (e-mail profissional): R$ 12/usuário/mês
### Organização
- **Google Drive**: 15GB grátis
- **Trello** (gestão de tarefas): Grátis
- **Google Sheets**: Grátis
### Presença Online
- **BCards** (página de links): A partir de R$ 12,90/mês
- **Instagram/Facebook**: Grátis
- **Google Meu Negócio**: Grátis
### Marketing
- **Canva** (design): Plano gratuito robusto
- **Meta Business Suite**: Grátis
- **Google Analytics**: Grátis
### Pagamentos
- **Pix**: Grátis
- **Mercado Pago**: Taxas sobre vendas
- **PagSeguro**: Taxas sobre vendas
**Investimento total inicial:** R$ 0 a R$ 50/mês
## Erros Comuns a Evitar
### 1. Tentar Fazer Tudo de Uma Vez
**Erro:** Criar 10 perfis, começar blog, loja virtual, tudo junto.
**Solução:** Comece com o básico, domine, depois expanda.
### 2. Não Ter Consistência
**Erro:** Postar muito uma semana, depois sumir por meses.
**Solução:** Defina frequência realista e mantenha.
### 3. Ignorar Clientes Online
**Erro:** Não responder comentários e mensagens rapidamente.
**Solução:** Defina horários para checar e responder (3x ao dia).
### 4. Não Mensurar Resultados
**Erro:** Investir sem saber o que funciona.
**Solução:** Acompanhe métricas básicas (seguidores, curtidas, vendas).
### 5. Copiar Concorrentes sem Personalidade
**Erro:** Ser uma cópia genérica.
**Solução:** Mostre sua personalidade, conte sua história única.
## Casos de Sucesso Reais
### Caso 1: Doceria da Dona Maria
**Antes:**
- Vendia apenas para vizinhos
- Divulgação boca a boca
- Faturamento: R$ 2.000/mês
**Ações:**
- Criou Instagram com fotos profissionais dos doces
- Configurou WhatsApp Business com catálogo
- Começou a aceitar encomendas por mensagem
- Criou página de links para facilitar contato
**Depois (6 meses):**
- Alcance em 3 bairros diferentes
- 1.200 seguidores no Instagram
- Faturamento: R$ 6.500/mês (+225%)
**Investimento:** R$ 30/mês (internet + ferramentas)
### Caso 2: Oficina do João
**Antes:**
- Clientes apenas por indicação
- Sem presença online
- Dificuldade em mostrar serviços
**Ações:**
- Criou Google Meu Negócio
- Pediu avaliações dos clientes satisfeitos
- Criou página de links com serviços e preços
- Postou fotos de antes/depois dos carros
**Depois (4 meses):**
- 5-7 novos clientes por mês via Google
- 4.8 estrelas no Google (15 avaliações)
- Aumento de 40% no faturamento
**Investimento:** R$ 0 (todas ferramentas gratuitas)
## Próximos Passos Após os Primeiros 30 Dias
### Mês 2-3: Consolidação
- Refine processos que funcionaram
- Descarte o que não deu resultado
- Aumente frequência de posts
- Comece a investir pequenas quantias em anúncios
### Mês 4-6: Expansão
- Considere criar site próprio
- Expanda para novas plataformas (TikTok, LinkedIn)
- Crie programa de fidelidade digital
- Automatize mais processos
### Mês 7-12: Maturidade
- Analise dados para decisões estratégicas
- Invista em ferramentas mais robustas
- Contrate especialistas para áreas específicas
- Expanda equipe digital se necessário
## Checklist da Transformação Digital
Use esta checklist para acompanhar seu progresso:
**Fundamentos (Primeiras 2 semanas):**
- [ ] WhatsApp Business configurado
- [ ] Instagram Business criado
- [ ] Facebook Page ativa
- [ ] Google Meu Negócio cadastrado
- [ ] Página de links profissional criada
- [ ] Todos os links atualizados nas redes sociais
**Conteúdo (Primeiro mês):**
- [ ] Calendário de conteúdo criado
- [ ] Pelo menos 12 posts publicados
- [ ] Todas as mensagens respondidas em até 24h
- [ ] Primeiras avaliações recebidas
**Organização (Primeiros 2 meses):**
- [ ] Planilha de vendas funcionando
- [ ] Backup de arquivos na nuvem
- [ ] Processos principais documentados
- [ ] Sistema de pagamento digital ativo
**Marketing (Primeiros 3 meses):**
- [ ] Pelo menos 1 campanha paga testada
- [ ] Análise de métricas semanalmente
- [ ] Estratégia de conteúdo refinada
- [ ] Base de clientes digitais crescendo
## Conclusão: O Momento É Agora
A transformação digital não é sobre tecnologia complicada ou investimentos massivos. É sobre adaptar seu negócio para o mundo moderno, onde clientes esperam:
- **Encontrar você facilmente online**
- **Entrar em contato rapidamente**
- **Ver seu trabalho/produtos**
- **Fazer negócio de forma conveniente**
Você não precisa fazer tudo perfeitamente desde o início. Precisa começar.
Cada pequeno passo digital é um passo na direção certa:
- Primeira postagem no Instagram
- Primeiro cliente que te encontrou online
- Primeira venda via mensagem
- Primeira avaliação positiva
Esses pequenos passos se acumulam. Em 6 meses, você olhará para trás e ficará surpreso com a transformação.
**Comece hoje. Seu futuro digital está a um clique de distância.**
---
**Sobre o BCards:** Ajudamos pequenos negócios e profissionais a terem presença online profissional de forma simples e acessível. Crie sua página de links agora mesmo.
[Começar minha transformação digital →](https://bcards.site/)

View File

@ -1,63 +0,0 @@
# Regras de Moderação e Diretrizes da Comunidade BCards
**Última atualização:** 31 de agosto de 2025
O BCards se dedica a manter uma comunidade segura, profissional e respeitosa. Todas as páginas e conteúdos criados em nossa plataforma devem seguir estas regras. O descumprimento pode levar à remoção do conteúdo, suspensão da página ou banimento da conta.
---
### 1. Conteúdo Estritamente Proibido
O seguinte conteúdo será removido imediatamente e pode resultar no banimento da conta sem aviso prévio:
- **Conteúdo Adulto e Sexual Explícito:**
- Nudez, pornografia, ou qualquer conteúdo sexualmente sugestivo.
- Serviços de acompanhantes, prostituição ou conteúdo relacionado.
- **Violência, Ódio e Discriminação:**
- Ameaças diretas ou indiretas de violência contra indivíduos ou grupos.
- Discurso de ódio baseado em raça, etnia, religião, nacionalidade, gênero, orientação sexual, deficiência ou qualquer outra característica protegida.
- Glorificação de violência, terrorismo ou organizações extremistas.
- **Atividades Ilegais:**
- Venda, promoção ou facilitação de drogas ilegais, substâncias controladas e armas de fogo.
- Promoção de qualquer atividade ilegal, como jogos de azar não regulamentados, esquemas de pirâmide ou fraude.
- **Spam, Phishing e Golpes:**
- Links que levam a sites maliciosos, phishing ou que tentam enganar os usuários para obter informações pessoais.
- Páginas criadas com o único propósito de gerar tráfego de spam ou manipular SEO.
- **Violação de Direitos Autorais e Propriedade Intelectual:**
- Uso não autorizado de marcas registradas, logotipos, ou material protegido por direitos autorais.
- Venda ou distribuição de conteúdo pirateado.
- **Informações Falsas e Desinformação Perigosa:**
- Disseminação de informações comprovadamente falsas que possam causar dano real (ex: desinformação médica perigosa, teorias da conspiração violentas).
- **Conteúdo que Explora ou Prejudica Menores:**
- Qualquer conteúdo que explore, sexualize ou coloque menores em risco. Denunciaremos tal conteúdo às autoridades competentes.
---
### 2. Conteúdo Permitido com Restrições
Este conteúdo pode ser permitido, mas está sujeito a uma análise mais rigorosa e deve cumprir todas as leis locais aplicáveis:
- **Bebidas Alcoólicas:** Permitido apenas se o criador da página tiver a licença apropriada para vender e se o conteúdo for direcionado a um público adulto, com as devidas restrições de idade.
- **Jogos e Apostas:** Permitido apenas em jurisdições onde tal atividade é legal e regulamentada. A página deve exibir claramente as licenças e avisos legais necessários.
- **Conteúdo Político:** Permitido, desde que não incite ódio, violência ou extremismo. Não é permitido o uso da plataforma para campanhas de desinformação.
- **Produtos ou Serviços Financeiros:** Deve cumprir todas as regulamentações financeiras locais e ser transparente sobre riscos.
---
### 3. Critérios de Aprovação e Boas Práticas
Para que sua página seja aprovada e tenha um bom desempenho, siga estas diretrizes:
- **Links Funcionais:** Todos os links devem estar funcionando e levar ao destino prometido.
- **Informações Claras e Verdadeiras:** A biografia, títulos e descrições devem representar com precisão o propósito da sua página.
- **Imagens Apropriadas:** As imagens de perfil e de fundo devem ser de alta qualidade e não violar nenhuma das regras acima.
- **Idioma:** O conteúdo principal deve estar em português ou espanhol, de acordo com o público-alvo.
- **Respeito às Leis:** O conteúdo da sua página deve respeitar todas as leis e regulamentações do Brasil e dos países onde você atua.
Nosso objetivo é capacitar criadores e profissionais. Estas regras nos ajudam a garantir que o BCards continue sendo uma plataforma confiável e valiosa para todos.

View File

@ -1,435 +0,0 @@
---
title: "BCards para Advogados: Guia Completo de Marketing Digital Ético"
description: "Como advogados podem usar o BCards de forma profissional e ética, respeitando as normas da OAB sobre publicidade jurídica digital."
keywords: "advogados, marketing jurídico, publicidade advocacia, OAB, marketing digital advogados"
author: "Equipe BCards"
date: 2025-01-14
lastMod: 2025-01-14
image: "/images/tutoriais/advogados-bcards.jpg"
culture: "pt-BR"
category: "advocacia"
---
# BCards para Advogados: Guia Completo de Marketing Digital Ético
O marketing digital se tornou essencial para advogados que desejam expandir sua base de clientes. No entanto, a advocacia possui regras específicas estabelecidas pela OAB sobre publicidade.
Neste guia, você aprenderá como usar o BCards de forma profissional, ética e em conformidade com o Código de Ética da OAB.
## Por Que Advogados Precisam de Presença Digital?
### Seus Clientes Pesquisam Online
Quando alguém precisa de um advogado:
1. Pesquisa no Google
2. Pede indicações em redes sociais
3. Verifica perfis e avaliações online
Se você não está online, está invisível para potenciais clientes.
### Credibilidade Profissional
Uma presença digital organizada transmite:
- Profissionalismo
- Confiabilidade
- Acessibilidade
- Modernidade
### Concorrência
Muitos advogados já estão online. Ficar de fora significa perder espaço no mercado.
## Regras da OAB Sobre Publicidade Digital
Antes de criar sua página, é fundamental conhecer as regras do **Provimento n° 205/2021** da OAB:
### ✅ Permitido:
- Informar sobre áreas de atuação
- Divulgar títulos e especializações
- Publicar conteúdo educativo
- Compartilhar experiência profissional
- Indicar formas de contato
### ❌ Proibido:
- Garantir resultados
- Captação de clientela (mercantilização)
- Publicidade agressiva ou sensacionalista
- Promessas enganosas
- Orçamento sem análise do caso
- Comparações depreciativas com colegas
**Princípio fundamental:** Discrição, sobriedade e informação precisa.
## Como Configurar Seu BCards Profissional
### 1. Informações Básicas
#### Nome da Página
Use seu nome profissional completo seguido de "Advogado(a)" ou sua especialização:
**Exemplos adequados:**
- Dra. Maria Silva - Advogada
- João Santos | Direito Empresarial
- Ana Costa - Advocacia Trabalhista
**Evite:**
- "O Melhor Advogado"
- "Ganhe Sua Causa Garantido"
- "Advogado Nota 10"
#### Slug (URL)
Crie uma URL profissional:
**Bons exemplos:**
- `bcards.site/page/advocacia/maria-silva-advogada`
- `bcards.site/page/advocacia/joao-santos-empresarial`
- `bcards.site/page/advocacia/dra-ana-costa`
#### Descrição
Seja claro, objetivo e profissional:
**Exemplo adequado:**
```
Advogada especializada em Direito de Família e Sucessões.
OAB/SP 123.456 | Mestre em Direito Civil pela USP.
Atendimento presencial e online.
```
**Evite:**
```
A melhor advogada! Ganho 99% dos casos!
Atendo urgências a qualquer hora!
```
### 2. Links Profissionais
#### Links Essenciais para Advogados
**1. WhatsApp Business**
```
Título: Agendar Consulta
URL: https://wa.me/5511999999999?text=Olá, gostaria de agendar uma consulta
```
**2. Email Profissional**
```
Título: Contato por Email
URL: mailto:contato@seuescritorio.com.br
```
**3. Site ou Blog**
```
Título: Nosso Site
URL: https://seuescritorio.com.br
```
**4. LinkedIn**
```
Título: Perfil Profissional
URL: https://linkedin.com/in/seu-perfil
```
**5. Instagram Profissional**
```
Título: Instagram Jurídico
URL: https://instagram.com/seu.escritorio
```
**6. Artigos Jurídicos**
```
Título: Artigos e Publicações
URL: Link para seu blog ou Medium
```
**7. Google Meu Negócio**
```
Título: Avaliações e Localização
URL: Link do Google Maps do escritório
```
**8. Agendamento Online** (se usar)
```
Título: Agendar Horário
URL: Link do Calendly ou sistema de agenda
```
### 3. Escolha do Tema
Para advogados, recomendamos temas sóbrios e profissionais:
- **Clássico**: Fundo branco, clean
- **Profissional**: Tons de azul marinho ou cinza
- **Minimalista**: Ultra clean e objetivo
**Evite:**
- Cores muito vibrantes
- Animações excessivas
- Designs infantis ou descontraídos demais
### 4. Foto de Perfil
Use uma foto profissional:
- Fundo neutro
- Vestimenta formal
- Boa iluminação
- Expressão profissional mas acessível
**Evite:**
- Fotos de festas
- Selfies casuais
- Fotos em ambientes inadequados
## Conteúdo para Redes Sociais (Integrando com BCards)
Seu BCards será o hub central. Nas redes sociais, compartilhe:
### Conteúdo Educativo Permitido
**1. Dicas Jurídicas Gerais**
```
"Você sabia que tem até 2 anos para reclamar de vícios aparentes
no imóvel comprado? Entenda seus direitos!"
```
**2. Explicações de Leis**
```
"A Nova Lei de Licitações trouxe mudanças importantes.
Resumo das principais alterações: [thread]"
```
**3. Mitos e Verdades**
```
"Mito ou Verdade: É preciso registrar união estável em cartório?
MITO. Entenda por quê..."
```
**4. Casos Anônimos (com aprendizado)**
```
"Caso recente (sem identificação): Cliente conseguiu reaver valor
pago indevidamente em plano de saúde. Como? [explica processo genérico]"
```
### O Que NUNCA Postar
❌ "Acabei de ganhar mais uma causa! Somos imbatíveis!"
❌ "100% de sucesso em processos trabalhistas!"
❌ "Seu processo pode valer R$ 50.000! Entre em contato!"
❌ "Melhor advogado da cidade! Comprovado!"
❌ Fotos de audiências ou fóruns sem autorização
## Estratégia de Marketing Digital Ético
### 1. Marketing de Conteúdo
Crie conteúdo valioso:
- Artigos em blog
- Posts educativos em redes
- E-books sobre temas específicos
- Vídeos explicativos (YouTube, Instagram)
**Exemplo de calendário mensal:**
- Semana 1: Post sobre mudança legislativa
- Semana 2: Dica prática sobre direitos
- Semana 3: Explicação de conceito jurídico
- Semana 4: Resposta a dúvida comum
### 2. SEO Local para Advogados
Otimize para buscas locais:
- Google Meu Negócio completo
- URL no BCards com sua cidade (se relevante)
- Conteúdo mencionando região de atuação
**Exemplo:**
```
Advogada especializada em Direito de Família em São Paulo.
Atendimento na Zona Sul e Centro.
```
### 3. Peça Avaliações (com Cuidado)
É permitido ter avaliações no Google Meu Negócio, desde que:
- Sejam espontâneas (não solicite diretamente)
- Não há oferecimento de benefícios por avaliação
- Não são fabricadas
**Forma adequada:**
Após finalizar caso bem-sucedido, você pode:
```
"Ficamos felizes em ajudá-lo(a). Caso queira, sua opinião
sobre nosso atendimento é muito valiosa."
```
### 4. Networking Digital
Conecte-se com:
- Outros advogados (não concorrentes diretos)
- Profissionais complementares (contadores, corretores)
- Associações e entidades de classe
## Estrutura Completa: Exemplo Real
### Dra. Ana Costa - Direito Trabalhista
**URL BCards:**
`bcards.site/page/advocacia/dra-ana-costa-trabalhista`
**Descrição:**
```
Advogada Trabalhista | OAB/SP 234.567
Especialista em Direitos do Trabalhador
Mestre em Direito do Trabalho pela PUC-SP
Atendimento em São Paulo e online
```
**Links na página:**
1. 📱 WhatsApp: Agendar Consulta
2. 📧 Email: contato@anacosta.adv.br
3. 🌐 Site: www.anacosta.adv.br
4. 💼 LinkedIn: Perfil Profissional
5. 📸 Instagram: @dra.anacosta.trabalhista
6. 📄 Blog: Artigos sobre Direito Trabalhista
7. 📍 Localização: Escritório no Google Maps
8. 🕒 Agendar: Sistema de agendamento online
**Bio nas redes sociais:**
```
Dra. Ana Costa | Advogada Trabalhista 👩‍⚖️
OAB/SP 234.567
Defendendo direitos dos trabalhadores
📍 São Paulo | Atendimento presencial e online
🔗 Todos os contatos: [link BCards]
```
## Ferramentas Complementares
### 1. Gestão de Processos
- Projuris (jurídico)
- Astrea (gestão processual)
### 2. Agendamento
- Calendly
- Google Calendar
### 3. Comunicação
- WhatsApp Business
- Email profissional (Gmail Workspace)
### 4. Conteúdo
- Canva (design)
- Grammarly (revisão de texto)
### 5. Analytics
- Google Analytics (site)
- Meta Business Suite (redes sociais)
- BCards Analytics (cliques nos links)
## Monitoramento e Métricas
Acompanhe mensalmente:
**No BCards:**
- Número de visitantes
- Cliques em cada link
- Horários de maior acesso
**Nas Redes Sociais:**
- Crescimento de seguidores
- Engajamento (curtidas, comentários, compartilhamentos)
- Alcance de posts
**No Negócio:**
- Novos clientes vindos do online
- Taxa de conversão (visita → consulta)
- ROI (retorno sobre investimento em ads)
## Riscos e Como Evitá-los
### Risco 1: Infração Ética
**Como evitar:**
- Revise tudo antes de publicar
- Não garanta resultados
- Seja discreto e profissional
### Risco 2: Exposição Inadequada
**Como evitar:**
- Nunca divulgue detalhes de casos reais identificáveis
- Proteja o sigilo profissional sempre
- Peça autorização antes de mencionar qualquer caso
### Risco 3: Comentários Negativos
**Como evitar:**
- Responda sempre com profissionalismo
- Não discuta casos públicos
- Se necessário, leve discussão para privado
### Risco 4: Concorrência Desleal
**Como evitar:**
- Nunca critique colegas
- Foque em seu diferencial, não em defeitos alheios
- Colabore, não compita de forma negativa
## Checklist de Conformidade OAB
Antes de publicar sua página, verifique:
- [ ] Informações são verídicas?
- [ ] Títulos e especializações estão corretos?
- [ ] Não há promessas de resultado?
- [ ] Linguagem é sóbria e profissional?
- [ ] Não há captação mercantil de clientela?
- [ ] Fotos são profissionais?
- [ ] Links levam a conteúdo apropriado?
- [ ] OAB e número de inscrição estão visíveis?
- [ ] Especialização é reconhecida pela OAB (se mencionar)?
## Dúvidas Frequentes de Advogados
**P: Posso oferecer primeira consulta gratuita?**
R: Sim, desde que seja informação verdadeira e não caracterize captação irregular.
**P: Posso mencionar clientes famosos que atendi?**
R: Não, a menos que tenha autorização expressa e por escrito do cliente.
**P: Posso colocar preços no BCards?**
R: Não é recomendado. Orçamentos devem ser feitos após análise do caso específico.
**P: Posso fazer anúncios pagos no Google/Instagram?**
R: Sim, desde que o conteúdo respeite as normas éticas da OAB.
**P: Preciso colocar minha OAB no BCards?**
R: Sim, é obrigatório identificar número e seccional da OAB.
## Conclusão
O BCards é uma ferramenta poderosa para advogados modernizarem sua presença digital mantendo a ética profissional.
**Principais vantagens para advogados:**
- ✅ URL profissional e categorizada
- ✅ Centralização de todos os contatos
- ✅ Facilita marketing de conteúdo ético
- ✅ Transmite credibilidade
- ✅ Compatível com normas da OAB
- ✅ Analytics para medir resultados
**Lembre-se:**
- Priorize discrição e sobriedade
- Foque em educar, não em vender
- Seja transparente e honesto
- Respeite sempre as normas da OAB
Sua presença digital pode ser profissional, eficiente e ética ao mesmo tempo.
**Pronto para criar sua página profissional?**
[Criar meu BCards para Advocacia →](https://bcards.site/)
---
**Referências:**
- Provimento OAB n° 205/2021
- Código de Ética e Disciplina da OAB
**Disclaimer:** Este artigo tem caráter informativo. Para dúvidas específicas sobre ética profissional, consulte a OAB de sua seccional.
**Última atualização:** Janeiro 2025

View File

@ -1,359 +0,0 @@
---
title: "Como Criar seu BCard em 5 Minutos: Tutorial Completo"
description: "Aprenda passo a passo como criar sua página profissional de links no BCards. Tutorial completo para iniciantes com capturas de tela e dicas práticas."
keywords: "tutorial bcards, criar página de links, bio link, tutorial passo a passo"
author: "Equipe BCards"
date: 2025-01-12
lastMod: 2025-01-12
image: "/images/tutoriais/criar-bcard.jpg"
culture: "pt-BR"
category: "tecnologia"
---
# Como Criar seu BCard em 5 Minutos: Tutorial Completo
Criar sua página profissional de links no BCards é mais fácil do que você imagina. Neste tutorial, vou te guiar passo a passo desde o cadastro até a publicação da sua página.
## O Que Você Vai Precisar
- Email válido
- Conta no Google ou Microsoft (para login rápido)
- 5 minutos do seu tempo
- Links que você quer compartilhar
## Passo 1: Criar Sua Conta
### 1.1. Acesse o Site
Vá para [bcards.site](https://bcards.site) e clique em **"Entrar"** no menu superior.
### 1.2. Escolha o Método de Login
Você tem duas opções:
**Opção A: Login com Google**
- Clique em "Entrar com Google"
- Selecione sua conta
- Autorize o acesso
**Opção B: Login com Microsoft**
- Clique em "Entrar com Microsoft"
- Insira suas credenciais
- Autorize o acesso
**Dica:** Usar login social é mais rápido e seguro (sem necessidade de criar nova senha).
## Passo 2: Acessar o Dashboard
Após fazer login, você será redirecionado para seu **Dashboard**. Este é o painel de controle onde você gerencia sua página.
No Dashboard você verá:
- Botão "Criar Minha Página" (se é sua primeira vez)
- Estatísticas (após criar a página)
- Links para editar sua página
## Passo 3: Criar Sua Página
### 3.1. Clique em "Criar Minha Página"
Você verá um formulário com vários campos. Vamos preencher cada um:
### 3.2. Informações Básicas
**Nome da Página:**
- Digite como você quer ser identificado
- Exemplo: "João Silva", "Maria Design", "Advocacia Santos"
- Este será o título principal da sua página
**Categoria:**
- Selecione a categoria que melhor representa sua atividade
- Exemplos disponíveis:
- Tecnologia
- Advocacia
- Saúde
- Educação
- Marketing
- E muitas outras...
**Dica:** A categoria fará parte da sua URL e ajuda no SEO.
**Slug (URL Personalizada):**
- Este será o final da sua URL
- Formato final: `bcards.site/page/{categoria}/{seu-slug}`
- Use apenas letras minúsculas, números e hifens
- Sem espaços, acentos ou caracteres especiais
**Exemplos:**
- `joao-silva-dev` → bcards.site/page/tecnologia/joao-silva-dev
- `maria-designer` → bcards.site/page/design/maria-designer
- `dra-ana-pediatra` → bcards.site/page/saude/dra-ana-pediatra
### 3.3. Descrição
Digite uma breve descrição sobre você ou seu negócio:
**Bom exemplo:**
```
Desenvolvedora Full Stack especializada em React e Node.js.
Ajudo empresas a criarem aplicações web modernas e escaláveis.
```
**Evite:**
```
Desenvolvedora
```
(muito curto, sem contexto)
**Dica:** Use 2-3 frases. Seja claro e direto.
### 3.4. Escolha do Tema Visual
Role até a seção "Tema" e selecione o visual da sua página:
**Temas disponíveis (variam por plano):**
- **Clássico**: Fundo branco limpo, ideal para profissionais
- **Dark**: Fundo escuro, moderno
- **Gradiente**: Cores vibrantes em degradê
- **Minimalista**: Ultra clean
- **Profissional**: Sóbrio e corporativo
Você pode trocar o tema depois a qualquer momento.
### 3.5. Adicionar Links
Agora vamos adicionar os links que aparecerão na sua página.
**Para adicionar um link:**
1. Clique em **"Adicionar Link"**
2. Preencha os campos:
- **Título**: Nome que aparecerá no botão
- **URL**: Endereço completo (começando com https://)
- **Ícone** (opcional): Escolha um ícone para o link
**Exemplo de Link:**
```
Título: Meu Portfólio
URL: https://meusite.com.br
Ícone: fa-briefcase
```
**Tipos de Links Comuns:**
- WhatsApp: `https://wa.me/5511999999999`
- Instagram: `https://instagram.com/seuusuario`
- Facebook: `https://facebook.com/suapagina`
- LinkedIn: `https://linkedin.com/in/seuusuario`
- YouTube: `https://youtube.com/@seucanal`
- Site: `https://seusite.com.br`
- Email: `mailto:seu@email.com`
**Dica:** Organize links por importância. Os primeiros aparecem no topo da página.
### 3.6. Reordenar Links
Você pode arrastar e soltar os links para mudar a ordem de exibição. Os links mais importantes devem ficar no topo.
## Passo 4: Personalização Avançada (Opcional)
### 4.1. Foto de Perfil
Upload de uma foto profissional (recomendado):
- Tamanho ideal: 400x400 pixels
- Formato: JPG ou PNG
- Peso: Até 2MB
### 4.2. Cores Personalizadas (Plano Premium)
Se você tem plano Premium, pode personalizar:
- Cor dos botões
- Cor do fundo
- Cor do texto
- Fontes
### 4.3. Redes Sociais
Adicione seus perfis sociais na seção específica. Eles aparecerão como ícones na sua página.
## Passo 5: Salvar e Submeter para Moderação
### 5.1. Revisar Tudo
Antes de salvar, revise:
- ✅ Nome está correto?
- ✅ URL (slug) está como você quer?
- ✅ Todos os links funcionam?
- ✅ Descrição está clara?
- ✅ Tema escolhido te agrada?
### 5.2. Salvar
Clique no botão **"Salvar Página"** no final do formulário.
### 5.3. Submeter para Moderação
Após salvar, você verá sua página em modo de **preview** (visualização).
Para torná-la pública, clique em **"Submeter para Moderação"**.
**O Que Acontece Agora?**
1. Nossa equipe revisa sua página (24-48 horas)
2. Verificamos se segue as diretrizes da comunidade
3. Você recebe email quando for aprovada
4. Sua página fica pública!
## Passo 6: Visualizar e Compartilhar
### 6.1. Token de Preview
Enquanto aguarda aprovação, você pode visualizar sua página usando o **token de preview**:
1. No Dashboard, clique em "Gerar Token de Preview"
2. Copie o link gerado
3. Abra em uma aba privada ou compartilhe com amigos
**Exemplo de link de preview:**
```
bcards.site/page/tecnologia/joao-dev?preview=ABC123XYZ
```
### 6.2. Após Aprovação
Quando sua página for aprovada:
- Ela estará acessível publicamente
- URL final: `bcards.site/page/{categoria}/{seu-slug}`
- Você pode compartilhar em suas redes sociais!
## Passo 7: Atualizar Bio das Redes Sociais
Com sua página aprovada, atualize a bio de todas as suas redes:
### Instagram
1. Vá em "Editar Perfil"
2. Cole seu link BCards no campo "Website"
3. Salve
### TikTok
1. Editar Perfil
2. Cole o link em "Bio"
3. Salve
### Twitter/X
1. Editar Perfil
2. Cole o link em "Website"
3. Salve
### LinkedIn
1. Editar Perfil
2. Cole na seção "Informações de Contato"
3. Salve
## Dicas de Boas Práticas
### ✅ Faça:
- Use foto profissional nítida
- Escreva descrição clara e objetiva
- Teste todos os links antes de publicar
- Mantenha a página atualizada
- Responda mensagens rapidamente
- Use call-to-action nos títulos dos links
### ❌ Evite:
- Links quebrados ou incorretos
- Descrição muito longa ou muito curta
- Excesso de links (foco no essencial)
- Informações enganosas
- Conteúdo que viola diretrizes
## Atualizando Sua Página Depois
Você pode editar sua página a qualquer momento:
1. Acesse o Dashboard
2. Clique em "Editar Página"
3. Faça as alterações
4. Salve
**Importante:** Mudanças significativas podem exigir nova moderação.
## Planos e Upgrades
### Plano Básico (R$ 12,90/mês)
- 5 links
- Temas básicos
- Analytics essenciais
### Plano Profissional (R$ 25,90/mês)
- 15 links
- Todos os temas
- Analytics completo
- Suporte prioritário
### Plano Premium (R$ 29,90/mês)
- Links ilimitados
- Customização total
- Temas exclusivos
- Editor CSS
- Suporte VIP
Para fazer upgrade:
1. Dashboard → "Meu Plano"
2. Escolha o plano desejado
3. Preencha dados de pagamento
4. Confirme
## Analisando Resultados
Após sua página estar ativa, acompanhe as métricas no Dashboard:
- **Total de visitantes**: Quantas pessoas acessaram
- **Cliques por link**: Quais links são mais populares
- **Origem do tráfico**: De onde vieram os visitantes
Use esses dados para otimizar sua página!
## Problemas Comuns e Soluções
### "Meu slug já está em uso"
**Solução:** Escolha outro slug. Tente adicionar seu nome, cidade ou especialidade.
### "Link não funciona"
**Solução:** Verifique se a URL começa com `https://` e está digitada corretamente.
### "Página foi rejeitada na moderação"
**Solução:** Leia o email com o motivo da rejeição, ajuste conforme orientações e resubmeta.
### "Não recebi email de aprovação"
**Solução:** Verifique spam/lixo eletrônico ou entre em contato com suporte.
## Suporte
Precisa de ajuda?
- **Email**: suporte@bcards.site
- **Horário**: Segunda a sexta, 9h às 18h
- **FAQ**: bcards.site/ajuda
- **WhatsApp**: (Link no site)
## Conclusão
Parabéns! 🎉 Agora você sabe criar sua página profissional no BCards do zero.
**Recapitulando:**
1. ✅ Criar conta
2. ✅ Preencher informações
3. ✅ Escolher tema
4. ✅ Adicionar links
5. ✅ Salvar e submeter
6. ✅ Aguardar aprovação
7. ✅ Compartilhar!
Sua presença online profissional está a apenas alguns cliques de distância.
**Pronto para começar?** [Criar meu BCard agora →](https://bcards.site/)
---
**Tempo de leitura:** 8 minutos
**Dificuldade:** Iniciante
**Última atualização:** Janeiro 2025

View File

@ -5,13 +5,7 @@ using BCards.Web.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Configuration;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Linq;
using MongoDB.Bson;
using System.Collections.Generic;
namespace BCards.Web.Controllers;
@ -27,11 +21,8 @@ public class AdminController : Controller
private readonly IEmailService _emailService;
private readonly ILivePageService _livePageService;
private readonly IImageStorageService _imageStorage;
private readonly IDocumentStorageService _documentStorage;
private readonly IPaymentService _paymentService;
private readonly IDowngradeService _downgradeService;
private readonly ILogger<AdminController> _logger;
private readonly IConfiguration _configuration;
public AdminController(
IAuthService authService,
@ -42,11 +33,8 @@ public class AdminController : Controller
IEmailService emailService,
ILivePageService livePageService,
IImageStorageService imageStorage,
IDocumentStorageService documentStorage,
IPaymentService paymentService,
IDowngradeService downgradeService,
ILogger<AdminController> logger,
IConfiguration configuration)
ILogger<AdminController> logger)
{
_authService = authService;
_userPageService = userPageService;
@ -56,11 +44,8 @@ public class AdminController : Controller
_emailService = emailService;
_livePageService = livePageService;
_imageStorage = imageStorage;
_documentStorage = documentStorage;
_paymentService = paymentService;
_downgradeService = downgradeService;
_logger = logger;
_configuration = configuration;
}
@ -74,13 +59,7 @@ public class AdminController : Controller
if (user == null)
return RedirectToAction("Login", "Auth");
_logger.LogInformation("[DASHBOARD DEBUG] User {UserId} ({Email}) - CurrentPlan: '{CurrentPlan}'",
user.Id, user.Email, user.CurrentPlan ?? "NULL");
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
_logger.LogInformation("[DASHBOARD DEBUG] Parsed PlanType: {PlanType} (from '{CurrentPlan}')",
userPlanType, user.CurrentPlan ?? "NULL");
var userPages = await _userPageService.GetUserPagesAsync(user.Id);
var listCounts = new Dictionary<string, dynamic>();
@ -151,8 +130,6 @@ public class AdminController : Controller
[HttpGet]
[Route("ManagePage")]
public async Task<IActionResult> ManagePage(string id = null)
{
try
{
ViewBag.IsHomePage = false;
@ -160,7 +137,6 @@ public class AdminController : Controller
if (user == null)
return RedirectToAction("Login", "Auth");
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var categories = await _categoryService.GetAllCategoriesAsync();
var themes = await _themeService.GetAvailableThemesAsync();
@ -185,11 +161,7 @@ public class AdminController : Controller
AvailableCategories = categories,
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(),
AllowProductLinks = planLimitations.AllowProductLinks,
AllowDocumentUpload = planLimitations.AllowDocumentUpload,
MaxDocumentsAllowed = planLimitations.MaxDocuments,
Documents = new List<ManageDocumentViewModel>(),
DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay()
AllowProductLinks = planLimitations.AllowProductLinks
};
return View(model);
}
@ -204,22 +176,10 @@ public class AdminController : Controller
return View(model);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ManagePage GET");
TempData["Error"] = "Ocorreu um erro ao carregar a página. Tente novamente.";
throw new Exception("Erro ao salvar o bcard", ex);
}
}
[HttpPost]
[Route("ManagePage")]
[RequestSizeLimit(5 * 1024 * 1024)] // Allow 5MB uploads
[RequestFormLimits(MultipartBodyLengthLimit = 5 * 1024 * 1024)]
public async Task<IActionResult> ManagePage(ManagePageViewModel model)
{
string userId = "";
try
{
ViewBag.IsHomePage = false;
@ -227,22 +187,15 @@ public class AdminController : Controller
if (user == null)
return RedirectToAction("Login", "Auth");
userId = user.Id;
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var parsedPlan) ? parsedPlan : PlanType.Trial;
var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString());
model.AllowProductLinks = planLimitations.AllowProductLinks;
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
// Limpar campos de redes sociais que são apenas espaços (tratados como vazios)
CleanSocialMediaFields(model);
AdjustModelState(ModelState, model);
_logger.LogInformation($"ManagePage POST: IsNewPage={model.IsNewPage}, DisplayName={model.DisplayName}, Category={model.Category}, Links={model.Links?.Count ?? 0}");
//Logar modelstate em information
_logger.LogInformation($"ModelState: {JsonSerializer.Serialize(ModelState)}");
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)
@ -264,83 +217,34 @@ public class AdminController : Controller
}
catch (Exception ex)
{
_logger.LogError(ex, "Userid: {UserId} - Error uploading profile image. FileName: {FileName}, ContentType: {ContentType}, Size: {Size}KB, ExceptionType: {ExceptionType}",
userId, model.ProfileImageFile?.FileName ?? "Unknown", model.ProfileImageFile?.ContentType ?? "Unknown",
model.ProfileImageFile?.Length / 1024 ?? 0, ex.GetType().Name);
_logger.LogError(ex, "Error uploading profile image");
ModelState.AddModelError("ProfileImageFile", "Erro ao fazer upload da imagem. Tente novamente.");
// Mensagem específica baseada no tipo de erro
var errorMessage = ex is ArgumentException argEx ? argEx.Message : "Erro ao processar a imagem. Verifique o formato e tamanho.";
ModelState.AddModelError("ProfileImageFile", errorMessage);
TempData["ImageError"] = errorMessage;
// Preservar dados do form e repopular dropdowns
// Repopulate dropdowns
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.MaxLinksAllowed = userPlanType.GetMaxLinksPerPage();
model.AllowProductLinks = planLimitations.AllowProductLinks;
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
// Preservar ProfileImageId existente se estava editando
if (!model.IsNewPage && !string.IsNullOrEmpty(model.Id))
{
var existingPage = await _userPageService.GetPageByIdAsync(model.Id);
if (existingPage != null)
{
model.ProfileImageId = existingPage.ProfileImageId;
}
}
return View(model);
}
}
var (processedDocuments, removedFileIds, newFileIds) = await BuildDocumentsAsync(model, planLimitations);
if (!ModelState.IsValid)
{
var sbError = new StringBuilder();
sbError.AppendLine("ModelState is invalid!");
_logger.LogWarning("ModelState is invalid:");
foreach (var error in ModelState)
{
var erroMsg = string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage));
if (!string.IsNullOrEmpty(erroMsg))
{
sbError.AppendLine($"Key: {error.Key}, Errors: {erroMsg}");
_logger.LogWarning($"Key: {error.Key}, Errors: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}");
}
}
_logger.LogWarning(sbError.ToString());
// Repopulate dropdowns
var slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName);
model.Slug = slug;
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.AllowProductLinks = planLimitations.AllowProductLinks;
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model);
}
if (model.IsNewPage)
{
// CRITICAL: Check if user can create new page (validate MaxPages limit)
var existingPages = await _userPageService.GetUserPagesAsync(user.Id);
var maxPages = userPlanType.GetMaxPages();
if (existingPages.Count >= maxPages)
{
TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas.";
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model);
}
// Generate slug if not provided
if (string.IsNullOrEmpty(model.Slug))
{
@ -353,7 +257,6 @@ public class AdminController : Controller
ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model);
}
@ -364,7 +267,6 @@ public class AdminController : Controller
ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model);
}
@ -372,7 +274,6 @@ public class AdminController : Controller
{
// Create new page
var userPage = await MapToUserPage(model, user.Id);
userPage.Documents = processedDocuments;
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
// Set status to Creating for new pages
@ -381,17 +282,21 @@ public class AdminController : Controller
await _userPageService.CreatePageAsync(userPage);
_logger.LogInformation("Page created successfully!");
// Token será gerado apenas quando usuário clicar "Testar Página"
// 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! Use o botão 'Enviar para Moderação' quando estiver pronta.";
}
catch (Exception ex)
{
_logger.LogError(ex, $"Userid: {userId} - Error creating page");
_logger.LogError(ex, "Error creating page");
ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}";
return View(model);
}
@ -401,15 +306,7 @@ public class AdminController : Controller
// Update existing page
var existingPage = await _userPageService.GetPageByIdAsync(model.Id);
if (existingPage == null || existingPage.UserId != user.Id)
{
await CleanupNewDocumentsAsync(newFileIds);
return NotFound();
}
if (!planLimitations.AllowDocumentUpload && existingPage.Documents?.Any() == true)
{
removedFileIds.AddRange(existingPage.Documents.Select(d => d.FileId));
}
// Check if user can create pages (for users with rejected pages)
var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id);
@ -419,23 +316,13 @@ public class AdminController : Controller
return RedirectToAction("Dashboard");
}
// IMPORTANTE: Tratar remoção de imagem ou preservar existente se não houver novo upload
// IMPORTANTE: Preservar ProfileImageId da página existente se não houver novo upload
if (model.ProfileImageFile == null || model.ProfileImageFile.Length == 0)
{
if (model.ProfileImageId == "REMOVE_IMAGE")
{
// Usuário quer remover a imagem existente
model.ProfileImageId = null;
_logger.LogInformation("Profile image removed by user request");
}
else
{
// Preservar imagem existente
model.ProfileImageId = existingPage.ProfileImageId;
}
}
await UpdateUserPageFromModel(existingPage, model, processedDocuments);
await UpdateUserPageFromModel(existingPage, model);
// Set status to PendingModeration for updates
existingPage.Status = ViewModels.PageStatus.Creating;
@ -443,22 +330,9 @@ public class AdminController : Controller
await _userPageService.UpdatePageAsync(existingPage);
if (removedFileIds.Count > 0)
{
foreach (var fileId in removedFileIds)
{
try
{
await _documentStorage.DeleteDocumentAsync(fileId);
}
catch (Exception cleanupEx)
{
_logger.LogWarning(cleanupEx, "Erro ao remover documento antigo {FileId}", fileId);
}
}
}
// Token será gerado apenas quando usuário clicar "Testar Página"
// 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(
@ -467,20 +341,13 @@ public class AdminController : Controller
existingPage.DisplayName,
"pending",
null,
null); // previewUrl não é mais necessário - token será gerado no clique
previewUrl);
TempData["Success"] = "Página atualizada! Teste e envie para moderação.";
}
return RedirectToAction("Dashboard");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Userid: {userId} - Error in ManagePage GET");
TempData["Error"] = "Ocorreu um erro ao carregar a página. Tente novamente.";
throw new Exception("Erro ao salvar o bcard", ex);
}
}
[HttpPost]
[Route("CreatePage")]
@ -604,54 +471,6 @@ public class AdminController : Controller
});
}
if (!string.IsNullOrEmpty(model.TiktokUrl))
{
socialLinks.Add(new LinkItem
{
Title = "TikTok",
Url = model.TiktokUrl,
Icon = "fab fa-tiktok",
IsActive = true,
Order = userPage.Links.Count + socialLinks.Count
});
}
if (!string.IsNullOrEmpty(model.PinterestUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Pinterest",
Url = model.PinterestUrl,
Icon = "fab fa-pinterest",
IsActive = true,
Order = userPage.Links.Count + socialLinks.Count
});
}
if (!string.IsNullOrEmpty(model.DiscordUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Discord",
Url = model.DiscordUrl,
Icon = "fab fa-discord",
IsActive = true,
Order = userPage.Links.Count + socialLinks.Count
});
}
if (!string.IsNullOrEmpty(model.KawaiUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Kawai",
Url = model.KawaiUrl,
Icon = "fas fa-heart",
IsActive = true,
Order = userPage.Links.Count + socialLinks.Count
});
}
userPage.Links.AddRange(socialLinks);
await _userPageService.CreatePageAsync(userPage);
@ -798,8 +617,6 @@ public class AdminController : Controller
private async Task<ManagePageViewModel> MapToManageViewModel(UserPage page, List<Category> categories, List<PageTheme> themes, PlanType userPlanType)
{
var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString());
return new ManagePageViewModel
{
Id = page.Id,
@ -827,23 +644,10 @@ public class AdminController : Controller
ProductDescription = l.ProductDescription,
ProductDataCachedAt = l.ProductDataCachedAt
}).ToList() ?? new List<ManageLinkViewModel>(),
Documents = page.Documents?.Select((d, index) => new ManageDocumentViewModel
{
Id = string.IsNullOrEmpty(d.Id) ? $"doc_{index}" : d.Id,
DocumentId = d.FileId,
Title = d.Title,
Description = d.Description,
FileName = d.FileName,
FileSize = d.FileSize,
UploadedAt = d.UploadedAt
}).ToList() ?? new List<ManageDocumentViewModel>(),
AvailableCategories = categories,
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(),
AllowProductLinks = planLimitations.AllowProductLinks,
AllowDocumentUpload = planLimitations.AllowDocumentUpload,
MaxDocumentsAllowed = planLimitations.MaxDocuments,
DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay()
AllowProductLinks = (await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString())).AllowProductLinks
};
}
@ -938,250 +742,11 @@ public class AdminController : Controller
});
}
if (!string.IsNullOrEmpty(model.TiktokUrl))
{
socialLinks.Add(new LinkItem
{
Title = "TikTok",
Url = model.TiktokUrl,
Icon = "fab fa-tiktok",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.PinterestUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Pinterest",
Url = model.PinterestUrl,
Icon = "fab fa-pinterest",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.DiscordUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Discord",
Url = model.DiscordUrl,
Icon = "fab fa-discord",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.KawaiUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Kawai",
Url = model.KawaiUrl,
Icon = "fas fa-heart",
IsActive = true,
Order = currentOrder++
});
}
userPage.Links.AddRange(socialLinks);
return userPage;
}
private string GetDocumentUploadPlansDisplay()
{
var sections = _configuration.GetSection("Plans").GetChildren();
var friendlyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var section in sections)
{
if (!bool.TryParse(section["AllowDocumentUpload"], out var allow) || !allow)
continue;
var key = section.Key ?? string.Empty;
var normalizedKey = key.EndsWith("Yearly", StringComparison.OrdinalIgnoreCase)
? key[..^6]
: key;
if (Enum.TryParse<PlanType>(normalizedKey, true, out var planType))
{
friendlyNames.Add(planType.GetDisplayName());
}
else
{
var display = section["Name"] ?? key;
if (!string.IsNullOrWhiteSpace(display))
{
friendlyNames.Add(display);
}
}
}
if (friendlyNames.Count == 0)
return "planos com suporte a documentos";
var orderedNames = friendlyNames.OrderBy(name => name).ToList();
return orderedNames.Count switch
{
1 => orderedNames[0],
2 => string.Join(" e ", orderedNames),
_ => $"{string.Join(", ", orderedNames.Take(orderedNames.Count - 1))} e {orderedNames.Last()}"
};
}
private async Task<(List<PageDocument> Documents, List<string> RemovedFileIds, List<string> NewlyUploadedFileIds)> BuildDocumentsAsync(ManagePageViewModel model, PlanLimitations planLimitations)
{
var documents = new List<PageDocument>();
var removedFileIds = new List<string>();
var newFileIds = new List<string>();
if (!planLimitations.AllowDocumentUpload)
{
return (documents, removedFileIds, newFileIds);
}
if (model.Documents == null || model.Documents.Count == 0)
return (documents, removedFileIds, newFileIds);
for (int index = 0; index < model.Documents.Count; index++)
{
var docVm = model.Documents[index];
if (docVm == null)
continue;
var hasExisting = !string.IsNullOrEmpty(docVm.DocumentId);
if (docVm.MarkForRemoval)
{
if (hasExisting && docVm.DocumentId != null)
removedFileIds.Add(docVm.DocumentId);
continue;
}
if (string.IsNullOrWhiteSpace(docVm.Title))
{
ModelState.AddModelError($"Documents[{index}].Title", "Título é obrigatório");
continue;
}
string fileId = docVm.DocumentId?.Trim() ?? string.Empty;
string fileName = docVm.FileName?.Trim() ?? string.Empty;
long fileSize = docVm.FileSize;
var uploadedAt = docVm.UploadedAt ?? DateTime.UtcNow;
if (hasExisting && string.IsNullOrEmpty(docVm.Id))
{
docVm.Id = ObjectId.GenerateNewId().ToString();
}
if (docVm.DocumentFile != null && docVm.DocumentFile.Length > 0)
{
if (!docVm.DocumentFile.ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Envie um arquivo em PDF.");
continue;
}
if (docVm.DocumentFile.Length > 10 * 1024 * 1024)
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Arquivo muito grande. Tamanho máximo: 10MB.");
continue;
}
using var memoryStream = new MemoryStream();
await docVm.DocumentFile.CopyToAsync(memoryStream);
var documentBytes = memoryStream.ToArray();
try
{
fileId = await _documentStorage.SaveDocumentAsync(documentBytes, docVm.DocumentFile.FileName, docVm.DocumentFile.ContentType);
fileName = docVm.DocumentFile.FileName;
fileSize = docVm.DocumentFile.Length;
uploadedAt = DateTime.UtcNow;
newFileIds.Add(fileId);
if (hasExisting && docVm.DocumentId != null)
removedFileIds.Add(docVm.DocumentId);
}
catch (ArgumentException ex)
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", ex.Message);
continue;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao salvar PDF do usuário");
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Erro ao salvar o PDF. Tente novamente.");
continue;
}
}
else if (!hasExisting)
{
// Nenhum arquivo associado: ignorar
continue;
}
if (string.IsNullOrEmpty(fileId))
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Falha ao processar o documento.");
continue;
}
documents.Add(new PageDocument
{
Id = string.IsNullOrEmpty(docVm.Id) || !ObjectId.TryParse(docVm.Id, out _) ? ObjectId.GenerateNewId().ToString() : docVm.Id,
FileId = fileId,
Title = docVm.Title,
Description = docVm.Description,
FileName = fileName,
FileSize = fileSize,
UploadedAt = uploadedAt
});
docVm.DocumentId = fileId;
docVm.FileName = fileName;
docVm.FileSize = fileSize;
docVm.UploadedAt = uploadedAt;
if (string.IsNullOrEmpty(docVm.Id) || !ObjectId.TryParse(docVm.Id, out _))
{
docVm.Id = documents.Last().Id;
}
}
if (planLimitations.MaxDocuments > 0 && documents.Count > planLimitations.MaxDocuments)
{
ModelState.AddModelError("Documents", $"Você pode enviar no máximo {planLimitations.MaxDocuments} documento(s) com seu plano.");
}
return (documents, removedFileIds, newFileIds);
}
private async Task CleanupNewDocumentsAsync(IEnumerable<string> documentIds)
{
if (documentIds == null)
return;
foreach (var docId in documentIds)
{
if (string.IsNullOrEmpty(docId))
continue;
try
{
await _documentStorage.DeleteDocumentAsync(docId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Erro ao limpar documento temporário {DocumentId}", docId);
}
}
}
private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model, List<PageDocument> documents)
private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model)
{
page.DisplayName = model.DisplayName;
page.Category = model.Category;
@ -1287,57 +852,7 @@ public class AdminController : Controller
});
}
if (!string.IsNullOrEmpty(model.TiktokUrl))
{
socialLinks.Add(new LinkItem
{
Title = "TikTok",
Url = model.TiktokUrl,
Icon = "fab fa-tiktok",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.PinterestUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Pinterest",
Url = model.PinterestUrl,
Icon = "fab fa-pinterest",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.DiscordUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Discord",
Url = model.DiscordUrl,
Icon = "fab fa-discord",
IsActive = true,
Order = currentOrder++
});
}
if (!string.IsNullOrEmpty(model.KawaiUrl))
{
socialLinks.Add(new LinkItem
{
Title = "Kawai",
Url = model.KawaiUrl,
Icon = "fas fa-heart",
IsActive = true,
Order = currentOrder++
});
}
page.Links.AddRange(socialLinks);
page.Documents = documents ?? new List<PageDocument>();
}
[HttpPost]
@ -1406,10 +921,9 @@ public class AdminController : Controller
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" e "Rejected"
if (pageItem.Status != ViewModels.PageStatus.Creating &&
pageItem.Status != ViewModels.PageStatus.Rejected)
return Json(new { success = false, message = "Token só pode ser renovado para páginas em desenvolvimento ou rejeitadas" });
// 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
{
@ -1422,7 +936,7 @@ public class AdminController : Controller
success = true,
previewToken = newToken,
previewUrl = previewUrl,
expiresAt = DateTime.UtcNow.AddMinutes(5).ToString("yyyy-MM-dd HH:mm:ss")
expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss")
});
}
catch (Exception ex)
@ -1440,14 +954,10 @@ public class AdminController : Controller
if (user == null)
return Json(new { success = false, message = "Usuário não autenticado" });
_logger.LogInformation($"Generating preview token for page {id} by user {user.Id}");
var pageItem = await _userPageService.GetPageByIdAsync(id);
if (pageItem == null || pageItem.UserId != user.Id)
return Json(new { success = false, message = "Página não encontrada" });
_logger.LogInformation($"Generating preview token for page {id} preview token {pageItem.PreviewToken} by user {user.Id}");
// Verificar se página pode ter preview
if (pageItem.Status != ViewModels.PageStatus.Creating &&
pageItem.Status != ViewModels.PageStatus.PendingModeration &&
@ -1461,13 +971,13 @@ public class AdminController : Controller
// Gerar novo token com 4 horas de validade
var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id);
_logger.LogInformation($"Generating preview token for page {id} preview token {pageItem.PreviewToken} by user {user.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.AddMinutes(5).ToString("yyyy-MM-dd HH:mm:ss")
expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss")
});
}
catch (Exception ex)
@ -1547,18 +1057,6 @@ public class AdminController : Controller
}
}
// Método auxiliar para critérios de downgrade (usado na API)
private DowngradeCriteria GetDowngradeCriteria(PlanType plan)
{
return new DowngradeCriteria
{
MaxPages = plan.GetMaxPages(),
MaxLinksPerPage = plan.GetMaxLinksPerPage(),
SelectionCriteria = "Páginas mais antigas têm prioridade",
LinksCriteria = "Páginas com muitos links são automaticamente suspensas"
};
}
private void CleanSocialMediaFields(ManagePageViewModel model)
{
// Tratar espaço em branco como campo vazio para redes sociais
@ -1573,122 +1071,5 @@ public class AdminController : Controller
if (string.IsNullOrWhiteSpace(model.TwitterUrl) || model.TwitterUrl.Trim().Length <= 1)
model.TwitterUrl = string.Empty;
if (string.IsNullOrWhiteSpace(model.TiktokUrl) || model.TiktokUrl.Trim().Length <= 1)
model.TiktokUrl = string.Empty;
if (string.IsNullOrWhiteSpace(model.PinterestUrl) || model.PinterestUrl.Trim().Length <= 1)
model.PinterestUrl = string.Empty;
if (string.IsNullOrWhiteSpace(model.DiscordUrl) || model.DiscordUrl.Trim().Length <= 1)
model.DiscordUrl = string.Empty;
if (string.IsNullOrWhiteSpace(model.KawaiUrl) || model.KawaiUrl.Trim().Length <= 1)
model.KawaiUrl = string.Empty;
}
// Endpoint para validar impacto de downgrade
[HttpPost]
[Route("ValidateDowngrade")]
public async Task<IActionResult> ValidateDowngrade(string targetPlan)
{
try
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return Json(new { error = "Usuário não encontrado" });
if (!Enum.TryParse<PlanType>(targetPlan, true, out var newPlan))
return Json(new { error = "Plano inválido" });
var analysis = await _downgradeService.AnalyzeDowngradeImpact(user.Id, newPlan);
// CASO CRÍTICO: Nenhuma página atende os critérios
if (analysis.IsCritical)
{
return Json(new
{
canDowngrade = false,
critical = true,
title = "⚠️ Downgrade não recomendado",
message = "Nenhuma de suas páginas atende aos limites do novo plano. Todas seriam suspensas.",
details = analysis.Issues,
suggestion = "Considere editar suas páginas para reduzir o número de links antes do downgrade.",
criteria = GetDowngradeCriteria(newPlan)
});
}
// CASO NORMAL: Algumas páginas serão afetadas
return Json(new
{
canDowngrade = true,
critical = false,
title = "Confirmação de Downgrade",
summary = analysis.Summary,
eligiblePages = analysis.EligiblePages.Select(p => new
{
name = p.DisplayName,
linkCount = p.LinkCount,
reason = "✅ Dentro dos limites"
}),
suspendedPages = analysis.AffectedPages.Select(p => new
{
name = p.DisplayName,
linkCount = p.LinkCount,
reason = p.SuspensionReason
}),
criteria = GetDowngradeCriteria(newPlan)
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao validar downgrade para usuário {User}", User.Identity?.Name);
return Json(new { error = "Erro interno do servidor" });
}
}
// 🔥 OTIMIZAÇÃO: Endpoint para manter a sessão do usuário ativa
[HttpPost]
[Route("KeepAlive")]
public IActionResult KeepAlive()
{
_logger.LogInformation("KeepAlive endpoint triggered for user {User}", User.Identity?.Name ?? "Anonymous");
return Json(new { status = "session_extended" });
}
private void AdjustModelState(ModelStateDictionary modelState, ManagePageViewModel model)
{
modelState.Remove<ManagePageViewModel>(x => x.InstagramUrl);
modelState.Remove<ManagePageViewModel>(x => x.FacebookUrl);
modelState.Remove<ManagePageViewModel>(x => x.TwitterUrl);
modelState.Remove<ManagePageViewModel>(x => x.WhatsAppNumber);
modelState.Remove<ManagePageViewModel>(x => x.TiktokUrl);
modelState.Remove<ManagePageViewModel>(x => x.PinterestUrl);
modelState.Remove<ManagePageViewModel>(x => x.DiscordUrl);
modelState.Remove<ManagePageViewModel>(x => x.KawaiUrl);
// Remover validação de 'Description' para links do tipo 'Normal'
if (model.Links != null)
{
for (int i = 0; i < model.Links.Count; i++)
{
if (model.Links[i].Type == LinkType.Normal)
{
string key = $"Links[{i}].Description";
if (ModelState.ContainsKey(key))
{
ModelState.Remove(key);
ModelState.MarkFieldValid(key);
}
key = $"Links[{i}].Url";
if (ModelState.ContainsKey(key))
{
ModelState.Remove(key);
ModelState.MarkFieldValid(key);
}
}
}
}
}
}

View File

@ -6,9 +6,6 @@ using Microsoft.AspNetCore.Authentication.MicrosoftAccount;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
#if TESTING
using BCards.Web.TestSupport;
#endif
namespace BCards.Web.Controllers;
@ -16,106 +13,24 @@ namespace BCards.Web.Controllers;
public class AuthController : Controller
{
private readonly IAuthService _authService;
private readonly IOAuthHealthService _oauthHealthService;
private readonly ILogger<AuthController> _logger;
private readonly IWebHostEnvironment _env;
public AuthController(
IAuthService authService,
IOAuthHealthService oauthHealthService,
ILogger<AuthController> logger,
IWebHostEnvironment env)
public AuthController(IAuthService authService)
{
_authService = authService;
_oauthHealthService = oauthHealthService;
_logger = logger;
_env = env;
}
[HttpGet]
[Route("Login")]
public async Task<IActionResult> Login(string? returnUrl = null)
public IActionResult Login(string? returnUrl = null)
{
ViewBag.ReturnUrl = returnUrl;
ViewBag.IsTestingEnvironment = _env.IsEnvironment("Testing");
// Verificar status dos OAuth providers e passar para a view
var oauthStatus = await _oauthHealthService.CheckOAuthProvidersAsync();
ViewBag.OAuthStatus = oauthStatus;
return View();
}
/// <summary>
/// Endpoint AJAX para verificar status dos OAuth providers
/// </summary>
[HttpGet]
[Route("oauth-status")]
public async Task<IActionResult> GetOAuthStatus()
{
try
{
var status = await _oauthHealthService.CheckOAuthProvidersAsync();
return Json(new
{
googleAvailable = status.GoogleAvailable,
microsoftAvailable = status.MicrosoftAvailable,
allProvidersHealthy = status.AllProvidersHealthy,
anyProviderHealthy = status.AnyProviderHealthy,
message = GetOAuthStatusMessage(status)
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao verificar status OAuth");
return Json(new { error = "Erro ao verificar disponibilidade de login" });
}
}
private string GetOAuthStatusMessage(OAuthHealthStatus status)
{
if (status.AllProvidersHealthy)
return "Todos os métodos de login estão funcionando normalmente";
if (!status.AnyProviderHealthy)
return "⚠️ Login temporariamente indisponível. Tente novamente em alguns minutos.";
var available = new List<string>();
var unavailable = new List<string>();
if (status.GoogleAvailable) available.Add("Google");
else unavailable.Add("Google");
if (status.MicrosoftAvailable) available.Add("Microsoft");
else unavailable.Add("Microsoft");
return $"⚠️ Login com {string.Join(" e ", unavailable)} temporariamente indisponível. Use {string.Join(" ou ", available)}.";
}
[HttpPost]
[Route("LoginWithGoogle")]
public async Task<IActionResult> LoginWithGoogle(string? returnUrl = null)
public IActionResult LoginWithGoogle(string? returnUrl = null)
{
// TEMPORARIAMENTE DESABILITADO - para testar se a verificação OAuth está causando problemas
/*
try
{
// Verificar se Google está disponível (com timeout curto para não travar UX)
var isGoogleAvailable = await _oauthHealthService.IsGoogleAvailableAsync();
if (!isGoogleAvailable)
{
_logger.LogWarning("🟡 Usuário tentou fazer login com Google, mas o serviço parece estar offline");
TempData["LoginError"] = "Login com Google pode estar temporariamente indisponível. Se o problema persistir, tente Microsoft.";
// Mas PERMITA o login mesmo assim - o próprio Google vai dar erro se estiver offline
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Erro ao verificar status Google - permitindo login mesmo assim");
// Se não conseguir verificar, permitir login
}
*/
var redirectUrl = Url.Action("GoogleCallback", "Auth", new { returnUrl });
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
return Challenge(properties, GoogleDefaults.AuthenticationScheme);
@ -123,59 +38,13 @@ public class AuthController : Controller
[HttpPost]
[Route("LoginWithMicrosoft")]
public async Task<IActionResult> LoginWithMicrosoft(string? returnUrl = null)
public IActionResult LoginWithMicrosoft(string? returnUrl = null)
{
// TEMPORARIAMENTE DESABILITADO - para testar se a verificação OAuth está causando problemas
/*
try
{
// Verificar se Microsoft está disponível (com timeout curto para não travar UX)
var isMicrosoftAvailable = await _oauthHealthService.IsMicrosoftAvailableAsync();
if (!isMicrosoftAvailable)
{
_logger.LogWarning("🟡 Usuário tentou fazer login com Microsoft, mas o serviço parece estar offline");
TempData["LoginError"] = "Login com Microsoft pode estar temporariamente indisponível. Se o problema persistir, tente Google.";
// Mas PERMITA o login mesmo assim - o próprio Microsoft vai dar erro se estiver offline
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Erro ao verificar status Microsoft - permitindo login mesmo assim");
// Se não conseguir verificar, permitir login
}
*/
var redirectUrl = Url.Action("MicrosoftCallback", "Auth", new { returnUrl });
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
return Challenge(properties, MicrosoftAccountDefaults.AuthenticationScheme);
}
#if TESTING
[HttpPost]
[Route("LoginWithTest")]
public IActionResult LoginWithTest(string? returnUrl = null)
{
if (!_env.IsEnvironment("Testing"))
{
return NotFound(); // Endpoint de teste só funciona no ambiente de Testing
}
string redirectUrlString;
if (Url.IsLocalUrl(returnUrl))
{
redirectUrlString = returnUrl;
}
else
{
redirectUrlString = Url.Action("Dashboard", "Admin");
}
var properties = new AuthenticationProperties { RedirectUri = redirectUrlString };
return Challenge(properties, TestAuthConstants.AuthenticationScheme);
}
#endif
[HttpGet]
[Route("GoogleCallback")]
public async Task<IActionResult> GoogleCallback(string? returnUrl = null)
@ -183,7 +52,7 @@ public class AuthController : Controller
var result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
if (!result.Succeeded)
{
TempData["Error"] = "Falha na autentica<EFBFBD><EFBFBD>o com Google";
TempData["Error"] = "Falha na autenticação com Google";
return RedirectToAction("Login");
}
@ -213,7 +82,7 @@ public class AuthController : Controller
var result = await HttpContext.AuthenticateAsync(MicrosoftAccountDefaults.AuthenticationScheme);
if (!result.Succeeded)
{
TempData["Error"] = "Falha na autentica<EFBFBD><EFBFBD>o com Microsoft";
TempData["Error"] = "Falha na autenticação com Microsoft";
return RedirectToAction("Login");
}
@ -236,15 +105,30 @@ public class AuthController : Controller
return RedirectToLocal(returnUrl);
}
[HttpPost]
[HttpGet]
[Route("Logout")]
[Authorize]
[ValidateAntiForgeryToken]
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);
TempData["Success"] = "Você saiu com segurança.";
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");
}

View File

@ -1,64 +0,0 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Route("api/[controller]")]
[ApiController]
public class DocumentController : ControllerBase
{
private readonly IDocumentStorageService _documentStorage;
private readonly ILogger<DocumentController> _logger;
public DocumentController(IDocumentStorageService documentStorage, ILogger<DocumentController> logger)
{
_documentStorage = documentStorage;
_logger = logger;
}
[HttpGet("{documentId}")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
public async Task<IActionResult> GetDocument(string documentId)
{
try
{
if (string.IsNullOrEmpty(documentId))
return BadRequest("Documento inválido.");
var documentBytes = await _documentStorage.GetDocumentAsync(documentId);
if (documentBytes == null)
return NotFound();
Response.Headers["Cache-Control"] = "public, max-age=31536000";
Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R");
Response.Headers["ETag"] = $"\"{documentId}\"";
return File(documentBytes, "application/pdf");
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao obter documento {DocumentId}", documentId);
return NotFound();
}
}
[HttpDelete("{documentId}")]
public async Task<IActionResult> DeleteDocument(string documentId)
{
if (string.IsNullOrEmpty(documentId))
return BadRequest();
var deleted = await _documentStorage.DeleteDocumentAsync(documentId);
return deleted ? Ok(new { success = true }) : NotFound();
}
[HttpHead("{documentId}")]
public async Task<IActionResult> DocumentExists(string documentId)
{
if (string.IsNullOrEmpty(documentId))
return BadRequest();
var exists = await _documentStorage.DocumentExistsAsync(documentId);
return exists ? Ok() : NotFound();
}
}

View File

@ -1,305 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Diagnostics;
using System.Text.Json;
namespace BCards.Web.Controllers;
[ApiController]
[Route("health")]
public class HealthController : ControllerBase
{
private readonly HealthCheckService _healthCheckService;
private readonly ILogger<HealthController> _logger;
private readonly IConfiguration _configuration;
private static readonly DateTime _startTime = DateTime.UtcNow;
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger, IConfiguration configuration)
{
_healthCheckService = healthCheckService;
_logger = logger;
_configuration = configuration;
}
/// <summary>
/// Health check simples - retorna apenas status geral
/// </summary>
[HttpGet]
public async Task<IActionResult> GetHealth()
{
var stopwatch = Stopwatch.StartNew();
try
{
var healthReport = await _healthCheckService.CheckHealthAsync();
stopwatch.Stop();
var response = new
{
status = healthReport.Status.ToString().ToLower(),
timestamp = DateTime.UtcNow,
duration = $"{stopwatch.ElapsedMilliseconds}ms"
};
_logger.LogInformation("Simple health check completed: {Status} in {Duration}ms",
response.status, stopwatch.ElapsedMilliseconds);
return healthReport.Status == HealthStatus.Healthy
? Ok(response)
: StatusCode(503, response);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds);
return StatusCode(503, new
{
status = "unhealthy",
timestamp = DateTime.UtcNow,
duration = $"{stopwatch.ElapsedMilliseconds}ms",
error = ex.Message
});
}
}
/// <summary>
/// Health check detalhado - formato completo com métricas
/// </summary>
[HttpGet("detailed")]
public async Task<IActionResult> GetDetailedHealth()
{
var stopwatch = Stopwatch.StartNew();
try
{
var healthReport = await _healthCheckService.CheckHealthAsync();
stopwatch.Stop();
var checks = new Dictionary<string, object>();
foreach (var entry in healthReport.Entries)
{
checks[entry.Key] = new
{
status = entry.Value.Status.ToString().ToLower(),
duration = entry.Value.Duration.TotalMilliseconds + "ms",
description = entry.Value.Description,
data = entry.Value.Data,
exception = entry.Value.Exception?.Message
};
}
var uptime = DateTime.UtcNow - _startTime;
var response = new
{
applicationName = _configuration["ApplicationName"] ?? "BCards",
status = healthReport.Status.ToString().ToLower(),
timestamp = DateTime.UtcNow,
uptime = FormatUptime(uptime),
totalDuration = $"{stopwatch.ElapsedMilliseconds}ms",
checks = checks,
version = "1.0.0",
environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"
};
_logger.LogInformation("Detailed health check completed: {Status} in {Duration}ms - {HealthyCount}/{TotalCount} services healthy",
response.status, stopwatch.ElapsedMilliseconds,
healthReport.Entries.Count(e => e.Value.Status == HealthStatus.Healthy),
healthReport.Entries.Count);
return healthReport.Status == HealthStatus.Unhealthy
? StatusCode(503, response)
: Ok(response);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Detailed health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds);
return StatusCode(503, new
{
applicationName = "BCards",
status = "unhealthy",
timestamp = DateTime.UtcNow,
uptime = FormatUptime(DateTime.UtcNow - _startTime),
totalDuration = $"{stopwatch.ElapsedMilliseconds}ms",
error = ex.Message,
version = "1.0.0",
environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"
});
}
}
/// <summary>
/// Health check para Uptime Kuma - formato específico
/// </summary>
[HttpGet("uptime-kuma")]
public async Task<IActionResult> GetUptimeKumaHealth()
{
var stopwatch = Stopwatch.StartNew();
try
{
var healthReport = await _healthCheckService.CheckHealthAsync();
stopwatch.Stop();
var isHealthy = healthReport.Status == HealthStatus.Healthy;
var response = new
{
status = isHealthy ? "up" : "down",
message = isHealthy ? "All services operational" : $"Issues detected: {healthReport.Status}",
timestamp = DateTime.UtcNow.ToString("O"),
responseTime = stopwatch.ElapsedMilliseconds,
services = healthReport.Entries.ToDictionary(
e => e.Key,
e => new {
status = e.Value.Status.ToString().ToLower(),
responseTime = e.Value.Duration.TotalMilliseconds
}
)
};
_logger.LogInformation("Uptime Kuma health check: {Status} in {Duration}ms",
response.status, stopwatch.ElapsedMilliseconds);
var memoryMB = Math.Round(GC.GetTotalMemory(false) / 1024.0 / 1024.0, 2);
var uptimeMinutes = (DateTime.UtcNow - Process.GetCurrentProcess().StartTime).TotalMinutes;
_logger.LogInformation("[HEALTH] Memory: {memoryBytes} MemoryMB: {memoryMB} ThreadPool: {threadPool} ProcessId: {processId} ActiveConnections: {activeConnections} UptimeMinutes: {uptimeMinutes}",
GC.GetTotalMemory(false),
memoryMB,
ThreadPool.ThreadCount,
Process.GetCurrentProcess().Id,
HttpContext.Connection?.Id ?? "null",
Math.Round(uptimeMinutes, 1));
return Ok(response);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Uptime Kuma health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds);
return Ok(new
{
status = "down",
message = $"Health check failed: {ex.Message}",
timestamp = DateTime.UtcNow.ToString("O"),
responseTime = stopwatch.ElapsedMilliseconds
});
}
}
/// <summary>
/// Health checks específicos por serviço
/// </summary>
[HttpGet("mongodb")]
public async Task<IActionResult> GetMongoDbHealth()
{
return await GetSpecificServiceHealth("mongodb");
}
[HttpGet("stripe")]
public async Task<IActionResult> GetStripeHealth()
{
return await GetSpecificServiceHealth("stripe");
}
[HttpGet("sendgrid")]
public async Task<IActionResult> GetSendGridHealth()
{
return await GetSpecificServiceHealth("sendgrid");
}
[HttpGet("external")]
public async Task<IActionResult> GetExternalServicesHealth()
{
return await GetSpecificServiceHealth("external_services");
}
[HttpGet("resources")]
public async Task<IActionResult> GetSystemResourcesHealth()
{
return await GetSpecificServiceHealth("resources");
}
private async Task<IActionResult> GetSpecificServiceHealth(string serviceName)
{
var stopwatch = Stopwatch.StartNew();
try
{
var healthReport = await _healthCheckService.CheckHealthAsync(check => check.Name == serviceName);
stopwatch.Stop();
if (!healthReport.Entries.Any())
{
return NotFound(new { error = $"Service '{serviceName}' not found" });
}
var entry = healthReport.Entries.First().Value;
_logger.LogInformation("[HEALTH_CHECK] Service: {serviceName} Status: {status} Duration: {duration} ResponseTime: {responseTime} Timestamp: {timestamp} Data: {data}",
serviceName,
entry.Status.ToString().ToLower(),
entry.Duration.TotalMilliseconds,
stopwatch.ElapsedMilliseconds,
DateTime.UtcNow,
entry.Data != null ? string.Join(", ", entry.Data.Select(d => $"{d.Key}:{d.Value}")) : "none");
if (entry.Exception != null)
{
_logger.LogError("[HEALTH_CHECK] Service: {serviceName} Status: {status} Duration: {duration} Error: {error} Exception: {exception}",
serviceName, entry.Status.ToString().ToLower(), stopwatch.ElapsedMilliseconds,
entry.Exception.Message, entry.Exception.ToString());
}
var response = new
{
service = serviceName,
status = entry.Status.ToString().ToLower(),
timestamp = DateTime.UtcNow,
duration = $"{entry.Duration.TotalMilliseconds}ms",
description = entry.Description,
data = entry.Data,
exception = entry.Exception?.Message
};
_logger.LogInformation("Service {ServiceName} health check: {Status} in {Duration}ms",
serviceName, response.status, entry.Duration.TotalMilliseconds);
return entry.Status == HealthStatus.Unhealthy
? StatusCode(503, response)
: Ok(response);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Service {ServiceName} health check failed after {Duration}ms",
serviceName, stopwatch.ElapsedMilliseconds);
return StatusCode(503, new
{
service = serviceName,
status = "unhealthy",
timestamp = DateTime.UtcNow,
duration = $"{stopwatch.ElapsedMilliseconds}ms",
error = ex.Message
});
}
}
private static string FormatUptime(TimeSpan uptime)
{
if (uptime.TotalDays >= 1)
return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m";
if (uptime.TotalHours >= 1)
return $"{uptime.Hours}h {uptime.Minutes}m";
if (uptime.TotalMinutes >= 1)
return $"{uptime.Minutes}m {uptime.Seconds}s";
return $"{uptime.Seconds}s";
}
}

View File

@ -1,7 +1,5 @@
using BCards.Web.Services;
using BCards.Web.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace BCards.Web.Controllers;
@ -9,31 +7,15 @@ public class HomeController : Controller
{
private readonly ICategoryService _categoryService;
private readonly IUserPageService _userPageService;
private readonly StripeSettings _stripeSettings;
public HomeController(
ICategoryService categoryService,
IUserPageService userPageService,
IOptions<StripeSettings> stripeSettings)
public HomeController(ICategoryService categoryService, IUserPageService userPageService)
{
_categoryService = categoryService;
_userPageService = userPageService;
_stripeSettings = stripeSettings.Value;
}
public async Task<IActionResult> Index()
{
// Cache condicional: apenas para usuários não logados
if (User.Identity?.IsAuthenticated != true)
{
Response.Headers["Cache-Control"] = "public, max-age=600"; // 10 minutos
}
else
{
Response.Headers["Cache-Control"] = "no-cache, must-revalidate";
Response.Headers["Vary"] = "Cookie";
}
ViewBag.IsHomePage = true; // Flag para identificar home
ViewBag.Categories = await _categoryService.GetAllCategoriesAsync();
ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6);
@ -41,7 +23,6 @@ public class HomeController : Controller
}
[Route("Privacy")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] // 1 hora
public IActionResult Privacy()
{
ViewBag.IsHomePage = true;
@ -51,55 +32,26 @@ public class HomeController : Controller
[Route("Pricing")]
public IActionResult Pricing()
{
// Cache condicional: apenas para usuários não logados
if (User.Identity?.IsAuthenticated != true)
{
Response.Headers["Cache-Control"] = "public, max-age=1800"; // 30 minutos
}
else
{
Response.Headers["Cache-Control"] = "no-cache, must-revalidate";
Response.Headers["Vary"] = "Cookie";
}
ViewBag.IsHomePage = true;
return View();
}
[Route("health")]
public IActionResult Health()
{
return Ok(new {
status = "healthy",
timestamp = DateTime.UtcNow,
version = "1.0.0"
});
}
[Route("stripe-info")]
public IActionResult StripeInfo()
{
// Apenas para usuários logados ou em desenvolvimento
if (!User.Identity?.IsAuthenticated == true && !HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
[Route("categoria/{categorySlug}")]
public async Task<IActionResult> Category(string categorySlug)
{
ViewBag.IsHomePage = true;
var category = await _categoryService.GetCategoryBySlugAsync(categorySlug);
if (category == null)
return NotFound();
var pages = await _userPageService.GetPagesByCategoryAsync(categorySlug, 20);
ViewBag.Category = category;
ViewBag.Pages = pages;
return View();
}
var info = new
{
Environment = _stripeSettings.Environment,
IsTestMode = _stripeSettings.IsTestMode,
IsLiveMode = _stripeSettings.IsLiveMode,
PublishableKeyPrefix = _stripeSettings.PublishableKey?.Substring(0, Math.Min(12, _stripeSettings.PublishableKey.Length)) + "...",
SecretKeyPrefix = _stripeSettings.SecretKey?.Substring(0, Math.Min(12, _stripeSettings.SecretKey.Length)) + "...",
WebhookConfigured = !string.IsNullOrEmpty(_stripeSettings.WebhookSecret),
Timestamp = DateTime.UtcNow
};
return Json(info);
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{

View File

@ -51,7 +51,7 @@ public class ImageController : ControllerBase
}
[HttpPost("upload")]
[RequestSizeLimit(2 * 1024 * 1024)] // 2MB máximo - otimizado para celulares
[RequestSizeLimit(5 * 1024 * 1024)] // 5MB máximo
[DisableRequestSizeLimit] // Para formulários grandes
public async Task<IActionResult> UploadImage(IFormFile file)
{
@ -75,11 +75,11 @@ public class ImageController : ControllerBase
}
// Validação de tamanho
if (file.Length > 2 * 1024 * 1024) // 2MB
if (file.Length > 5 * 1024 * 1024) // 5MB
{
_logger.LogWarning("File too large: {Size}MB", file.Length / (1024 * 1024));
return BadRequest(new {
error = "Arquivo muito grande. Tamanho máximo: 2MB.",
error = "File too large. Maximum size is 5MB.",
code = "FILE_TOO_LARGE"
});
}

View File

@ -1,90 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using System.Linq;
namespace BCards.Web.Controllers
{
public class LegalController : Controller
{
// GET: /privacidade or /Legal/Privacy
public IActionResult Privacy()
{
var lang = GetUserLanguage();
if (lang == "es")
{
return RedirectToAction("PrivacyES");
}
return View();
}
// GET: /privacy or /Legal/PrivacyES
public IActionResult PrivacyES()
{
return View();
}
// GET: /termos or /Legal/Terms
public IActionResult Terms()
{
var lang = GetUserLanguage();
if (lang == "es")
{
return RedirectToAction("TermsES");
}
return View();
}
// GET: /terminos or /Legal/TermsES
public IActionResult TermsES()
{
return View();
}
// GET: /regras or /Legal/CommunityGuidelines
public IActionResult CommunityGuidelines()
{
// As regras da comunidade podem ser um único documento com tradução na própria página
// ou também podem ser redirecionadas. Por enquanto, uma única view.
return View();
}
// GET: /Legal/RequestData
public IActionResult RequestData()
{
ViewData["Title"] = "Solicitação de Dados Pessoais";
return View();
}
/// <summary>
/// Detecta o idioma do usuário com base em query string, cabeçalhos ou um padrão.
/// </summary>
/// <returns>"pt" para português ou "es" para espanhol.</returns>
private string GetUserLanguage()
{
// 1. Verificar por parâmetro na URL (?lang=es)
if (Request.Query.TryGetValue("lang", out var lang))
{
if (lang == "es" || lang == "pt")
return lang;
}
// 2. Verificar o cabeçalho Accept-Language
var acceptLanguage = Request.Headers["Accept-Language"].ToString();
if (!string.IsNullOrEmpty(acceptLanguage))
{
var languages = acceptLanguage.Split(',').Select(x => x.Trim().Split(';')[0]);
foreach (var language in languages)
{
if (language.StartsWith("es", StringComparison.OrdinalIgnoreCase))
return "es";
if (language.StartsWith("pt", StringComparison.OrdinalIgnoreCase))
return "pt";
}
}
// 3. TODO: Implementar detecção por GeoIP se necessário
// 4. Padrão: português
return "pt";
}
}
}

View File

@ -39,7 +39,17 @@ public class LivePageController : Controller
}
// Incrementar view de forma assíncrona (não bloquear response)
_ = IncrementViewSafelyAsync(livePage.Id);
_ = 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;
@ -64,34 +74,20 @@ public class LivePageController : Controller
var link = livePage.Links[linkIndex];
// Track click de forma assíncrona
_ = IncrementLinkClickSafelyAsync(livePage.Id, linkIndex);
_ = 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);
}
private async Task IncrementViewSafelyAsync(string livePageId)
{
try
{
await _livePageService.IncrementViewAsync(livePageId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePageId);
}
}
private async Task IncrementLinkClickSafelyAsync(string livePageId, int linkIndex)
{
try
{
await _livePageService.IncrementLinkClickAsync(livePageId, linkIndex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to track click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex);
}
}
}

View File

@ -6,7 +6,6 @@ using BCards.Web.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Globalization;
namespace BCards.Web.Controllers;
@ -238,7 +237,7 @@ public class PaymentController : Controller
{
PlanType = planKey.ToLower(),
DisplayName = planSection["Name"] ?? planKey,
Price = decimal.Parse(planSection["Price"] ?? "0", new CultureInfo("en-US")),
Price = decimal.Parse(planSection["Price"] ?? "0"),
PriceId = planSection["PriceId"] ?? "",
MaxLinks = int.Parse(planSection["MaxLinks"] ?? "0"),
AllowAnalytics = bool.Parse(planSection["AllowAnalytics"] ?? "false"),

View File

@ -30,44 +30,40 @@ public class SitemapController : Controller
// 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages
var livePages = await _livePageService.GetAllActiveAsync();
// Define namespace corretamente para evitar conflitos
XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
// Construir URLs das páginas dinâmicas separadamente para evitar problemas
var dynamicUrls = livePages.Select(page =>
new XElement(ns + "url",
new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category?.Replace(" ", "-")?.ToLower()}/{page.Slug}"),
new XElement(ns + "lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")),
new XElement(ns + "changefreq", "weekly"),
new XElement(ns + "priority", "0.8")
)
).ToList();
var sitemap = new XDocument(
new XDeclaration("1.0", "utf-8", "yes"),
new XElement(ns + "urlset",
new XElement("urlset",
new XAttribute("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9"),
// Add static pages
new XElement(ns + "url",
new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/"),
new XElement(ns + "lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
new XElement(ns + "changefreq", "daily"),
new XElement(ns + "priority", "1.0")
new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/"),
new XElement("lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
new XElement("changefreq", "daily"),
new XElement("priority", "1.0")
),
new XElement(ns + "url",
new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/Home/Pricing"),
new XElement(ns + "lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
new XElement(ns + "changefreq", "weekly"),
new XElement(ns + "priority", "0.9")
new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/Home/Pricing"),
new XElement("lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
new XElement("changefreq", "weekly"),
new XElement("priority", "0.9")
),
// Add live pages (SEO-optimized URLs only)
dynamicUrls
livePages.Select(page =>
new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"),
new XElement("lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")),
new XElement("changefreq", "weekly"),
new XElement("priority", "0.8")
)
)
)
);
_logger.LogInformation($"Generated sitemap with {livePages.Count} live pages");
return Content(sitemap.ToString(SaveOptions.DisableFormatting), "application/xml", Encoding.UTF8);
return Content(sitemap.ToString(), "application/xml", Encoding.UTF8);
}
catch (Exception ex)
{

View File

@ -1,11 +1,9 @@
using BCards.Web.Configuration;
using BCards.Web.Repositories;
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using Stripe;
using System.Diagnostics;
using BCards.Web.Services;
using BCards.Web.Repositories;
using BCards.Web.Configuration;
using Microsoft.Extensions.Options;
namespace BCards.Web.Controllers;
@ -16,23 +14,17 @@ public class StripeWebhookController : ControllerBase
private readonly ILogger<StripeWebhookController> _logger;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly IUserPageService _userPageService;
private readonly IUserRepository _userRepository;
private readonly IPlanConfigurationService _planConfigurationService;
private readonly string _webhookSecret;
public StripeWebhookController(
ILogger<StripeWebhookController> logger,
ISubscriptionRepository subscriptionRepository,
IUserPageService userPageService,
IUserRepository userRepository,
IPlanConfigurationService planConfigurationService,
IOptions<StripeSettings> stripeSettings)
{
_logger = logger;
_subscriptionRepository = subscriptionRepository;
_userPageService = userPageService;
_userRepository = userRepository;
_planConfigurationService = planConfigurationService;
_webhookSecret = stripeSettings.Value.WebhookSecret ?? "";
}
@ -42,14 +34,12 @@ public class StripeWebhookController : ControllerBase
try
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
_logger.LogInformation($"Recebido:{json}");
if (string.IsNullOrEmpty(_webhookSecret))
{
_logger.LogWarning("Webhook secret not configured");
return BadRequest("Webhook secret not configured");
}
_logger.LogWarning($"Recebido:{json}");
var stripeSignature = Request.Headers["Stripe-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(stripeSignature))
@ -58,7 +48,6 @@ public class StripeWebhookController : ControllerBase
return BadRequest("Missing Stripe signature");
}
_logger.LogInformation($"Contruir evento Stripe: {json}");
var stripeEvent = EventUtility.ConstructEvent(
json,
stripeSignature,
@ -66,7 +55,7 @@ public class StripeWebhookController : ControllerBase
throwOnApiVersionMismatch: false
);
_logger.LogInformation($"[DEBUG] Processing Stripe webhook: {stripeEvent.Type}");
_logger.LogInformation($"Processing Stripe webhook: {stripeEvent.Type}");
switch (stripeEvent.Type)
{
@ -86,10 +75,6 @@ public class StripeWebhookController : ControllerBase
await HandleSubscriptionUpdated(stripeEvent);
break;
case "customer.subscription.created":
await HandleSubscriptionCreated(stripeEvent);
break;
default:
_logger.LogInformation($"Unhandled webhook event type: {stripeEvent.Type}");
break;
@ -111,13 +96,9 @@ public class StripeWebhookController : ControllerBase
private async Task HandlePaymentSucceeded(Event stripeEvent)
{
var traceId = Guid.NewGuid().ToString();
try
{
_logger.LogInformation($"[TID: {traceId}] - Staring HandlePaymentSucceeded");
if (stripeEvent.Data.Object is Invoice invoice)
{
_logger.LogInformation($"[TID: {traceId}] - Payment succeeded for customer: {invoice.CustomerId}");
_logger.LogInformation($"Payment succeeded for customer: {invoice.CustomerId}");
var subscriptionId = GetSubscriptionId(stripeEvent);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
@ -136,26 +117,12 @@ public class StripeWebhookController : ControllerBase
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"[TID: {traceId}] - Reactivated {userPages.Count} pages for user {subscription.UserId}");
_logger.LogInformation($"Reactivated {userPages.Count} pages for user {subscription.UserId}");
}
}
else
{
_logger.LogWarning($"[TID: {traceId}] - Unexpected event type on HandlePaymentSucceeded: {stripeEvent.Type}");
}
await Task.Delay(4000);
}
catch (Exception ex)
{
_logger.LogError(ex, $"[TID: {traceId}] - Error handling payment succeeded event");
await Task.Delay(4000);
throw new Exception($"[TID: {traceId}] - Error handling payment succeeded event", ex);
}
}
private async Task HandlePaymentFailed(Event stripeEvent)
{
try
{
if (stripeEvent.Data.Object is Invoice invoice)
{
@ -181,21 +148,9 @@ public class StripeWebhookController : ControllerBase
_logger.LogInformation($"Set {userPages.Count} pages to pending payment for user {subscription.UserId}");
}
}
else
{
_logger.LogWarning($"Unexpected event type on HandlePaymentFailed: {stripeEvent.Type}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling payment failed event");
throw new Exception("Error handling payment failed event", ex);
}
}
private async Task HandleSubscriptionDeleted(Event stripeEvent)
{
try
{
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
@ -220,21 +175,9 @@ public class StripeWebhookController : ControllerBase
_logger.LogInformation($"Deactivated {userPages.Count} pages for cancelled subscription {subscription.UserId}");
}
}
else
{
_logger.LogWarning($"Unexpected event type on HandlePaymentFailed: {stripeEvent.Type}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling subscription deleted failed event");
throw new Exception("Error handling subscription deleted failed event", ex);
}
}
private async Task HandleSubscriptionUpdated(Event stripeEvent)
{
try
{
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
@ -256,7 +199,7 @@ public class StripeWebhookController : ControllerBase
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
if (!string.IsNullOrEmpty(priceId))
{
subscription.PlanType = _planConfigurationService.GetPlanNameFromPriceId(priceId);
subscription.PlanType = MapPriceIdToPlanType(priceId);
}
await _subscriptionRepository.UpdateAsync(subscription);
@ -264,91 +207,29 @@ public class StripeWebhookController : ControllerBase
_logger.LogInformation($"Updated subscription for user {subscription.UserId}");
}
}
else
}
private string MapPriceIdToPlanType(string priceId)
{
_logger.LogWarning($"Unexpected event type on HandlePaymentFailed: {stripeEvent.Type}");
}
}
catch (Exception ex)
// Map Stripe price IDs to plan types
// This would be configured based on your actual Stripe price IDs
return priceId switch
{
_logger.LogError(ex, "Error handling subscription updated failed event");
throw new Exception("Error handling subscription updated failed event", ex);
"price_1RjUskBMIadsOxJVgLwlVo1y" => "Basic",
"price_1RjUv9BMIadsOxJVORqlM4E9" => "Professional",
"price_1RjUw0BMIadsOxJVmdouNV1g" => "Premium",
"price_basic_yearly_placeholder" => "BasicYearly",
"price_professional_yearly_placeholder" => "ProfessionalYearly",
"price_premium_yearly_placeholder" => "PremiumYearly",
var id when id.Contains("basic") && id.Contains("yearly") => "BasicYearly",
var id when id.Contains("professional") && id.Contains("yearly") => "ProfessionalYearly",
var id when id.Contains("premium") && id.Contains("yearly") => "PremiumYearly",
var id when id.Contains("basic") => "Basic",
var id when id.Contains("professional") => "Professional",
var id when id.Contains("premium") => "Premium",
_ => "Trial"
};
}
}
private async Task HandleSubscriptionCreated(Event stripeEvent)
{
var traceId = Guid.NewGuid().ToString();
try
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - HandleSubscriptionCreated started");
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Subscription created: {stripeSubscription.Id} for customer: {stripeSubscription.CustomerId}");
// Get subscription record from our database
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Looking for existing subscription with ID: {stripeSubscription.Id}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Found existing subscription: {subscription.Id}");
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
// Update subscription status to active
subscription.Status = "active";
subscription.UpdatedAt = DateTime.UtcNow;
subscription.CurrentPeriodStart = subItem.CurrentPeriodStart;
subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd;
// Update plan type based on Stripe price ID
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
if (!string.IsNullOrEmpty(priceId))
{
subscription.PlanType = _planConfigurationService.GetPlanNameFromPriceId(priceId);
}
await _subscriptionRepository.UpdateAsync(subscription);
// Activate user pages that were pending payment or trial
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p =>
p.Status == ViewModels.PageStatus.PendingPayment ||
p.Status == ViewModels.PageStatus.Expired))
{
page.Status = ViewModels.PageStatus.Active;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"[TID: {traceId}] - Activated subscription and {userPages.Count()} pages for user {subscription.UserId}");
}
else
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Subscription not found in database: {stripeSubscription.Id}");
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Calling HandleSubscriptionCreatedForNewSubscription");
// Try to find user by Stripe Customer ID and create/update subscription
await HandleSubscriptionCreatedForNewSubscription(stripeSubscription);
}
}
else
{
_logger.LogWarning($"[TID: {traceId}] - Unexpected event type on HandleSubscriptionCreated: {stripeEvent.Type}");
}
await Task.Delay(4000);
}
catch (Exception ex)
{
_logger.LogError(ex, "[TID: {traceId}] - Error handling subscription created event");
await Task.Delay(4000);
throw new Exception("[TID: {traceId}] - Error handling subscription created event", ex);
}
}
private string GetSubscriptionId(Event stripeEvent)
{
@ -378,90 +259,4 @@ public class StripeWebhookController : ControllerBase
return null;
}
private async Task HandleSubscriptionCreatedForNewSubscription(Subscription stripeSubscription)
{
var traceId = Guid.NewGuid().ToString();
try
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - HandleSubscriptionCreatedForNewSubscription started for customer: {stripeSubscription.CustomerId}");
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Getting MongoDB database");
var mongoDatabase = HttpContext.RequestServices.GetRequiredService<IMongoDatabase>();
var usersCollection = mongoDatabase.GetCollection<Models.User>("users");
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Searching for user with StripeCustomerId: {stripeSubscription.CustomerId}");
var user = await usersCollection.Find(u => u.StripeCustomerId == stripeSubscription.CustomerId).FirstOrDefaultAsync();
if (user != null)
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Found user: {user.Id} ({user.Email})");
_logger.LogInformation($"[TID: {traceId}] - Found user {user.Id} for customer {stripeSubscription.CustomerId}");
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
// Get plan type from price ID
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
var planType = !string.IsNullOrEmpty(priceId) ? _planConfigurationService.GetPlanNameFromPriceId(priceId) : "Trial";
_logger.LogInformation($"[TID: {traceId}] - PriceId: {priceId}, PlanType: {planType}, User: {user.Id}");
// Create new subscription in our database
var newSubscription = new Models.Subscription
{
Id = MongoDB.Bson.ObjectId.GenerateNewId().ToString(),
UserId = user.Id,
StripeSubscriptionId = stripeSubscription.Id,
Status = "active",
PlanType = planType,
CurrentPeriodStart = subItem.CurrentPeriodStart,
CurrentPeriodEnd = subItem.CurrentPeriodEnd,
CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_logger.LogInformation($"[TID: {traceId}] - try to save into database (subscription) - User: {user.Id}");
await _subscriptionRepository.CreateAsync(newSubscription);
_logger.LogInformation($"[TID: {traceId}] - Created new subscription {newSubscription.Id} for user {user.Id}");
_logger.LogInformation($"[TID: {traceId}] - Updating user {user.Id} CurrentPlan from '{user.CurrentPlan}' to '{planType}'");
user.CurrentPlan = planType;
user.UpdatedAt = DateTime.UtcNow;
var usersCollection2 = mongoDatabase.GetCollection<Models.User>("users");
await usersCollection2.ReplaceOneAsync(u => u.Id == user.Id, user);
_logger.LogInformation($"[TID: {traceId}] - User {user.Id} CurrentPlan updated to '{planType}'");
var userPages = await _userPageService.GetUserPagesAsync(user.Id);
foreach (var page in userPages.Where(p =>
p.Status == ViewModels.PageStatus.PendingPayment ||
p.Status == ViewModels.PageStatus.Expired))
{
page.Status = ViewModels.PageStatus.Active;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"[TID: {traceId}] - Activated {userPages.Count()} pages for user {user.Id} after subscription creation");
}
else
{
_logger.LogError($"[DEBUG] [TID: {traceId}] - User not found for Stripe customer ID: {stripeSubscription.CustomerId}");
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Will try to list some users to debug");
var allUsers = await usersCollection.Find(_ => true).Limit(5).ToListAsync();
foreach (var u in allUsers)
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - User in DB: {u.Id} - {u.Email} - StripeCustomerId: '{u.StripeCustomerId}'");
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"[TID: {traceId}] - Error handling new subscription creation for {stripeSubscription.Id}");
throw;
}
}
}

View File

@ -73,7 +73,7 @@ public class SubscriptionController : Controller
{
case "immediate_with_refund":
success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, true);
message = success ? "Assinatura cancelada com reembolso total. O valor será retornado em até 10 dias úteis." : "Erro ao processar cancelamento.";
message = success ? "Assinatura cancelada. Reembolso será processado manualmente em até 10 dias úteis." : "Erro ao processar cancelamento.";
break;
case "immediate_no_refund":
@ -86,7 +86,7 @@ public class SubscriptionController : Controller
var (_, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(request.SubscriptionId);
if (success && canRefundPartial && refundAmount > 0)
{
message = $"Assinatura cancelada com reembolso parcial de R$ {refundAmount:F2}. O valor será retornado em até 10 dias úteis.";
message = $"Assinatura cancelada. Reembolso parcial de R$ {refundAmount:F2} será processado manualmente em até 10 dias úteis.";
}
else
{
@ -131,8 +131,8 @@ public class SubscriptionController : Controller
try
{
// Reativar assinatura removendo o agendamento de cancelamento
var success = await _paymentService.ReactivateSubscriptionAsync(subscriptionId);
// Remover agendamento de cancelamento
var success = await _paymentService.CancelSubscriptionAtPeriodEndAsync(subscriptionId);
if (success)
{

View File

@ -1,172 +0,0 @@
#if TESTING
using BCards.Web.Models;
using BCards.Web.Repositories;
using BCards.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Authorize]
[ApiController]
[Route("testing/tools")]
public class TestToolsController : ControllerBase
{
private readonly IUserRepository _userRepository;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly IUserPageService _userPageService;
private readonly ILivePageService _livePageService;
private readonly IPlanConfigurationService _planConfigurationService;
private readonly IWebHostEnvironment _environment;
public TestToolsController(
IUserRepository userRepository,
ISubscriptionRepository subscriptionRepository,
IUserPageService userPageService,
ILivePageService livePageService,
IPlanConfigurationService planConfigurationService,
IWebHostEnvironment environment)
{
_userRepository = userRepository;
_subscriptionRepository = subscriptionRepository;
_userPageService = userPageService;
_livePageService = livePageService;
_planConfigurationService = planConfigurationService;
_environment = environment;
}
[HttpPost("plan")]
public async Task<IActionResult> SetPlan([FromBody] SetPlanRequest request)
{
if (!_environment.IsEnvironment("Testing"))
{
return NotFound();
}
if (!Enum.TryParse<PlanType>(request.Plan, true, out var planType))
{
return BadRequest(new { error = $"Plano desconhecido: {request.Plan}" });
}
var email = string.IsNullOrWhiteSpace(request.Email)
? TestUserDefaults.Email
: request.Email!.Trim();
var user = await _userRepository.GetByEmailAsync(email);
if (user == null)
{
return NotFound(new { error = $"Usuário de teste não encontrado para o e-mail {email}" });
}
var planLimits = _planConfigurationService.GetPlanLimitations(planType);
var normalizedPlan = planLimits.PlanType ?? planType.ToString().ToLowerInvariant();
user.CurrentPlan = normalizedPlan;
user.SubscriptionStatus = "active";
user.UpdatedAt = DateTime.UtcNow;
await _userRepository.UpdateAsync(user);
var subscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
if (subscription == null)
{
subscription = new Subscription
{
UserId = user.Id,
StripeSubscriptionId = $"test-{Guid.NewGuid():N}",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
}
subscription.PlanType = normalizedPlan;
subscription.Status = "active";
subscription.MaxLinks = planLimits.MaxLinks;
subscription.AllowAnalytics = planLimits.AllowAnalytics;
subscription.AllowCustomThemes = planLimits.AllowCustomThemes;
subscription.AllowCustomDomain = planLimits.AllowCustomDomain;
subscription.AllowMultipleDomains = planLimits.AllowMultipleDomains;
subscription.PrioritySupport = planLimits.PrioritySupport;
subscription.CurrentPeriodStart = DateTime.UtcNow.Date;
subscription.CurrentPeriodEnd = DateTime.UtcNow.Date.AddMonths(1);
subscription.CancelAtPeriodEnd = false;
subscription.UpdatedAt = DateTime.UtcNow;
if (string.IsNullOrEmpty(subscription.Id))
{
await _subscriptionRepository.CreateAsync(subscription);
}
else
{
await _subscriptionRepository.UpdateAsync(subscription);
}
var pages = await _userPageService.GetUserPagesAsync(user.Id);
var originalPagesCount = pages.Count;
var removed = 0;
if (request.ResetPages)
{
foreach (var page in pages)
{
await _livePageService.DeleteByOriginalPageIdAsync(page.Id);
await _userPageService.DeletePageAsync(page.Id);
removed++;
}
pages = new List<UserPage>();
}
else
{
foreach (var page in pages)
{
page.PlanLimitations = ClonePlanLimitations(planLimits);
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
}
return Ok(new
{
email,
plan = normalizedPlan,
pagesAffected = request.ResetPages ? originalPagesCount : pages.Count,
pagesRemoved = removed
});
}
private static PlanLimitations ClonePlanLimitations(PlanLimitations source)
{
return new PlanLimitations
{
MaxLinks = source.MaxLinks,
AllowCustomThemes = source.AllowCustomThemes,
AllowAnalytics = source.AllowAnalytics,
AllowCustomDomain = source.AllowCustomDomain,
AllowMultipleDomains = source.AllowMultipleDomains,
PrioritySupport = source.PrioritySupport,
PlanType = source.PlanType,
MaxProductLinks = source.MaxProductLinks,
MaxOGExtractionsPerDay = source.MaxOGExtractionsPerDay,
AllowProductLinks = source.AllowProductLinks,
SpecialModeration = source.SpecialModeration,
OGExtractionsUsedToday = 0,
LastExtractionDate = null,
AllowDocumentUpload = source.AllowDocumentUpload,
MaxDocuments = source.MaxDocuments
};
}
private static class TestUserDefaults
{
public const string Email = "test.user@example.com";
}
public class SetPlanRequest
{
public string? Email { get; set; }
public string Plan { get; set; } = "trial";
public bool ResetPages { get; set; }
}
}
#endif

View File

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
//[Route("[controller]")]
public class UserPageController : Controller
{
private readonly IUserPageService _userPageService;
@ -12,25 +13,24 @@ public class UserPageController : Controller
private readonly ISeoService _seoService;
private readonly IThemeService _themeService;
private readonly IModerationService _moderationService;
private readonly ILogger<UserPageController> _logger;
public UserPageController(
IUserPageService userPageService,
ICategoryService categoryService,
ISeoService seoService,
IThemeService themeService,
IModerationService moderationService,
ILogger<UserPageController> logger)
IModerationService moderationService)
{
_userPageService = userPageService;
_categoryService = categoryService;
_seoService = seoService;
_themeService = themeService;
_moderationService = moderationService;
_logger = logger;
}
[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "preview" })]
//[Route("{category}/{slug}")]
//VOltar a linha abaixo em prod
//[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })]
public async Task<IActionResult> Display(string category, string slug)
{
var userPage = await _userPageService.GetPageAsync(category, slug);
@ -45,9 +45,6 @@ public class UserPageController : Controller
var isPreview = HttpContext.Items.ContainsKey("IsPreview") && (bool)HttpContext.Items["IsPreview"];
var previewToken = Request.Query["preview"].FirstOrDefault();
_logger.LogDebug("Request - Page: {Slug}, Status: {Status}, IsPreview: {IsPreview}, PreviewToken: {PreviewToken}, UserId: {UserId}",
userPage.Slug, userPage.Status, isPreview, !string.IsNullOrEmpty(previewToken), userPage.UserId);
if (!string.IsNullOrEmpty(previewToken))
{
// Handle preview request
@ -90,18 +87,17 @@ public class UserPageController : Controller
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
// Record page view (async, don't wait) - only for non-preview requests
_logger.LogDebug("View Count - Page: {Slug}, Status: {Status}, IsPreview: {IsPreview}, PreviewToken: {PreviewToken}",
userPage.Slug, userPage.Status, isPreview, !string.IsNullOrEmpty(previewToken));
Console.WriteLine($"DEBUG VIEW COUNT - Page: {userPage.Slug}, Status: {userPage.Status}, IsPreview: {isPreview}, PreviewToken: {previewToken}");
if (!isPreview)
{
_logger.LogDebug("Recording view for page {Slug}", userPage.Slug);
Console.WriteLine($"DEBUG: Recording view for page {userPage.Slug}");
var referrer = Request.Headers["Referer"].FirstOrDefault();
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
_ = _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent);
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
}
else
{
_logger.LogDebug("NOT recording view - isPreview = true for page {Slug}", userPage.Slug);
Console.WriteLine($"DEBUG: NOT recording view - isPreview = true");
}
ViewBag.SeoSettings = seoSettings;

View File

@ -1,110 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MongoDB.Driver;
using MongoDB.Bson;
using Stripe;
namespace BCards.Web.HealthChecks;
/// <summary>
/// Health check para serviços CRÍTICOS - falha = ALERTA VERMELHO 🔴
/// - MongoDB (páginas não funcionam)
/// - Stripe (pagamentos não funcionam)
/// - Website (implícito - se este check executa, o site funciona)
/// </summary>
public class CriticalServicesHealthCheck : IHealthCheck
{
private readonly IMongoDatabase _database;
private readonly HttpClient _httpClient;
private readonly ILogger<CriticalServicesHealthCheck> _logger;
public CriticalServicesHealthCheck(
IMongoDatabase database,
HttpClient httpClient,
ILogger<CriticalServicesHealthCheck> logger)
{
_database = database;
_httpClient = httpClient;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, object>();
var allCritical = true;
var criticalFailures = new List<string>();
// 1. MongoDB Check
try
{
await _database.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1), cancellationToken: cancellationToken);
results["mongodb"] = new { status = "healthy", service = "database" };
_logger.LogDebug("✅ MongoDB está respondendo");
}
catch (Exception ex)
{
allCritical = false;
criticalFailures.Add("MongoDB");
results["mongodb"] = new { status = "unhealthy", error = ex.Message, service = "database" };
_logger.LogError(ex, "🔴 CRÍTICO: MongoDB falhou - páginas de usuários não funcionam!");
// Pequeno delay para garantir que logs críticos sejam enviados
await Task.Delay(1000, cancellationToken);
}
// 2. Stripe API Check
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var response = await _httpClient.GetAsync("https://api.stripe.com/healthcheck", cts.Token);
if (response.IsSuccessStatusCode)
{
results["stripe"] = new { status = "healthy", service = "payment" };
_logger.LogDebug("✅ Stripe API está respondendo");
}
else
{
allCritical = false;
criticalFailures.Add("Stripe");
results["stripe"] = new { status = "unhealthy", status_code = (int)response.StatusCode, service = "payment" };
_logger.LogError("🔴 CRÍTICO: Stripe API falhou - pagamentos não funcionam! Status: {StatusCode}",
(int)response.StatusCode);
await Task.Delay(1000, cancellationToken);
}
}
catch (Exception ex)
{
allCritical = false;
criticalFailures.Add("Stripe");
results["stripe"] = new { status = "unhealthy", error = ex.Message, service = "payment" };
_logger.LogError(ex, "🔴 CRÍTICO: Stripe API inacessível - pagamentos não funcionam!");
await Task.Delay(1000, cancellationToken);
}
// 3. Self Health Check removido - se este código está executando, o website já está funcionando
results["website"] = new { status = "healthy", service = "website" };
_logger.LogDebug("✅ Website está operacional (health check executando)");
var data = new Dictionary<string, object>
{
{ "services", results },
{ "critical_failures", criticalFailures },
{ "failure_count", criticalFailures.Count },
{ "total_critical_services", 3 }
};
if (!allCritical)
{
var failureList = string.Join(", ", criticalFailures);
_logger.LogError("🔴 ALERTA VERMELHO: Serviços críticos falharam: {Services}", failureList);
return HealthCheckResult.Unhealthy($"Serviços críticos falharam: {failureList}", data: data);
}
return HealthCheckResult.Healthy("Todos os serviços críticos funcionando", data: data);
}
}

View File

@ -1,134 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class ExternalServicesHealthCheck : IHealthCheck
{
private readonly HttpClient _httpClient;
private readonly ILogger<ExternalServicesHealthCheck> _logger;
public ExternalServicesHealthCheck(HttpClient httpClient, ILogger<ExternalServicesHealthCheck> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var results = new Dictionary<string, object>();
var allHealthy = true;
var hasUnhealthy = false;
try
{
// Lista de serviços externos para verificar
var services = new Dictionary<string, string>
{
{ "google_oauth", "https://accounts.google.com" },
{ "microsoft_oauth", "https://login.microsoftonline.com" }
};
foreach (var service in services)
{
var serviceStopwatch = Stopwatch.StartNew();
try
{
using var response = await _httpClient.GetAsync(service.Value, cancellationToken);
serviceStopwatch.Stop();
var serviceResult = new Dictionary<string, object>
{
{ "status", response.IsSuccessStatusCode ? "healthy" : "unhealthy" },
{ "duration", $"{serviceStopwatch.ElapsedMilliseconds}ms" },
{ "status_code", (int)response.StatusCode },
{ "url", service.Value }
};
results[service.Key] = serviceResult;
if (!response.IsSuccessStatusCode)
{
allHealthy = false;
if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable ||
response.StatusCode == System.Net.HttpStatusCode.InternalServerError)
{
hasUnhealthy = true;
}
}
_logger.LogInformation("External service {Service} health check: {Status} in {Duration}ms",
service.Key, response.StatusCode, serviceStopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
serviceStopwatch.Stop();
allHealthy = false;
hasUnhealthy = true;
results[service.Key] = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{serviceStopwatch.ElapsedMilliseconds}ms" },
{ "error", ex.Message },
{ "url", service.Value }
};
// Usar Warning para OAuth providers (alerta amarelo)
if (service.Key.Contains("oauth"))
{
_logger.LogWarning("🟡 OAuth service {Service} offline - usuários não conseguem fazer login: {Error}",
service.Key, ex.Message);
}
else
{
_logger.LogError(ex, "🔴 Critical service {Service} failed", service.Key);
}
}
}
stopwatch.Stop();
var totalDuration = stopwatch.ElapsedMilliseconds;
var data = new Dictionary<string, object>
{
{ "status", hasUnhealthy ? "unhealthy" : (allHealthy ? "healthy" : "degraded") },
{ "duration", $"{totalDuration}ms" },
{ "services", results },
{ "total_services", services.Count },
{ "healthy_services", results.Values.Count(r => ((Dictionary<string, object>)r)["status"].ToString() == "healthy") }
};
if (hasUnhealthy)
{
return HealthCheckResult.Unhealthy("One or more external services are unhealthy", data: data);
}
if (!allHealthy)
{
return HealthCheckResult.Degraded("Some external services have issues", data: data);
}
return HealthCheckResult.Healthy($"All external services are responsive ({totalDuration}ms)", data: data);
}
catch (Exception ex)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(ex, "External services health check failed after {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"External services check failed: {ex.Message}", ex, data);
}
}
}

View File

@ -1,73 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MongoDB.Driver;
using MongoDB.Bson;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class MongoDbHealthCheck : IHealthCheck
{
private readonly IMongoDatabase _database;
private readonly ILogger<MongoDbHealthCheck> _logger;
public MongoDbHealthCheck(IMongoDatabase database, ILogger<MongoDbHealthCheck> logger)
{
_database = database;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Executa ping no MongoDB
var command = new BsonDocument("ping", 1);
await _database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken);
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogInformation("MongoDB health check completed successfully in {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "healthy" },
{ "duration", $"{duration}ms" },
{ "database", _database.DatabaseNamespace.DatabaseName },
{ "connection_state", "connected" },
{ "latency", duration }
};
// Status baseado na latência
if (duration > 5000) // > 5s
return HealthCheckResult.Unhealthy($"MongoDB response time too high: {duration}ms", data: data);
if (duration > 2000) // > 2s
return HealthCheckResult.Degraded($"MongoDB response time elevated: {duration}ms", data: data);
return HealthCheckResult.Healthy($"MongoDB is responsive ({duration}ms)", data: data);
}
catch (Exception ex)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(ex, "MongoDB health check failed after {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "database", _database.DatabaseNamespace.DatabaseName },
{ "connection_state", "disconnected" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"MongoDB connection failed: {ex.Message}", ex, data);
}
}
}

View File

@ -1,61 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using BCards.Web.Services;
namespace BCards.Web.HealthChecks;
public class OAuthProvidersHealthCheck : IHealthCheck
{
private readonly IOAuthHealthService _oauthHealthService;
private readonly ILogger<OAuthProvidersHealthCheck> _logger;
public OAuthProvidersHealthCheck(IOAuthHealthService oauthHealthService, ILogger<OAuthProvidersHealthCheck> logger)
{
_oauthHealthService = oauthHealthService;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var status = await _oauthHealthService.CheckOAuthProvidersAsync();
var data = new Dictionary<string, object>
{
{ "google_status", status.GoogleStatus },
{ "microsoft_status", status.MicrosoftStatus },
{ "all_providers_healthy", status.AllProvidersHealthy },
{ "any_provider_healthy", status.AnyProviderHealthy },
{ "checked_at", status.CheckedAt }
};
// Política de saúde: OAuth offline é DEGRADED, não UNHEALTHY
if (!status.AnyProviderHealthy)
{
_logger.LogError("🔴 CRÍTICO: Todos os OAuth providers estão offline - login totalmente indisponível!");
return HealthCheckResult.Degraded("Todos os OAuth providers estão offline", data: data);
}
if (!status.AllProvidersHealthy)
{
var offlineProviders = new List<string>();
if (!status.GoogleAvailable) offlineProviders.Add("Google");
if (!status.MicrosoftAvailable) offlineProviders.Add("Microsoft");
_logger.LogWarning("🟡 OAuth providers offline: {Providers} - alguns usuários não conseguem fazer login",
string.Join(", ", offlineProviders));
return HealthCheckResult.Degraded($"OAuth providers offline: {string.Join(", ", offlineProviders)}", data: data);
}
return HealthCheckResult.Healthy("Todos os OAuth providers estão funcionando", data: data);
}
catch (Exception ex)
{
_logger.LogError(ex, "🔴 Erro ao verificar saúde dos OAuth providers");
return HealthCheckResult.Degraded($"Erro na verificação OAuth: {ex.Message}");
}
}
}

View File

@ -1,95 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using SendGrid;
using SendGrid.Helpers.Mail;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class SendGridHealthCheck : IHealthCheck
{
private readonly ISendGridClient _sendGridClient;
private readonly ILogger<SendGridHealthCheck> _logger;
private readonly IConfiguration _configuration;
public SendGridHealthCheck(ISendGridClient sendGridClient, ILogger<SendGridHealthCheck> logger, IConfiguration configuration)
{
_sendGridClient = sendGridClient;
_logger = logger;
_configuration = configuration;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Testa a API do SendGrid fazendo uma validação de API key
// Usando endpoint de templates que não requer parâmetros específicos
var response = await _sendGridClient.RequestAsync(
method: SendGridClient.Method.GET,
urlPath: "templates",
queryParams: "{\"generations\":\"legacy,dynamic\",\"page_size\":1}",
cancellationToken: cancellationToken);
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
var apiKey = _configuration["SendGrid:ApiKey"];
var apiKeyPrefix = string.IsNullOrEmpty(apiKey) ? "not_configured" :
apiKey.Substring(0, Math.Min(8, apiKey.Length)) + "...";
_logger.LogInformation("SendGrid health check completed with status {StatusCode} in {Duration}ms",
response.StatusCode, duration);
var data = new Dictionary<string, object>
{
{ "status", response.IsSuccessStatusCode ? "healthy" : "unhealthy" },
{ "duration", $"{duration}ms" },
{ "status_code", (int)response.StatusCode },
{ "api_key_prefix", apiKeyPrefix },
{ "latency", duration }
};
// Verifica se a resposta foi bem-sucedida
if (response.IsSuccessStatusCode)
{
// Status baseado na latência
if (duration > 8000) // > 8s
return HealthCheckResult.Unhealthy($"SendGrid response time too high: {duration}ms", data: data);
if (duration > 4000) // > 4s
return HealthCheckResult.Degraded($"SendGrid response time elevated: {duration}ms", data: data);
return HealthCheckResult.Healthy($"SendGrid API is responsive ({duration}ms)", data: data);
}
else
{
data["error"] = $"HTTP {response.StatusCode}";
data["response_body"] = response.Body;
return HealthCheckResult.Unhealthy(
$"SendGrid API returned {response.StatusCode}",
data: data);
}
}
catch (Exception ex)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(ex, "SendGrid health check failed after {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"SendGrid connection failed: {ex.Message}", ex, data);
}
}
}

View File

@ -1,94 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using BCards.Web.Configuration;
using Stripe;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class StripeHealthCheck : IHealthCheck
{
private readonly StripeSettings _stripeSettings;
private readonly ILogger<StripeHealthCheck> _logger;
public StripeHealthCheck(IOptions<StripeSettings> stripeSettings, ILogger<StripeHealthCheck> logger)
{
_stripeSettings = stripeSettings.Value;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Configura Stripe temporariamente para o teste
StripeConfiguration.ApiKey = _stripeSettings.SecretKey;
// Testa conectividade listando produtos (limite 1 para ser rápido)
var productService = new ProductService();
var options = new ProductListOptions { Limit = 1 };
await productService.ListAsync(options, cancellationToken: cancellationToken);
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogInformation("Stripe health check completed successfully in {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "healthy" },
{ "duration", $"{duration}ms" },
{ "api_key_prefix", _stripeSettings.SecretKey?.Substring(0, Math.Min(12, _stripeSettings.SecretKey.Length)) + "..." },
{ "latency", duration }
};
// Status baseado na latência
if (duration > 10000) // > 10s
return HealthCheckResult.Unhealthy($"Stripe response time too high: {duration}ms", data: data);
if (duration > 5000) // > 5s
return HealthCheckResult.Degraded($"Stripe response time elevated: {duration}ms", data: data);
return HealthCheckResult.Healthy($"Stripe API is responsive ({duration}ms)", data: data);
}
catch (StripeException stripeEx)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(stripeEx, "Stripe health check failed after {Duration}ms: {Error}", duration, stripeEx.Message);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", stripeEx.Message },
{ "error_code", stripeEx.StripeError?.Code ?? "unknown" },
{ "error_type", stripeEx.StripeError?.Type ?? "unknown" }
};
return HealthCheckResult.Unhealthy($"Stripe API error: {stripeEx.Message}", stripeEx, data);
}
catch (Exception ex)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(ex, "Stripe health check failed after {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"Stripe connection failed: {ex.Message}", ex, data);
}
}
}

View File

@ -1,134 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class SystemResourcesHealthCheck : IHealthCheck
{
private readonly ILogger<SystemResourcesHealthCheck> _logger;
private static readonly DateTime _startTime = DateTime.UtcNow;
public SystemResourcesHealthCheck(ILogger<SystemResourcesHealthCheck> logger)
{
_logger = logger;
}
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Informações de memória
var totalMemory = GC.GetTotalMemory(false);
var workingSet = Environment.WorkingSet;
// Informações do processo atual
using var currentProcess = Process.GetCurrentProcess();
var cpuUsage = GetCpuUsage(currentProcess);
// Uptime
var uptime = DateTime.UtcNow - _startTime;
var uptimeString = FormatUptime(uptime);
// Thread count
var threadCount = currentProcess.Threads.Count;
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
var data = new Dictionary<string, object>
{
{ "status", "healthy" },
{ "duration", $"{duration}ms" },
{ "memory", new Dictionary<string, object>
{
{ "total_managed_mb", Math.Round(totalMemory / 1024.0 / 1024.0, 2) },
{ "working_set_mb", Math.Round(workingSet / 1024.0 / 1024.0, 2) },
{ "gc_generation_0", GC.CollectionCount(0) },
{ "gc_generation_1", GC.CollectionCount(1) },
{ "gc_generation_2", GC.CollectionCount(2) }
}
},
{ "process", new Dictionary<string, object>
{
{ "id", currentProcess.Id },
{ "threads", threadCount },
{ "handles", currentProcess.HandleCount },
{ "uptime", uptimeString },
{ "uptime_seconds", (int)uptime.TotalSeconds }
}
},
{ "system", new Dictionary<string, object>
{
{ "processor_count", Environment.ProcessorCount },
{ "os_version", Environment.OSVersion.ToString() },
{ "machine_name", Environment.MachineName },
{ "user_name", Environment.UserName }
}
}
};
_logger.LogInformation("System resources health check completed in {Duration}ms - Memory: {Memory}MB, Threads: {Threads}",
duration, Math.Round(totalMemory / 1024.0 / 1024.0, 1), threadCount);
// Definir thresholds para status
var memoryMb = totalMemory / 1024.0 / 1024.0;
if (memoryMb > 1000) // > 1GB
{
data["status"] = "degraded";
return Task.FromResult(HealthCheckResult.Degraded($"High memory usage: {memoryMb:F1}MB", data: data));
}
if (threadCount > 500)
{
data["status"] = "degraded";
return Task.FromResult(HealthCheckResult.Degraded($"High thread count: {threadCount}", data: data));
}
return Task.FromResult(HealthCheckResult.Healthy($"System resources normal (Memory: {memoryMb:F1}MB, Threads: {threadCount})", data: data));
}
catch (Exception ex)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(ex, "System resources health check failed after {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return Task.FromResult(HealthCheckResult.Unhealthy($"System resources check failed: {ex.Message}", ex, data));
}
}
private static double GetCpuUsage(Process process)
{
try
{
return process.TotalProcessorTime.TotalMilliseconds;
}
catch
{
return 0;
}
}
private static string FormatUptime(TimeSpan uptime)
{
if (uptime.TotalDays >= 1)
return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m";
if (uptime.TotalHours >= 1)
return $"{uptime.Hours}h {uptime.Minutes}m";
if (uptime.TotalMinutes >= 1)
return $"{uptime.Minutes}m {uptime.Seconds}s";
return $"{uptime.Seconds}s";
}
}

View File

@ -1,82 +0,0 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
namespace BCards.Web.Middleware
{
/// <summary>
/// Middleware para garantir que páginas que exibem conteúdo dependente de autenticação
/// tenham os headers de cache corretos para evitar problemas de cache do menu
/// </summary>
public class AuthCacheMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<AuthCacheMiddleware> _logger;
public AuthCacheMiddleware(RequestDelegate next, ILogger<AuthCacheMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
// Verificar se a resposta já começou antes de modificar headers
if (context.Response.HasStarted)
{
_logger.LogDebug("AuthCache: Response already started, skipping header modifications for {Path}",
context.Request.Path.Value);
return;
}
// Aplicar headers apenas para páginas HTML (não APIs, imagens, etc)
if (context.Response.ContentType?.StartsWith("text/html") == true)
{
var path = context.Request.Path.Value?.ToLower() ?? string.Empty;
// Páginas que sempre mostram menu com estado de autenticação
bool isPageWithAuthMenu = path == "/" ||
path.StartsWith("/home") ||
path == "/pricing" ||
path.StartsWith("/planos") ||
path.StartsWith("/admin") ||
path.StartsWith("/payment") ||
path.StartsWith("/subscription");
if (isPageWithAuthMenu)
{
// Se usuário está logado, garantir que não use cache
if (context.User?.Identity?.IsAuthenticated == true)
{
// Só adicionar se não foi definido explicitamente pelo controller
if (!context.Response.Headers.ContainsKey("Cache-Control"))
{
// Headers mais fortes para garantir que CDNs como Cloudflare não façam cache
context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate";
context.Response.Headers["Pragma"] = "no-cache";
context.Response.Headers["Expires"] = "0";
context.Response.Headers["Vary"] = "Cookie, Authorization";
_logger.LogDebug("AuthCache: Applied strong no-cache headers for authenticated user on {Path}", path);
}
}
else
{
// Para usuários não logados, garantir Vary: Cookie para cache adequado
if (!context.Response.Headers.ContainsKey("Vary") ||
!context.Response.Headers["Vary"].ToString().Contains("Cookie"))
{
var existingVary = context.Response.Headers["Vary"].ToString();
var newVary = string.IsNullOrEmpty(existingVary) ? "Cookie" : $"{existingVary}, Cookie";
context.Response.Headers["Vary"] = newVary;
_logger.LogDebug("AuthCache: Added Vary: Cookie for anonymous user on {Path}", path);
}
}
}
}
}
}
}

View File

@ -1,39 +0,0 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace BCards.Web.Middleware
{
public class OAuthCorrelationErrorHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<OAuthCorrelationErrorHandlerMiddleware> _logger;
public OAuthCorrelationErrorHandlerMiddleware(RequestDelegate next, ILogger<OAuthCorrelationErrorHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (AuthenticationFailureException ex)
when (ex.Message.Contains("Correlation failed"))
{
_logger.LogWarning(ex, "OAuth correlation failure detected for trace id {TraceId}. This often happens when the user's session expires on the login page before they complete the OAuth flow. Cleaning up and redirecting to login.", context.TraceIdentifier);
// Clean up the failed authentication attempt
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Redirect to a clean login page with an error message
context.Response.Redirect("/Auth/Login?error=session_expired");
}
}
}
}

View File

@ -8,10 +8,6 @@ public class PageStatusMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<PageStatusMiddleware> _logger;
private static readonly Regex UserPageRouteRegex = new(@"^/page/[a-z-]+/[a-z0-9-]+/?$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex RouteParametersRegex = new(@"^/page/([a-z-]+)/([a-z0-9-]+)/?$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public PageStatusMiddleware(RequestDelegate next, ILogger<PageStatusMiddleware> logger)
{
@ -34,8 +30,6 @@ public class PageStatusMiddleware
if (page != null)
{
var userId = page.UserId;
switch (page.Status)
{
case PageStatus.Expired:
@ -62,46 +56,16 @@ public class PageStatusMiddleware
case PageStatus.Rejected:
// Páginas em desenvolvimento/moderação requerem preview token
var previewToken = context.Request.Query["preview"].FirstOrDefault();
_logger.LogInformation($"User id: {userId} - Page {category}/{slug} (Status: {page.Status}) - Token provided: {!string.IsNullOrEmpty(previewToken)}, Page token: {!string.IsNullOrEmpty(page.PreviewToken)}, Expiry: {page.PreviewTokenExpiry}");
if (string.IsNullOrEmpty(previewToken))
if (string.IsNullOrEmpty(previewToken) ||
string.IsNullOrEmpty(page.PreviewToken) ||
previewToken != page.PreviewToken ||
page.PreviewTokenExpiry < DateTime.UtcNow)
{
_logger.LogInformation($"User id: {userId} - Page {category}/{slug} requires preview token - no token provided");
_logger.LogInformation($"Page {category}/{slug} requires valid preview token");
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito.");
return;
}
if (string.IsNullOrEmpty(page.PreviewToken))
{
_logger.LogWarning($"User id: {userId} - Page {category}/{slug} has no preview token set in database");
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito.");
return;
}
// LOG DETALHADO ANTES da comparação
_logger.LogInformation("Token comparison for page {PageId} - Provided: {ProvidedToken}, DB Token: {DbToken}, DB Expiry: {DbExpiry}",
page.Id, previewToken, page.PreviewToken, page.PreviewTokenExpiry);
if (previewToken != page.PreviewToken)
{
_logger.LogInformation($"User id: {userId} - Page {category}/{slug} preview token mismatch - provided: {previewToken}, expected: {page.PreviewToken}");
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito.");
return;
}
if (page.PreviewTokenExpiry < DateTime.UtcNow)
{
_logger.LogInformation($"User id: {userId} - Page {category}/{slug} preview token expired at {page.PreviewTokenExpiry} (now: {DateTime.UtcNow})");
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Token de preview expirado. Gere um novo token no painel.");
return;
}
_logger.LogInformation($"User id: {userId} - Page {category}/{slug} preview token validated successfully");
break;
case PageStatus.Active:
@ -110,7 +74,7 @@ public class PageStatusMiddleware
default:
// Status desconhecido - tratar como inativo
_logger.LogWarning($"User id: {userId} - Unknown page status: {page.Status} for page {category}/{slug}");
_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;
@ -129,13 +93,13 @@ public class PageStatusMiddleware
private static bool IsUserPageRoute(PathString path)
{
// Check if path matches pattern: /page/{category}/{slug} using compiled regex
return UserPageRouteRegex.IsMatch(path.Value ?? "");
// Check if path matches pattern: /page/{category}/{slug}
return Regex.IsMatch(path.Value ?? "", @"^/page/[a-z-]+/[a-z0-9-]+/?$", RegexOptions.IgnoreCase);
}
private static (string category, string slug) ExtractRouteParameters(PathString path)
{
var match = RouteParametersRegex.Match(path.Value ?? "");
var match = Regex.Match(path.Value ?? "", @"^/page/([a-z-]+)/([a-z0-9-]+)/?$", RegexOptions.IgnoreCase);
if (match.Success)
{

View File

@ -90,9 +90,7 @@ public class PlanLimitationMiddleware
AllowCustomDomain = false,
AllowMultipleDomains = false,
PrioritySupport = false,
PlanType = "basic",
AllowDocumentUpload = false,
MaxDocuments = 0
PlanType = "basic"
},
"professional" => new Models.PlanLimitations
{
@ -102,9 +100,7 @@ public class PlanLimitationMiddleware
AllowCustomDomain = true,
AllowMultipleDomains = false,
PrioritySupport = false,
PlanType = "professional",
AllowDocumentUpload = false,
MaxDocuments = 0
PlanType = "professional"
},
"premium" => new Models.PlanLimitations
{
@ -114,21 +110,7 @@ public class PlanLimitationMiddleware
AllowCustomDomain = true,
AllowMultipleDomains = true,
PrioritySupport = true,
PlanType = "premium",
AllowDocumentUpload = true,
MaxDocuments = 5
},
"premiumaffiliate" => new Models.PlanLimitations
{
MaxLinks = -1,
AllowCustomThemes = true,
AllowAnalytics = true,
AllowCustomDomain = true,
AllowMultipleDomains = true,
PrioritySupport = true,
PlanType = "premiumaffiliate",
AllowDocumentUpload = true,
MaxDocuments = 10
PlanType = "premium"
},
_ => new Models.PlanLimitations
{
@ -138,9 +120,7 @@ public class PlanLimitationMiddleware
AllowCustomDomain = false,
AllowMultipleDomains = false,
PrioritySupport = false,
PlanType = "free",
AllowDocumentUpload = false,
MaxDocuments = 0
PlanType = "free"
}
};
}

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

@ -1,39 +0,0 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace BCards.Web.Middleware
{
public class SessionTimeoutMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<SessionTimeoutMiddleware> _logger;
public SessionTimeoutMiddleware(RequestDelegate next, ILogger<SessionTimeoutMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
// Após a requisição, verifica se foi um 401 Unauthorized em uma chamada AJAX
if (context.Response.StatusCode == 401 && IsAjaxRequest(context.Request))
{
_logger.LogWarning("Session timeout detected for user {User} on path {Path}. AJAX request received 401, which may cause 503 errors on the client-side proxy.",
context.User.Identity?.Name ?? "Unknown",
context.Request.Path);
// Adiciona um header para o cliente (JavaScript) saber que precisa redirecionar
context.Response.Headers["X-Redirect-To"] = "/Auth/Login?reason=session_expired";
}
}
private bool IsAjaxRequest(HttpRequest request)
{
return "XMLHttpRequest".Equals(request.Headers["X-Requested-With"], System.StringComparison.Ordinal);
}
}
}

View File

@ -1,75 +0,0 @@
using System;
using System.Threading.Tasks;
using BCards.Web.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace BCards.Web.Middleware
{
public class SmartCacheMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<SmartCacheMiddleware> _logger;
public SmartCacheMiddleware(RequestDelegate next, ILogger<SmartCacheMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
// Aplica a lógica de cache apenas para as rotas de exibição de página
if (path.StartsWith("/page/", StringComparison.OrdinalIgnoreCase) && context.Request.Method == HttpMethods.Get)
{
var pathSegments = path.Trim('/').Split('/');
if (pathSegments.Length == 3 && pathSegments[0].Equals("page", StringComparison.OrdinalIgnoreCase))
{
var category = pathSegments[1];
var slug = pathSegments[2];
var isPreview = context.Request.Query.ContainsKey("preview");
// Para preview, NUNCA usar cache
if (isPreview)
{
_logger.LogInformation("SmartCache: Applying NO-CACHE for preview page {Category}/{Slug}", category, slug);
SetNoCacheHeaders(context.Response);
}
else
{
// Para páginas públicas, a existência de uma LivePage significa que ela está ativa e pode ser cacheada.
var livePageService = context.RequestServices.GetRequiredService<ILivePageService>();
var livePage = await livePageService.GetByCategoryAndSlugAsync(category, slug);
if (livePage != null)
{
// --- CENÁRIO 1: PÁGINA LIVE (ATIVA) ---
_logger.LogInformation("SmartCache: Applying 1-hour PUBLIC cache for live page {Category}/{Slug}", category, slug);
context.Response.Headers["Cache-Control"] = "public, max-age=3600";
context.Response.Headers.Append("Vary", "Accept-Encoding");
}
else
{
// --- CENÁRIO 2: PÁGINA NÃO-ATIVA OU NÃO ENCONTRADA ---
// A requisição vai para o UserPageController, que não deve ter cache.
_logger.LogInformation("SmartCache: Applying NO-CACHE for non-live page {Category}/{Slug}", category, slug);
SetNoCacheHeaders(context.Response);
}
}
}
}
await _next(context);
}
private void SetNoCacheHeaders(HttpResponse response)
{
response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
response.Headers["Pragma"] = "no-cache";
response.Headers["Expires"] = "0";
}
}
}

View File

@ -16,7 +16,6 @@
string BusinessType { get; }
PageTheme Theme { get; }
List<LinkItem> Links { get; }
List<PageDocument> Documents { get; }
SeoSettings SeoSettings { get; }
string Language { get; }
DateTime CreatedAt { get; }

View File

@ -47,9 +47,6 @@ public class LivePage : IPageDisplay
[BsonElement("links")]
public List<LinkItem> Links { get; set; } = new();
[BsonElement("documents")]
public List<PageDocument> Documents { get; set; } = new();
[BsonElement("seoSettings")]
public SeoSettings SeoSettings { get; set; } = new();

View File

@ -1,30 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class PageDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
[BsonElement("fileId")]
[BsonRepresentation(BsonType.ObjectId)]
public string FileId { get; set; } = string.Empty;
[BsonElement("title")]
public string Title { get; set; } = string.Empty;
[BsonElement("description")]
public string Description { get; set; } = string.Empty;
[BsonElement("fileName")]
public string FileName { get; set; } = string.Empty;
[BsonElement("fileSize")]
public long FileSize { get; set; }
[BsonElement("uploadedAt")]
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -43,10 +43,4 @@ public class PlanLimitations
[BsonElement("lastExtractionDate")]
public DateTime? LastExtractionDate { get; set; }
[BsonElement("allowDocumentUpload")]
public bool AllowDocumentUpload { get; set; } = false;
[BsonElement("maxDocuments")]
public int MaxDocuments { get; set; } = 0;
}

View File

@ -3,10 +3,9 @@ namespace BCards.Web.Models;
public enum PlanType
{
Trial = 0, // Gratuito por 7 dias
Basic = 1,
Professional = 2,
Premium = 4,
PremiumAffiliate = 5
Basic = 1, // R$ 9,90
Professional = 2, // R$ 24,90 (Decoy)
Premium = 3 // R$ 29,90
}
public static class PlanTypeExtensions
@ -19,28 +18,22 @@ public static class PlanTypeExtensions
PlanType.Basic => "Básico",
PlanType.Professional => "Profissional",
PlanType.Premium => "Premium",
PlanType.PremiumAffiliate => "Premium+Afiliados",
_ => "Desconhecido"
};
}
// NOTA: Preços agora são configurados dinamicamente via IPlanConfigurationService
// Este método mantém valores fallback para compatibilidade
public static decimal GetPrice(this PlanType planType)
{
return planType switch
{
PlanType.Trial => 0.00m,
PlanType.Basic => 12.90m,
PlanType.Professional => 25.90m,
PlanType.Basic => 9.90m,
PlanType.Professional => 24.90m,
PlanType.Premium => 29.90m,
PlanType.PremiumAffiliate => 34.90m,
_ => 0.00m
};
}
// NOTA: Limitações agora são configuradas dinamicamente via IPlanConfigurationService
// Este método mantém valores fallback para compatibilidade
public static int GetMaxPages(this PlanType planType)
{
return planType switch
@ -49,13 +42,10 @@ public static class PlanTypeExtensions
PlanType.Basic => 3,
PlanType.Professional => 5, // DECOY - not attractive
PlanType.Premium => 15,
PlanType.PremiumAffiliate => 15,
_ => 1
};
}
// NOTA: Limitações agora são configuradas dinamicamente via IPlanConfigurationService
// Este método mantém valores fallback para compatibilidade
public static int GetMaxLinksPerPage(this PlanType planType)
{
return planType switch
@ -64,7 +54,6 @@ public static class PlanTypeExtensions
PlanType.Basic => 8,
PlanType.Professional => 20, // DECOY - too expensive for the benefit
PlanType.Premium => int.MaxValue, // Unlimited
PlanType.PremiumAffiliate => int.MaxValue, // Unlimited
_ => 3
};
}
@ -82,7 +71,6 @@ public static class PlanTypeExtensions
PlanType.Basic => true,
PlanType.Professional => true,
PlanType.Premium => true,
PlanType.PremiumAffiliate => true,
_ => false
};
}
@ -95,7 +83,6 @@ public static class PlanTypeExtensions
PlanType.Basic => false,
PlanType.Professional => true,
PlanType.Premium => true,
PlanType.PremiumAffiliate => true,
_ => false
};
}
@ -109,11 +96,10 @@ public static class PlanTypeExtensions
{
return planType switch
{
PlanType.Trial => 0, // 1 link de produto para trial
PlanType.Basic => 0, // 3 links de produto
PlanType.Professional => 0, // DECOY - mais caro para poucos benefícios
PlanType.Premium => 0, // Ilimitado
PlanType.PremiumAffiliate => int.MaxValue, // Ilimitado
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
};
}
@ -122,11 +108,10 @@ public static class PlanTypeExtensions
{
return planType switch
{
PlanType.Trial => 0, // 2 extrações por dia no trial
PlanType.Basic => 0, // 5 extrações por dia
PlanType.Professional => 0, // 15 extrações por dia
PlanType.Premium => 0, // Ilimitado
PlanType.PremiumAffiliate => int.MaxValue, // Ilimitado
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
};
}

View File

@ -44,9 +44,6 @@ public class UserPage : IPageDisplay
[BsonElement("links")]
public List<LinkItem> Links { get; set; } = new();
[BsonElement("documents")]
public List<PageDocument> Documents { get; set; } = new();
[BsonElement("seoSettings")]
public SeoSettings SeoSettings { get; set; } = new();
@ -95,18 +92,6 @@ public class UserPage : IPageDisplay
[BsonElement("previewViewCount")]
public int PreviewViewCount { get; set; } = 0;
// Exclusão lógica
[BsonElement("deletedAt")]
[BsonIgnoreIfDefault]
public DateTime? DeletedAt { get; set; }
[BsonElement("deletionReason")]
[BsonIgnoreIfDefault]
public string? DeletionReason { get; set; } // "trial_expired", "user_requested", "moderation_violation"
[BsonIgnore]
public bool IsDeleted => DeletedAt.HasValue;
public string FullUrl => $"page/{Category}/{Slug}";
/// <summary>

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