Merge pull request 'feat/live-preview' (#1) from feat/live-preview into Release/V0.0.3
Reviewed-on: http://git.carneiro.ddnsfree.com/ricardo/BCards/pulls/1
This commit is contained in:
commit
296d161778
@ -15,7 +15,11 @@
|
|||||||
"Bash(rg:*)",
|
"Bash(rg:*)",
|
||||||
"Bash(pkill:*)",
|
"Bash(pkill:*)",
|
||||||
"Bash(sudo rm:*)",
|
"Bash(sudo rm:*)",
|
||||||
"Bash(rm:*)"
|
"Bash(rm:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(docker-compose up:*)",
|
||||||
|
"Bash(dotnet build:*)",
|
||||||
|
"Bash(chmod:*)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enableAllProjectMcpServers": false
|
"enableAllProjectMcpServers": false
|
||||||
|
|||||||
166
.gitea/workflows/release-deploy.yml
Normal file
166
.gitea/workflows/release-deploy.yml
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
name: Release Deployment Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'Release/*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: registry.redecarneir.us
|
||||||
|
IMAGE_NAME: bcards
|
||||||
|
MONGODB_HOST: 192.168.0.100:27017
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET 8
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
run: dotnet build --no-restore --configuration Release
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage"
|
||||||
|
|
||||||
|
- name: Test MongoDB connection
|
||||||
|
run: |
|
||||||
|
echo "Testing MongoDB connection to $MONGODB_HOST"
|
||||||
|
timeout 10 bash -c "</dev/tcp/192.168.0.100/27017" && echo "MongoDB connection successful" || echo "MongoDB connection failed"
|
||||||
|
|
||||||
|
build-and-deploy:
|
||||||
|
name: Build Multi-Arch Image and Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
|
- name: Extract branch name and version
|
||||||
|
id: extract_branch
|
||||||
|
run: |
|
||||||
|
BRANCH_NAME=${GITHUB_REF#refs/heads/}
|
||||||
|
VERSION=${BRANCH_NAME#Release/}
|
||||||
|
COMMIT_SHA=${GITHUB_SHA::7}
|
||||||
|
echo "branch=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "commit=$COMMIT_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "tag=$VERSION-$COMMIT_SHA" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push multi-arch Docker image
|
||||||
|
run: |
|
||||||
|
echo "Building multi-arch image for platforms: linux/amd64,linux/arm64"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--file Dockerfile.release \
|
||||||
|
--tag $REGISTRY/$IMAGE_NAME:${{ steps.extract_branch.outputs.tag }} \
|
||||||
|
--tag $REGISTRY/$IMAGE_NAME:${{ steps.extract_branch.outputs.version }}-latest \
|
||||||
|
--tag $REGISTRY/$IMAGE_NAME:release-latest \
|
||||||
|
--push \
|
||||||
|
--build-arg BUILDPLATFORM=linux/amd64 \
|
||||||
|
--build-arg VERSION=${{ steps.extract_branch.outputs.version }} \
|
||||||
|
--build-arg COMMIT=${{ steps.extract_branch.outputs.commit }} \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Deploy to staging environment
|
||||||
|
run: |
|
||||||
|
echo "Deploying to staging environment..."
|
||||||
|
|
||||||
|
# Create deployment directory
|
||||||
|
sudo mkdir -p /opt/bcards-staging
|
||||||
|
|
||||||
|
# Copy docker-compose file
|
||||||
|
sudo cp docker-compose.staging.yml /opt/bcards-staging/
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
sudo tee /opt/bcards-staging/.env > /dev/null <<EOF
|
||||||
|
IMAGE_TAG=${{ steps.extract_branch.outputs.tag }}
|
||||||
|
MONGODB_CONNECTION_STRING=mongodb://$MONGODB_HOST/BCardsDB
|
||||||
|
ASPNETCORE_ENVIRONMENT=Release
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Run deployment script
|
||||||
|
chmod +x scripts/deploy-release.sh
|
||||||
|
sudo ./scripts/deploy-release.sh ${{ steps.extract_branch.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Health check and validation
|
||||||
|
run: |
|
||||||
|
echo "Running health checks..."
|
||||||
|
|
||||||
|
# Wait for application to start
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
# Test application health
|
||||||
|
for i in {1..10}; do
|
||||||
|
if curl -f http://localhost:8090/health > /dev/null 2>&1; then
|
||||||
|
echo "Application health check passed"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Health check attempt $i failed, retrying in 10 seconds..."
|
||||||
|
sleep 10
|
||||||
|
if [ $i -eq 10 ]; then
|
||||||
|
echo "Health check failed after 10 attempts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Test MongoDB connectivity from application
|
||||||
|
chmod +x scripts/test-mongodb-connection.sh
|
||||||
|
./scripts/test-mongodb-connection.sh
|
||||||
|
|
||||||
|
- name: Deployment notification
|
||||||
|
run: |
|
||||||
|
echo "🚀 Deployment Status: SUCCESS"
|
||||||
|
echo "📦 Image: $REGISTRY/$IMAGE_NAME:${{ steps.extract_branch.outputs.tag }}"
|
||||||
|
echo "🌐 Environment: Staging"
|
||||||
|
echo "🔗 MongoDB: $MONGODB_HOST"
|
||||||
|
echo "🏗️ Architecture: Multi-arch (linux/amd64, linux/arm64)"
|
||||||
|
echo "📋 Branch: ${{ steps.extract_branch.outputs.branch }}"
|
||||||
|
echo "🆔 Commit: ${{ steps.extract_branch.outputs.commit }}"
|
||||||
|
|
||||||
|
rollback:
|
||||||
|
name: Rollback on Failure
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test, build-and-deploy]
|
||||||
|
if: failure()
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Rollback deployment
|
||||||
|
run: |
|
||||||
|
echo "🚨 Deployment failed, initiating rollback..."
|
||||||
|
|
||||||
|
# Stop current containers
|
||||||
|
cd /opt/bcards-staging
|
||||||
|
sudo docker-compose down
|
||||||
|
|
||||||
|
# Restore previous version if exists
|
||||||
|
if [ -f .env.backup ]; then
|
||||||
|
sudo mv .env.backup .env
|
||||||
|
sudo docker-compose up -d
|
||||||
|
echo "✅ Rollback completed to previous version"
|
||||||
|
else
|
||||||
|
echo "❌ No previous version found for rollback"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Failure notification
|
||||||
|
run: |
|
||||||
|
echo "❌ Deployment Status: FAILED"
|
||||||
|
echo "🔄 Rollback: Initiated"
|
||||||
|
echo "📋 Branch: ${GITHUB_REF#refs/heads/}"
|
||||||
|
echo "🆔 Commit: ${GITHUB_SHA::7}"
|
||||||
20
BCards.sln
20
BCards.sln
@ -4,7 +4,14 @@ VisualStudioVersion = 17.0.31903.59
|
|||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Tests", "tests\BCards.Tests\BCards.Tests.csproj", "{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.IntegrationTests", "src\BCards.IntegrationTests\BCards.IntegrationTests.csproj", "{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scripts", "Scripts", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
|
||||||
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
scripts\deploy-release.sh = scripts\deploy-release.sh
|
||||||
|
scripts\init-mongo.js = scripts\init-mongo.js
|
||||||
|
scripts\test-mongodb-connection.sh = scripts\test-mongodb-connection.sh
|
||||||
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@ -16,12 +23,15 @@ Global
|
|||||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
|
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.Build.0 = Release|Any CPU
|
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {3DA87F09-8B78-450D-9EF8-A0C0E02F0E04}
|
||||||
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
228
CLAUDE.md
Normal file
228
CLAUDE.md
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
BCards is a professional LinkTree clone built in ASP.NET Core MVC, targeting Brazilian and Spanish markets. It provides hierarchical URLs organized by business categories (/page/{category}/{slug}), integrated Stripe payments, and a sophisticated moderation system with preview tokens.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Build & Run
|
||||||
|
```bash
|
||||||
|
# Restore dependencies
|
||||||
|
dotnet restore
|
||||||
|
|
||||||
|
# Build solution
|
||||||
|
dotnet build
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
cd src/BCards.Web
|
||||||
|
dotnet run
|
||||||
|
# Access: https://localhost:49178
|
||||||
|
|
||||||
|
# Run with Docker
|
||||||
|
docker-compose up -d
|
||||||
|
# Access: http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
dotnet test
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
dotnet test --collect:"XPlat Code Coverage"
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
dotnet test --filter "TestClassName"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Technology Stack
|
||||||
|
- **Framework**: ASP.NET Core MVC (.NET 8)
|
||||||
|
- **Database**: MongoDB 7.0 with MongoDB.Driver 2.25.0
|
||||||
|
- **Authentication**: OAuth 2.0 (Google + Microsoft)
|
||||||
|
- **Payments**: Stripe.NET 44.7.0
|
||||||
|
- **Frontend**: Bootstrap 5.3.2, jQuery 3.7.1, vanilla JavaScript
|
||||||
|
- **Email**: SendGrid 9.29.3
|
||||||
|
- **Containerization**: Docker + Docker Compose
|
||||||
|
|
||||||
|
### Core Architecture Patterns
|
||||||
|
- **MVC Pattern**: Controllers handle HTTP requests, Views render UI, Models represent data
|
||||||
|
- **Repository Pattern**: Data access abstraction via IUserPageRepository, ICategoryRepository, etc.
|
||||||
|
- **Service Layer**: Business logic encapsulation (IUserPageService, IModerationService, etc.)
|
||||||
|
- **Dependency Injection**: Built-in ASP.NET Core DI container
|
||||||
|
- **Domain-Driven Design**: Rich domain models with business logic
|
||||||
|
|
||||||
|
### Key Business Logic
|
||||||
|
|
||||||
|
#### Page Status System
|
||||||
|
Pages follow this lifecycle with explicit numeric enum values:
|
||||||
|
- `Creating = 6`: Development phase, requires preview tokens
|
||||||
|
- `PendingModeration = 4`: Submitted for approval, requires preview tokens
|
||||||
|
- `Rejected = 5`: Failed moderation, requires preview tokens
|
||||||
|
- `Active = 0`: Live and publicly accessible
|
||||||
|
- `Inactive = 3`: Paused by user
|
||||||
|
- `Expired = 1`: Trial expired, redirects to pricing
|
||||||
|
- `PendingPayment = 2`: Payment overdue, shows warning
|
||||||
|
|
||||||
|
#### Moderation System
|
||||||
|
- Content approval workflow with attempts tracking
|
||||||
|
- Preview tokens with 4-hour expiration for non-Active pages
|
||||||
|
- Email notifications for status changes
|
||||||
|
- Automatic status transitions based on user actions
|
||||||
|
|
||||||
|
#### Pricing Strategy
|
||||||
|
Three-tier system with psychological pricing (decoy effect):
|
||||||
|
- Basic (R$ 9.90/mês): 5 links, basic themes
|
||||||
|
- Professional (R$ 24.90/mês): 15 links, all themes - *DECOY*
|
||||||
|
- Premium (R$ 29.90/mês): Unlimited links, custom themes
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
```
|
||||||
|
src/BCards.Web/
|
||||||
|
├── Controllers/ # MVC Controllers (9 total)
|
||||||
|
│ ├── AdminController # User dashboard and page management
|
||||||
|
│ ├── AuthController # OAuth authentication
|
||||||
|
│ ├── HomeController # Public pages and landing
|
||||||
|
│ ├── PaymentController # Stripe integration
|
||||||
|
│ ├── ModerationController # Content approval system
|
||||||
|
│ └── UserPageController # Public page display
|
||||||
|
├── Models/ # Domain entities (12 total)
|
||||||
|
│ ├── User # Authentication and subscriptions
|
||||||
|
│ ├── UserPage # Main business card entity
|
||||||
|
│ ├── LinkItem # Individual links with analytics
|
||||||
|
│ └── Category # Business categories
|
||||||
|
├── Services/ # Business logic (20 services)
|
||||||
|
│ ├── IUserPageService # Core page operations
|
||||||
|
│ ├── IModerationService # Content approval
|
||||||
|
│ ├── IAuthService # Authentication
|
||||||
|
│ └── IPaymentService # Stripe integration
|
||||||
|
├── Repositories/ # Data access (8 repositories)
|
||||||
|
├── ViewModels/ # View-specific models
|
||||||
|
├── Middleware/ # Custom middleware (4 pieces)
|
||||||
|
│ ├── PageStatusMiddleware # Handles page access by status
|
||||||
|
│ ├── ModerationAuthMiddleware # Admin access control
|
||||||
|
│ └── PreviewTokenMiddleware # Preview token validation
|
||||||
|
└── Views/ # Razor templates with Bootstrap 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Design (MongoDB)
|
||||||
|
|
||||||
|
#### Core Collections
|
||||||
|
- `users`: Authentication, subscription status, Stripe customer data
|
||||||
|
- `userpages`: Business cards with status, links, themes, moderation history
|
||||||
|
- `categories`: Business categories with SEO metadata
|
||||||
|
- `subscriptions`: Stripe subscription tracking
|
||||||
|
|
||||||
|
#### Important Indexes
|
||||||
|
- Compound: `{category: 1, slug: 1}` for page lookups
|
||||||
|
- User pages: `{userId: 1, status: 1}` for dashboard filtering
|
||||||
|
- Active pages: `{status: 1, category: 1}` for public listings
|
||||||
|
|
||||||
|
### Key Features Implementation
|
||||||
|
|
||||||
|
#### Preview Token System
|
||||||
|
Non-Active pages require preview tokens for access:
|
||||||
|
```csharp
|
||||||
|
// Generate fresh token (4-hour expiry)
|
||||||
|
POST /Admin/GeneratePreviewToken/{id}
|
||||||
|
|
||||||
|
// Access page with token
|
||||||
|
GET /page/{category}/{slug}?preview={token}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### OAuth Integration
|
||||||
|
Supports Google and Microsoft OAuth with automatic user creation and session management.
|
||||||
|
|
||||||
|
#### Stripe Payment Flow
|
||||||
|
Complete subscription lifecycle:
|
||||||
|
1. Checkout session creation
|
||||||
|
2. Webhook handling for events
|
||||||
|
3. Subscription status updates
|
||||||
|
4. Plan limitation enforcement
|
||||||
|
|
||||||
|
#### Dynamic Theming
|
||||||
|
CSS generation system with customizable colors, backgrounds, and layouts based on user's plan limitations.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"MongoDb": {
|
||||||
|
"ConnectionString": "mongodb://localhost:27017",
|
||||||
|
"DatabaseName": "BCardsDB"
|
||||||
|
},
|
||||||
|
"Stripe": {
|
||||||
|
"PublishableKey": "pk_test_...",
|
||||||
|
"SecretKey": "sk_test_...",
|
||||||
|
"WebhookSecret": "whsec_..."
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Google": { "ClientId": "...", "ClientSecret": "..." },
|
||||||
|
"Microsoft": { "ClientId": "...", "ClientSecret": "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
1. Install .NET 8 SDK, MongoDB 4.4+
|
||||||
|
2. Configure OAuth credentials (Google Cloud Console, Azure Portal)
|
||||||
|
3. Set up Stripe account with test keys
|
||||||
|
4. Configure webhook endpoints for `/webhook/stripe`
|
||||||
|
|
||||||
|
## Important Implementation Notes
|
||||||
|
|
||||||
|
### Page Status Middleware
|
||||||
|
`PageStatusMiddleware` intercepts all `/page/{category}/{slug}` requests and enforces access rules based on page status. Non-Active pages require valid preview tokens.
|
||||||
|
|
||||||
|
### Moderation Workflow
|
||||||
|
1. Pages start as `Creating` status
|
||||||
|
2. Users click "Submit for Moderation" → `PendingModeration`
|
||||||
|
3. Moderators approve/reject → `Active` or `Rejected`
|
||||||
|
4. Rejected pages can be edited and resubmitted
|
||||||
|
|
||||||
|
### Preview Token Security
|
||||||
|
- Tokens expire after 4 hours
|
||||||
|
- Generated on-demand via AJAX calls
|
||||||
|
- Required for Creating, PendingModeration, and Rejected pages
|
||||||
|
- Validated by middleware before page access
|
||||||
|
|
||||||
|
### Plan Limitations
|
||||||
|
Enforced throughout the application:
|
||||||
|
- Link count limits per plan
|
||||||
|
- Theme availability restrictions
|
||||||
|
- Analytics access control
|
||||||
|
- Page creation limits
|
||||||
|
|
||||||
|
## Common Development Patterns
|
||||||
|
|
||||||
|
### Repository Usage
|
||||||
|
```csharp
|
||||||
|
var page = await _userPageService.GetPageByIdAsync(id);
|
||||||
|
await _userPageService.UpdatePageAsync(page);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Layer Pattern
|
||||||
|
Business logic resides in services, not controllers:
|
||||||
|
```csharp
|
||||||
|
public class UserPageService : IUserPageService
|
||||||
|
{
|
||||||
|
private readonly IUserPageRepository _repository;
|
||||||
|
// Implementation with business rules
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status-Based Logic
|
||||||
|
Always check page status before operations:
|
||||||
|
```csharp
|
||||||
|
if (page.Status == PageStatus.Creating || page.Status == PageStatus.Rejected)
|
||||||
|
{
|
||||||
|
// Allow editing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This architecture supports a production-ready SaaS application with complex business rules, payment integration, and content moderation workflows.
|
||||||
123
Dockerfile.release
Normal file
123
Dockerfile.release
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
# Dockerfile.release - Multi-architecture build for Release environment
|
||||||
|
# Supports: linux/amd64, linux/arm64
|
||||||
|
|
||||||
|
ARG BUILDPLATFORM=linux/amd64
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
ARG VERSION=unknown
|
||||||
|
ARG COMMIT=unknown
|
||||||
|
|
||||||
|
# Base runtime image with multi-arch support
|
||||||
|
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
EXPOSE 8443
|
||||||
|
|
||||||
|
# Install dependencies based on target platform
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
libgdiplus \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& apt-get clean
|
||||||
|
|
||||||
|
# Create application directories
|
||||||
|
RUN mkdir -p /app/uploads /app/logs \
|
||||||
|
&& chmod 755 /app/uploads /app/logs
|
||||||
|
|
||||||
|
# Build stage - use build platform for compilation
|
||||||
|
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
ARG VERSION
|
||||||
|
ARG COMMIT
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy project files and restore dependencies
|
||||||
|
COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"]
|
||||||
|
RUN dotnet restore "src/BCards.Web/BCards.Web.csproj" \
|
||||||
|
--runtime $(echo $TARGETPLATFORM | tr '/' '-')
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/src/BCards.Web"
|
||||||
|
|
||||||
|
# Build application with Release configuration
|
||||||
|
RUN dotnet build "BCards.Web.csproj" \
|
||||||
|
-c Release \
|
||||||
|
-o /app/build \
|
||||||
|
--no-restore \
|
||||||
|
--runtime $(echo $TARGETPLATFORM | tr '/' '-') \
|
||||||
|
-p:Version=$VERSION \
|
||||||
|
-p:InformationalVersion=$COMMIT
|
||||||
|
|
||||||
|
# Publish stage - optimize for target platform
|
||||||
|
FROM build AS publish
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
ARG VERSION
|
||||||
|
ARG COMMIT
|
||||||
|
|
||||||
|
RUN dotnet publish "BCards.Web.csproj" \
|
||||||
|
-c Release \
|
||||||
|
-o /app/publish \
|
||||||
|
--no-restore \
|
||||||
|
--no-build \
|
||||||
|
--runtime $(echo $TARGETPLATFORM | tr '/' '-') \
|
||||||
|
--self-contained false \
|
||||||
|
-p:PublishReadyToRun=true \
|
||||||
|
-p:PublishSingleFile=false \
|
||||||
|
-p:UseAppHost=false \
|
||||||
|
-p:Version=$VERSION \
|
||||||
|
-p:InformationalVersion=$COMMIT
|
||||||
|
|
||||||
|
# Final stage - runtime optimized for Release environment
|
||||||
|
FROM base AS final
|
||||||
|
ARG VERSION=unknown
|
||||||
|
ARG COMMIT=unknown
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
|
# Metadata labels
|
||||||
|
LABEL maintainer="BCards Team"
|
||||||
|
LABEL version=$VERSION
|
||||||
|
LABEL commit=$COMMIT
|
||||||
|
LABEL platform=$TARGETPLATFORM
|
||||||
|
LABEL environment="release"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy published application
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN groupadd -r bcards && useradd -r -g bcards bcards \
|
||||||
|
&& chown -R bcards:bcards /app \
|
||||||
|
&& chmod +x /app/BCards.Web.dll
|
||||||
|
|
||||||
|
# Environment variables for Release
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Release
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV DOTNET_RUNNING_IN_CONTAINER=true
|
||||||
|
ENV DOTNET_EnableDiagnostics=0
|
||||||
|
ENV DOTNET_USE_POLLING_FILE_WATCHER=true
|
||||||
|
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||||
|
|
||||||
|
# ARM64 specific optimizations
|
||||||
|
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||||
|
echo "Applying ARM64 optimizations..." && \
|
||||||
|
export DOTNET_TieredPGO=1 && \
|
||||||
|
export DOTNET_TC_QuickJitForLoops=1 && \
|
||||||
|
export DOTNET_ReadyToRun=0; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER bcards
|
||||||
|
|
||||||
|
# Entry point with optimized runtime settings
|
||||||
|
ENTRYPOINT ["dotnet", "BCards.Web.dll"]
|
||||||
|
|
||||||
|
# Runtime configuration for better performance
|
||||||
|
CMD ["--urls", "http://0.0.0.0:8080"]
|
||||||
160
docker-compose.staging.yml
Normal file
160
docker-compose.staging.yml
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
bcards-web:
|
||||||
|
image: ${REGISTRY:-registry.redecarneir.us}/bcards:${IMAGE_TAG:-release-latest}
|
||||||
|
container_name: bcards-staging
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8090:8080"
|
||||||
|
- "8453:8443"
|
||||||
|
environment:
|
||||||
|
# Core ASP.NET Configuration
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Release
|
||||||
|
- ASPNETCORE_URLS=http://+:8080
|
||||||
|
- ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
|
||||||
|
|
||||||
|
# MongoDB Configuration
|
||||||
|
- MongoDb__ConnectionString=${MONGODB_CONNECTION_STRING:-mongodb://192.168.0.100:27017/BCardsDB}
|
||||||
|
- MongoDb__DatabaseName=BCardsDB
|
||||||
|
|
||||||
|
# Application Settings
|
||||||
|
- AppSettings__Environment=Staging
|
||||||
|
- AppSettings__Version=${IMAGE_TAG:-unknown}
|
||||||
|
- AppSettings__AllowedHosts=*
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
- Logging__LogLevel__Default=Information
|
||||||
|
- Logging__LogLevel__Microsoft.AspNetCore=Warning
|
||||||
|
- Logging__LogLevel__BCards=Debug
|
||||||
|
|
||||||
|
# Performance Optimizations
|
||||||
|
- DOTNET_RUNNING_IN_CONTAINER=true
|
||||||
|
- DOTNET_EnableDiagnostics=0
|
||||||
|
- DOTNET_USE_POLLING_FILE_WATCHER=true
|
||||||
|
- DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||||
|
- DOTNET_TieredPGO=1
|
||||||
|
- DOTNET_TC_QuickJitForLoops=1
|
||||||
|
|
||||||
|
# Security Headers
|
||||||
|
- ASPNETCORE_HTTPS_PORT=8443
|
||||||
|
- ASPNETCORE_Kestrel__Certificates__Default__Path=/app/certs/cert.pfx
|
||||||
|
- ASPNETCORE_Kestrel__Certificates__Default__Password=${CERT_PASSWORD:-}
|
||||||
|
|
||||||
|
# Redis Configuration (if needed)
|
||||||
|
- Redis__ConnectionString=localhost:6379
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Application logs
|
||||||
|
- ./logs:/app/logs:rw
|
||||||
|
|
||||||
|
# File uploads (if needed)
|
||||||
|
- ./uploads:/app/uploads:rw
|
||||||
|
|
||||||
|
# SSL certificates (if using HTTPS)
|
||||||
|
# - ./certs:/app/certs:ro
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- bcards-staging-network
|
||||||
|
|
||||||
|
# Health check configuration
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
# Resource limits for staging environment
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1.0'
|
||||||
|
reservations:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '0.5'
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "100m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
# Platform specification (will use the appropriate arch from multi-arch image)
|
||||||
|
# platform: linux/amd64 # Uncomment if forcing specific architecture
|
||||||
|
|
||||||
|
# Security options
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
read_only: false # Set to true for extra security, but may need volume mounts for temp files
|
||||||
|
|
||||||
|
# Process limits
|
||||||
|
ulimits:
|
||||||
|
nproc: 65535
|
||||||
|
nofile:
|
||||||
|
soft: 65535
|
||||||
|
hard: 65535
|
||||||
|
|
||||||
|
# Optional: Redis for caching (if application uses it)
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: bcards-redis-staging
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_staging_data:/data
|
||||||
|
networks:
|
||||||
|
- bcards-staging-network
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.5'
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# Optional: Nginx reverse proxy for additional features
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: bcards-nginx-staging
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8091:80"
|
||||||
|
- "8454:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/staging.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./nginx/ssl:/etc/ssl/certs:ro
|
||||||
|
- ./logs/nginx:/var/log/nginx:rw
|
||||||
|
depends_on:
|
||||||
|
- bcards-web
|
||||||
|
networks:
|
||||||
|
- bcards-staging-network
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
cpus: '0.25'
|
||||||
|
|
||||||
|
# Named volumes for persistent data
|
||||||
|
volumes:
|
||||||
|
redis_staging_data:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: ./data/redis
|
||||||
|
|
||||||
|
# Network for staging environment
|
||||||
|
networks:
|
||||||
|
bcards-staging-network:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.20.0.0/16
|
||||||
369
scripts/deploy-release.sh
Normal file
369
scripts/deploy-release.sh
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Deploy script for Release environment with multi-architecture support
|
||||||
|
# Usage: ./deploy-release.sh <IMAGE_TAG>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
readonly DEPLOY_DIR="/opt/bcards-staging"
|
||||||
|
readonly DOCKER_COMPOSE_FILE="docker-compose.staging.yml"
|
||||||
|
readonly CONTAINER_NAME="bcards-staging"
|
||||||
|
readonly HEALTH_CHECK_URL="http://localhost:8090/health"
|
||||||
|
readonly MAX_HEALTH_CHECK_ATTEMPTS=10
|
||||||
|
readonly HEALTH_CHECK_INTERVAL=10
|
||||||
|
readonly ROLLBACK_TIMEOUT=300
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
readonly RED='\033[0;31m'
|
||||||
|
readonly GREEN='\033[0;32m'
|
||||||
|
readonly YELLOW='\033[1;33m'
|
||||||
|
readonly BLUE='\033[0;34m'
|
||||||
|
readonly NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging functions
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup function
|
||||||
|
cleanup() {
|
||||||
|
local exit_code=$?
|
||||||
|
if [ $exit_code -ne 0 ]; then
|
||||||
|
log_error "Deployment failed with exit code $exit_code"
|
||||||
|
rollback_deployment
|
||||||
|
fi
|
||||||
|
exit $exit_code
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set trap for cleanup on exit
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Validate input parameters
|
||||||
|
validate_input() {
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
log_error "Usage: $0 <IMAGE_TAG>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local image_tag="$1"
|
||||||
|
if [[ ! "$image_tag" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||||
|
log_error "Invalid image tag format: $image_tag"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
check_prerequisites() {
|
||||||
|
log_info "Checking prerequisites..."
|
||||||
|
|
||||||
|
# Check if Docker is running
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
log_error "Docker is not running or not accessible"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if docker-compose is available
|
||||||
|
if ! command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
log_error "docker-compose is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if deployment directory exists
|
||||||
|
if [ ! -d "$DEPLOY_DIR" ]; then
|
||||||
|
log_info "Creating deployment directory: $DEPLOY_DIR"
|
||||||
|
mkdir -p "$DEPLOY_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Prerequisites check passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backup current deployment
|
||||||
|
backup_current_deployment() {
|
||||||
|
log_info "Backing up current deployment..."
|
||||||
|
|
||||||
|
local timestamp=$(date +%Y%m%d_%H%M%S)
|
||||||
|
local backup_dir="$DEPLOY_DIR/backups/$timestamp"
|
||||||
|
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
|
||||||
|
# Backup environment file if exists
|
||||||
|
if [ -f "$DEPLOY_DIR/.env" ]; then
|
||||||
|
cp "$DEPLOY_DIR/.env" "$backup_dir/.env.backup"
|
||||||
|
cp "$DEPLOY_DIR/.env" "$DEPLOY_DIR/.env.backup"
|
||||||
|
log_info "Environment file backed up"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup docker-compose file if exists
|
||||||
|
if [ -f "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" ]; then
|
||||||
|
cp "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" "$backup_dir/${DOCKER_COMPOSE_FILE}.backup"
|
||||||
|
log_info "Docker compose file backed up"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current container image for potential rollback
|
||||||
|
if docker ps --format "table {{.Names}}\t{{.Image}}" | grep -q "$CONTAINER_NAME"; then
|
||||||
|
local current_image=$(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$current_image" ]; then
|
||||||
|
echo "$current_image" > "$DEPLOY_DIR/.previous_image"
|
||||||
|
log_info "Current image backed up: $current_image"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Backup completed: $backup_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test MongoDB connectivity
|
||||||
|
test_mongodb_connection() {
|
||||||
|
log_info "Testing MongoDB connectivity..."
|
||||||
|
|
||||||
|
local mongodb_host="192.168.0.100"
|
||||||
|
local mongodb_port="27017"
|
||||||
|
|
||||||
|
# Test basic connectivity
|
||||||
|
if timeout 10 bash -c "</dev/tcp/$mongodb_host/$mongodb_port" 2>/dev/null; then
|
||||||
|
log_success "MongoDB connection test passed"
|
||||||
|
else
|
||||||
|
log_error "Cannot connect to MongoDB at $mongodb_host:$mongodb_port"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run detailed MongoDB test script if available
|
||||||
|
if [ -f "$SCRIPT_DIR/test-mongodb-connection.sh" ]; then
|
||||||
|
log_info "Running detailed MongoDB connection tests..."
|
||||||
|
bash "$SCRIPT_DIR/test-mongodb-connection.sh"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pull new Docker image
|
||||||
|
pull_docker_image() {
|
||||||
|
local image_tag="$1"
|
||||||
|
local full_image="registry.redecarneir.us/bcards:$image_tag"
|
||||||
|
|
||||||
|
log_info "Pulling Docker image: $full_image"
|
||||||
|
|
||||||
|
# Pull the multi-arch image
|
||||||
|
if docker pull "$full_image"; then
|
||||||
|
log_success "Image pulled successfully"
|
||||||
|
else
|
||||||
|
log_error "Failed to pull image: $full_image"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify image architecture
|
||||||
|
local image_arch=$(docker inspect --format='{{.Architecture}}' "$full_image" 2>/dev/null || echo "unknown")
|
||||||
|
local system_arch=$(uname -m)
|
||||||
|
|
||||||
|
log_info "Image architecture: $image_arch"
|
||||||
|
log_info "System architecture: $system_arch"
|
||||||
|
|
||||||
|
# Convert system arch format to Docker format for comparison
|
||||||
|
case "$system_arch" in
|
||||||
|
x86_64) system_arch="amd64" ;;
|
||||||
|
aarch64) system_arch="arm64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$image_arch" = "$system_arch" ] || [ "$image_arch" = "unknown" ]; then
|
||||||
|
log_success "Image architecture is compatible"
|
||||||
|
else
|
||||||
|
log_warning "Image architecture ($image_arch) may not match system ($system_arch), but multi-arch support should handle this"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deploy new version
|
||||||
|
deploy_new_version() {
|
||||||
|
local image_tag="$1"
|
||||||
|
|
||||||
|
log_info "Deploying new version with tag: $image_tag"
|
||||||
|
|
||||||
|
# Copy docker-compose file to deployment directory
|
||||||
|
cp "$PROJECT_ROOT/$DOCKER_COMPOSE_FILE" "$DEPLOY_DIR/"
|
||||||
|
|
||||||
|
# Create/update environment file
|
||||||
|
cat > "$DEPLOY_DIR/.env" << EOF
|
||||||
|
IMAGE_TAG=$image_tag
|
||||||
|
REGISTRY=registry.redecarneir.us
|
||||||
|
MONGODB_CONNECTION_STRING=mongodb://192.168.0.100:27017/BCardsDB
|
||||||
|
ASPNETCORE_ENVIRONMENT=Release
|
||||||
|
CERT_PASSWORD=
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Stop existing containers
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
if docker-compose -f "$DOCKER_COMPOSE_FILE" ps -q | grep -q .; then
|
||||||
|
log_info "Stopping existing containers..."
|
||||||
|
docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start new containers
|
||||||
|
log_info "Starting new containers..."
|
||||||
|
docker-compose -f "$DOCKER_COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
# Wait for containers to start
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
log_success "New version deployed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
perform_health_check() {
|
||||||
|
log_info "Performing health check..."
|
||||||
|
|
||||||
|
local attempt=1
|
||||||
|
while [ $attempt -le $MAX_HEALTH_CHECK_ATTEMPTS ]; do
|
||||||
|
log_info "Health check attempt $attempt/$MAX_HEALTH_CHECK_ATTEMPTS"
|
||||||
|
|
||||||
|
# Check if container is running
|
||||||
|
if ! docker ps --format "table {{.Names}}" | grep -q "$CONTAINER_NAME"; then
|
||||||
|
log_warning "Container $CONTAINER_NAME is not running"
|
||||||
|
else
|
||||||
|
# Check application health endpoint
|
||||||
|
if curl -f -s "$HEALTH_CHECK_URL" >/dev/null 2>&1; then
|
||||||
|
log_success "Health check passed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if the application is responding on port 80
|
||||||
|
if curl -f -s "http://localhost:8090/" >/dev/null 2>&1; then
|
||||||
|
log_success "Application is responding (health endpoint may not be configured)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $attempt -eq $MAX_HEALTH_CHECK_ATTEMPTS ]; then
|
||||||
|
log_error "Health check failed after $MAX_HEALTH_CHECK_ATTEMPTS attempts"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Waiting $HEALTH_CHECK_INTERVAL seconds before next attempt..."
|
||||||
|
sleep $HEALTH_CHECK_INTERVAL
|
||||||
|
((attempt++))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rollback deployment
|
||||||
|
rollback_deployment() {
|
||||||
|
log_warning "Initiating rollback..."
|
||||||
|
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
# Stop current containers
|
||||||
|
if [ -f "$DOCKER_COMPOSE_FILE" ]; then
|
||||||
|
docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restore previous environment if backup exists
|
||||||
|
if [ -f ".env.backup" ]; then
|
||||||
|
mv ".env.backup" ".env"
|
||||||
|
log_info "Previous environment restored"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try to start previous version if image is available
|
||||||
|
if [ -f ".previous_image" ]; then
|
||||||
|
local previous_image=$(cat ".previous_image")
|
||||||
|
log_info "Attempting to restore previous image: $previous_image"
|
||||||
|
|
||||||
|
# Update .env with previous image tag
|
||||||
|
local previous_tag=$(echo "$previous_image" | cut -d':' -f2)
|
||||||
|
sed -i "s/IMAGE_TAG=.*/IMAGE_TAG=$previous_tag/" .env 2>/dev/null || true
|
||||||
|
|
||||||
|
# Try to start previous version
|
||||||
|
if docker-compose -f "$DOCKER_COMPOSE_FILE" up -d; then
|
||||||
|
log_success "Rollback completed successfully"
|
||||||
|
else
|
||||||
|
log_error "Rollback failed - manual intervention required"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warning "No previous version found for rollback"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup old images and containers
|
||||||
|
cleanup_old_resources() {
|
||||||
|
log_info "Cleaning up old Docker resources..."
|
||||||
|
|
||||||
|
# Remove dangling images
|
||||||
|
if docker images -f "dangling=true" -q | head -1 | grep -q .; then
|
||||||
|
docker rmi $(docker images -f "dangling=true" -q) || true
|
||||||
|
log_info "Dangling images removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove old backups (keep last 5)
|
||||||
|
if [ -d "$DEPLOY_DIR/backups" ]; then
|
||||||
|
find "$DEPLOY_DIR/backups" -maxdepth 1 -type d -name "20*" | sort -r | tail -n +6 | xargs rm -rf || true
|
||||||
|
log_info "Old backups cleaned up"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Cleanup completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display deployment summary
|
||||||
|
display_summary() {
|
||||||
|
local image_tag="$1"
|
||||||
|
|
||||||
|
log_success "Deployment Summary:"
|
||||||
|
echo "=================================="
|
||||||
|
echo "🚀 Image Tag: $image_tag"
|
||||||
|
echo "🌐 Environment: Release (Staging)"
|
||||||
|
echo "🔗 Application URL: http://localhost:8090"
|
||||||
|
echo "🔗 Health Check: $HEALTH_CHECK_URL"
|
||||||
|
echo "🗄️ MongoDB: 192.168.0.100:27017"
|
||||||
|
echo "📁 Deploy Directory: $DEPLOY_DIR"
|
||||||
|
echo "🐳 Container: $CONTAINER_NAME"
|
||||||
|
|
||||||
|
# Show container status
|
||||||
|
echo ""
|
||||||
|
echo "Container Status:"
|
||||||
|
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(NAMES|$CONTAINER_NAME)" || true
|
||||||
|
|
||||||
|
# Show image info
|
||||||
|
echo ""
|
||||||
|
echo "Image Information:"
|
||||||
|
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}\t{{.Size}}" | grep -E "(REPOSITORY|bcards)" | head -5 || true
|
||||||
|
|
||||||
|
echo "=================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main deployment function
|
||||||
|
main() {
|
||||||
|
local image_tag="$1"
|
||||||
|
|
||||||
|
log_info "Starting deployment process for BCards Release environment"
|
||||||
|
log_info "Target image tag: $image_tag"
|
||||||
|
log_info "Target architecture: $(uname -m)"
|
||||||
|
log_info "Deploy directory: $DEPLOY_DIR"
|
||||||
|
|
||||||
|
# Execute deployment steps
|
||||||
|
validate_input "$@"
|
||||||
|
check_prerequisites
|
||||||
|
test_mongodb_connection
|
||||||
|
backup_current_deployment
|
||||||
|
pull_docker_image "$image_tag"
|
||||||
|
deploy_new_version "$image_tag"
|
||||||
|
|
||||||
|
# Perform health check (rollback handled by trap if this fails)
|
||||||
|
if perform_health_check; then
|
||||||
|
cleanup_old_resources
|
||||||
|
display_summary "$image_tag"
|
||||||
|
log_success "Deployment completed successfully!"
|
||||||
|
else
|
||||||
|
log_error "Health check failed - rollback will be triggered"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function with all arguments
|
||||||
|
main "$@"
|
||||||
495
scripts/test-mongodb-connection.sh
Normal file
495
scripts/test-mongodb-connection.sh
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# MongoDB Connection Test Script for Release Environment
|
||||||
|
# Tests connectivity, database operations, and index validation
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
readonly MONGODB_HOST="${MONGODB_HOST:-192.168.0.100}"
|
||||||
|
readonly MONGODB_PORT="${MONGODB_PORT:-27017}"
|
||||||
|
readonly DATABASE_NAME="${DATABASE_NAME:-BCardsDB}"
|
||||||
|
readonly CONNECTION_STRING="mongodb://${MONGODB_HOST}:${MONGODB_PORT}/${DATABASE_NAME}"
|
||||||
|
readonly TIMEOUT=30
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
readonly RED='\033[0;31m'
|
||||||
|
readonly GREEN='\033[0;32m'
|
||||||
|
readonly YELLOW='\033[1;33m'
|
||||||
|
readonly BLUE='\033[0;34m'
|
||||||
|
readonly NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging functions
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test basic TCP connectivity
|
||||||
|
test_tcp_connection() {
|
||||||
|
log_info "Testing TCP connection to $MONGODB_HOST:$MONGODB_PORT..."
|
||||||
|
|
||||||
|
if timeout $TIMEOUT bash -c "</dev/tcp/$MONGODB_HOST/$MONGODB_PORT" 2>/dev/null; then
|
||||||
|
log_success "TCP connection successful"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_error "TCP connection failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test MongoDB connectivity using mongosh if available
|
||||||
|
test_mongodb_with_mongosh() {
|
||||||
|
if ! command -v mongosh >/dev/null 2>&1; then
|
||||||
|
log_warning "mongosh not available, skipping MongoDB shell tests"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Testing MongoDB connection with mongosh..."
|
||||||
|
|
||||||
|
# Test basic connection
|
||||||
|
local test_output=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "db.runCommand({ping: 1})" 2>/dev/null || echo "FAILED")
|
||||||
|
|
||||||
|
if [[ "$test_output" == *"ok"* ]]; then
|
||||||
|
log_success "MongoDB ping successful"
|
||||||
|
else
|
||||||
|
log_error "MongoDB ping failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test database access
|
||||||
|
log_info "Testing database operations..."
|
||||||
|
|
||||||
|
local db_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
|
||||||
|
try {
|
||||||
|
// Test basic database operations
|
||||||
|
db.connection_test.insertOne({test: true, timestamp: new Date()});
|
||||||
|
var result = db.connection_test.findOne({test: true});
|
||||||
|
db.connection_test.deleteOne({test: true});
|
||||||
|
print('DATABASE_ACCESS_OK');
|
||||||
|
} catch (e) {
|
||||||
|
print('DATABASE_ACCESS_FAILED: ' + e.message);
|
||||||
|
}
|
||||||
|
" 2>/dev/null || echo "DATABASE_ACCESS_FAILED")
|
||||||
|
|
||||||
|
if [[ "$db_test" == *"DATABASE_ACCESS_OK"* ]]; then
|
||||||
|
log_success "Database operations test passed"
|
||||||
|
else
|
||||||
|
log_error "Database operations test failed: $db_test"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test MongoDB connectivity using Python if available
|
||||||
|
test_mongodb_with_python() {
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
log_warning "Python3 not available, skipping Python MongoDB tests"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Testing MongoDB connection with Python..."
|
||||||
|
|
||||||
|
python3 << EOF
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
import pymongo
|
||||||
|
from pymongo import MongoClient
|
||||||
|
import socket
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
client = MongoClient("$CONNECTION_STRING", serverSelectionTimeoutMS=$((TIMEOUT * 1000)))
|
||||||
|
|
||||||
|
# Test ping
|
||||||
|
client.admin.command('ping')
|
||||||
|
print("MongoDB ping successful (Python)")
|
||||||
|
|
||||||
|
# Test database access
|
||||||
|
db = client["$DATABASE_NAME"]
|
||||||
|
|
||||||
|
# Insert test document
|
||||||
|
test_collection = db.connection_test
|
||||||
|
result = test_collection.insert_one({"test": True, "source": "python"})
|
||||||
|
|
||||||
|
# Read test document
|
||||||
|
doc = test_collection.find_one({"_id": result.inserted_id})
|
||||||
|
if doc:
|
||||||
|
print("Database read/write test passed (Python)")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
test_collection.delete_one({"_id": result.inserted_id})
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
print("PYTHON_TEST_SUCCESS")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print("PyMongo not installed, skipping Python tests")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Python MongoDB test failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
local python_result=$?
|
||||||
|
if [ $python_result -eq 0 ]; then
|
||||||
|
log_success "Python MongoDB test passed"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_error "Python MongoDB test failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test using Docker MongoDB client
|
||||||
|
test_mongodb_with_docker() {
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
log_warning "Docker not available, skipping Docker MongoDB tests"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Testing MongoDB connection using Docker MongoDB client..."
|
||||||
|
|
||||||
|
# Use official MongoDB image to test connection
|
||||||
|
local docker_test=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval "
|
||||||
|
try {
|
||||||
|
db.runCommand({ping: 1});
|
||||||
|
db.connection_test.insertOne({test: true, source: 'docker', timestamp: new Date()});
|
||||||
|
var doc = db.connection_test.findOne({source: 'docker'});
|
||||||
|
db.connection_test.deleteOne({source: 'docker'});
|
||||||
|
print('DOCKER_TEST_SUCCESS');
|
||||||
|
} catch (e) {
|
||||||
|
print('DOCKER_TEST_FAILED: ' + e.message);
|
||||||
|
}
|
||||||
|
" 2>/dev/null || echo "DOCKER_TEST_FAILED")
|
||||||
|
|
||||||
|
if [[ "$docker_test" == *"DOCKER_TEST_SUCCESS"* ]]; then
|
||||||
|
log_success "Docker MongoDB test passed"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_error "Docker MongoDB test failed: $docker_test"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test MongoDB from application container
|
||||||
|
test_from_application_container() {
|
||||||
|
local container_name="bcards-staging"
|
||||||
|
|
||||||
|
if ! docker ps --format "{{.Names}}" | grep -q "^${container_name}$"; then
|
||||||
|
log_warning "Application container '$container_name' not running, skipping application test"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Testing MongoDB connection from application container..."
|
||||||
|
|
||||||
|
# Test connection from the application container
|
||||||
|
local app_test=$(docker exec "$container_name" timeout 10 bash -c "
|
||||||
|
# Test TCP connection
|
||||||
|
if timeout 5 bash -c '</dev/tcp/$MONGODB_HOST/$MONGODB_PORT' 2>/dev/null; then
|
||||||
|
echo 'APP_TCP_OK'
|
||||||
|
else
|
||||||
|
echo 'APP_TCP_FAILED'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test HTTP health endpoint if available
|
||||||
|
if curl -f -s http://localhost:8080/health >/dev/null 2>&1; then
|
||||||
|
echo 'APP_HEALTH_OK'
|
||||||
|
else
|
||||||
|
echo 'APP_HEALTH_FAILED'
|
||||||
|
fi
|
||||||
|
" 2>/dev/null || echo "APP_TEST_FAILED")
|
||||||
|
|
||||||
|
if [[ "$app_test" == *"APP_TCP_OK"* ]]; then
|
||||||
|
log_success "Application container can connect to MongoDB"
|
||||||
|
|
||||||
|
if [[ "$app_test" == *"APP_HEALTH_OK"* ]]; then
|
||||||
|
log_success "Application health check passed"
|
||||||
|
else
|
||||||
|
log_warning "Application health check failed - app may still be starting"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_error "Application container cannot connect to MongoDB"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check MongoDB server status and version
|
||||||
|
check_mongodb_status() {
|
||||||
|
log_info "Checking MongoDB server status..."
|
||||||
|
|
||||||
|
# Try multiple methods to check status
|
||||||
|
local status_checked=false
|
||||||
|
|
||||||
|
# Method 1: Using mongosh
|
||||||
|
if command -v mongosh >/dev/null 2>&1; then
|
||||||
|
local server_status=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
|
||||||
|
try {
|
||||||
|
var status = db.runCommand({serverStatus: 1});
|
||||||
|
print('MongoDB Version: ' + status.version);
|
||||||
|
print('Uptime: ' + status.uptime + ' seconds');
|
||||||
|
print('Connections: ' + status.connections.current + '/' + status.connections.available);
|
||||||
|
print('STATUS_CHECK_OK');
|
||||||
|
} catch (e) {
|
||||||
|
print('STATUS_CHECK_FAILED: ' + e.message);
|
||||||
|
}
|
||||||
|
" 2>/dev/null || echo "STATUS_CHECK_FAILED")
|
||||||
|
|
||||||
|
if [[ "$server_status" == *"STATUS_CHECK_OK"* ]]; then
|
||||||
|
echo "$server_status" | grep -v "STATUS_CHECK_OK"
|
||||||
|
log_success "MongoDB server status check passed"
|
||||||
|
status_checked=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 2: Using Docker if mongosh failed
|
||||||
|
if [ "$status_checked" = false ] && command -v docker >/dev/null 2>&1; then
|
||||||
|
local docker_status=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval "
|
||||||
|
try {
|
||||||
|
var status = db.runCommand({serverStatus: 1});
|
||||||
|
print('MongoDB Version: ' + status.version);
|
||||||
|
print('STATUS_CHECK_OK');
|
||||||
|
} catch (e) {
|
||||||
|
print('STATUS_CHECK_FAILED: ' + e.message);
|
||||||
|
}
|
||||||
|
" 2>/dev/null || echo "STATUS_CHECK_FAILED")
|
||||||
|
|
||||||
|
if [[ "$docker_status" == *"STATUS_CHECK_OK"* ]]; then
|
||||||
|
echo "$docker_status" | grep -v "STATUS_CHECK_OK"
|
||||||
|
log_success "MongoDB server status check passed (via Docker)"
|
||||||
|
status_checked=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$status_checked" = false ]; then
|
||||||
|
log_warning "Could not retrieve MongoDB server status"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test BCards specific collections and indexes
|
||||||
|
test_bcards_collections() {
|
||||||
|
if ! command -v mongosh >/dev/null 2>&1 && ! command -v docker >/dev/null 2>&1; then
|
||||||
|
log_warning "Cannot test BCards collections - no MongoDB client available"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Testing BCards specific collections and indexes..."
|
||||||
|
|
||||||
|
local mongo_cmd="mongosh"
|
||||||
|
local docker_prefix=""
|
||||||
|
|
||||||
|
if ! command -v mongosh >/dev/null 2>&1; then
|
||||||
|
mongo_cmd="docker run --rm mongo:7.0 mongosh"
|
||||||
|
docker_prefix="timeout $TIMEOUT "
|
||||||
|
fi
|
||||||
|
|
||||||
|
local collections_test=$(${docker_prefix}${mongo_cmd} "$CONNECTION_STRING" --quiet --eval "
|
||||||
|
try {
|
||||||
|
// Check required collections
|
||||||
|
var collections = db.listCollectionNames();
|
||||||
|
var requiredCollections = ['users', 'userpages', 'categories'];
|
||||||
|
var missingCollections = [];
|
||||||
|
|
||||||
|
requiredCollections.forEach(function(collection) {
|
||||||
|
if (collections.indexOf(collection) === -1) {
|
||||||
|
missingCollections.push(collection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missingCollections.length > 0) {
|
||||||
|
print('Missing collections: ' + missingCollections.join(', '));
|
||||||
|
} else {
|
||||||
|
print('All required collections exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check indexes on userpages collection
|
||||||
|
if (collections.indexOf('userpages') !== -1) {
|
||||||
|
var indexes = db.userpages.getIndexes();
|
||||||
|
print('UserPages collection has ' + indexes.length + ' indexes');
|
||||||
|
|
||||||
|
// Check for important compound index
|
||||||
|
var hasCompoundIndex = indexes.some(function(index) {
|
||||||
|
return index.key && index.key.category && index.key.slug;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasCompoundIndex) {
|
||||||
|
print('Required compound index (category, slug) exists');
|
||||||
|
} else {
|
||||||
|
print('WARNING: Compound index (category, slug) is missing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print('COLLECTIONS_TEST_OK');
|
||||||
|
} catch (e) {
|
||||||
|
print('COLLECTIONS_TEST_FAILED: ' + e.message);
|
||||||
|
}
|
||||||
|
" 2>/dev/null || echo "COLLECTIONS_TEST_FAILED")
|
||||||
|
|
||||||
|
if [[ "$collections_test" == *"COLLECTIONS_TEST_OK"* ]]; then
|
||||||
|
echo "$collections_test" | grep -v "COLLECTIONS_TEST_OK"
|
||||||
|
log_success "BCards collections test passed"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_warning "BCards collections test had issues: $collections_test"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Performance test
|
||||||
|
test_mongodb_performance() {
|
||||||
|
log_info "Running basic performance test..."
|
||||||
|
|
||||||
|
if ! command -v mongosh >/dev/null 2>&1; then
|
||||||
|
log_warning "mongosh not available, skipping performance test"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local perf_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
|
||||||
|
try {
|
||||||
|
var start = new Date();
|
||||||
|
|
||||||
|
// Insert test documents
|
||||||
|
var docs = [];
|
||||||
|
for (var i = 0; i < 100; i++) {
|
||||||
|
docs.push({test: true, index: i, timestamp: new Date()});
|
||||||
|
}
|
||||||
|
db.performance_test.insertMany(docs);
|
||||||
|
|
||||||
|
// Read test
|
||||||
|
var count = db.performance_test.countDocuments({test: true});
|
||||||
|
|
||||||
|
// Update test
|
||||||
|
db.performance_test.updateMany({test: true}, {\$set: {updated: true}});
|
||||||
|
|
||||||
|
// Delete test
|
||||||
|
db.performance_test.deleteMany({test: true});
|
||||||
|
|
||||||
|
var end = new Date();
|
||||||
|
var duration = end - start;
|
||||||
|
|
||||||
|
print('Performance test completed in ' + duration + 'ms');
|
||||||
|
print('Operations: 100 inserts, 1 count, 100 updates, 100 deletes');
|
||||||
|
|
||||||
|
if (duration < 5000) {
|
||||||
|
print('PERFORMANCE_TEST_OK');
|
||||||
|
} else {
|
||||||
|
print('PERFORMANCE_TEST_SLOW');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('PERFORMANCE_TEST_FAILED: ' + e.message);
|
||||||
|
}
|
||||||
|
" 2>/dev/null || echo "PERFORMANCE_TEST_FAILED")
|
||||||
|
|
||||||
|
if [[ "$perf_test" == *"PERFORMANCE_TEST_OK"* ]]; then
|
||||||
|
echo "$perf_test" | grep -v "PERFORMANCE_TEST_OK"
|
||||||
|
log_success "Performance test passed"
|
||||||
|
return 0
|
||||||
|
elif [[ "$perf_test" == *"PERFORMANCE_TEST_SLOW"* ]]; then
|
||||||
|
echo "$perf_test" | grep -v "PERFORMANCE_TEST_SLOW"
|
||||||
|
log_warning "Performance test completed but was slow"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_error "Performance test failed: $perf_test"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display connection summary
|
||||||
|
display_summary() {
|
||||||
|
echo ""
|
||||||
|
log_info "MongoDB Connection Test Summary"
|
||||||
|
echo "=================================================="
|
||||||
|
echo "🏠 Host: $MONGODB_HOST:$MONGODB_PORT"
|
||||||
|
echo "🗄️ Database: $DATABASE_NAME"
|
||||||
|
echo "🔗 Connection String: $CONNECTION_STRING"
|
||||||
|
echo "⏱️ Timeout: ${TIMEOUT}s"
|
||||||
|
echo "📊 Tests completed: $(date)"
|
||||||
|
echo "=================================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main test function
|
||||||
|
main() {
|
||||||
|
log_info "Starting MongoDB connection tests for Release environment"
|
||||||
|
|
||||||
|
local test_results=()
|
||||||
|
local overall_success=true
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
if test_tcp_connection; then
|
||||||
|
test_results+=("✅ TCP Connection")
|
||||||
|
else
|
||||||
|
test_results+=("❌ TCP Connection")
|
||||||
|
overall_success=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
if test_mongodb_with_mongosh; then
|
||||||
|
test_results+=("✅ MongoDB Shell")
|
||||||
|
elif test_mongodb_with_docker; then
|
||||||
|
test_results+=("✅ MongoDB Docker")
|
||||||
|
elif test_mongodb_with_python; then
|
||||||
|
test_results+=("✅ MongoDB Python")
|
||||||
|
else
|
||||||
|
test_results+=("❌ MongoDB Client")
|
||||||
|
overall_success=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
if test_from_application_container; then
|
||||||
|
test_results+=("✅ Application Container")
|
||||||
|
else
|
||||||
|
test_results+=("⚠️ Application Container")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_mongodb_status; then
|
||||||
|
test_results+=("✅ Server Status")
|
||||||
|
else
|
||||||
|
test_results+=("⚠️ Server Status")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if test_bcards_collections; then
|
||||||
|
test_results+=("✅ BCards Collections")
|
||||||
|
else
|
||||||
|
test_results+=("⚠️ BCards Collections")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if test_mongodb_performance; then
|
||||||
|
test_results+=("✅ Performance Test")
|
||||||
|
else
|
||||||
|
test_results+=("⚠️ Performance Test")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display results
|
||||||
|
display_summary
|
||||||
|
echo ""
|
||||||
|
log_info "Test Results:"
|
||||||
|
for result in "${test_results[@]}"; do
|
||||||
|
echo " $result"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$overall_success" = true ]; then
|
||||||
|
log_success "All critical MongoDB tests passed!"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
log_error "Some critical MongoDB tests failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function
|
||||||
|
main "$@"
|
||||||
45
src/BCards.IntegrationTests/BCards.IntegrationTests.csproj
Normal file
45
src/BCards.IntegrationTests/BCards.IntegrationTests.csproj
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.6.6" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.1">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
||||||
|
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
|
||||||
|
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
|
||||||
|
<PackageReference Include="Testcontainers.MongoDb" Version="3.6.0" />
|
||||||
|
<PackageReference Include="Stripe.net" Version="44.7.0" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.70" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\BCards.Web\BCards.Web.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.Testing.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using BCards.Web.Configuration;
|
||||||
|
using BCards.Web.Services;
|
||||||
|
using Testcontainers.MongoDb;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace BCards.IntegrationTests.Fixtures;
|
||||||
|
|
||||||
|
public class BCardsWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly MongoDbContainer _mongoContainer = new MongoDbBuilder()
|
||||||
|
.WithImage("mongo:7.0")
|
||||||
|
.WithEnvironment("MONGO_INITDB_DATABASE", "BCardsDB_Test")
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
public IMongoDatabase TestDatabase { get; private set; } = null!;
|
||||||
|
public string TestDatabaseName => $"BCardsDB_Test_{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureAppConfiguration((context, config) =>
|
||||||
|
{
|
||||||
|
// Remove existing configuration and add test configuration
|
||||||
|
config.Sources.Clear();
|
||||||
|
config.AddJsonFile("appsettings.Testing.json", optional: false, reloadOnChange: false);
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["MongoDb:ConnectionString"] = _mongoContainer.GetConnectionString(),
|
||||||
|
["MongoDb:DatabaseName"] = TestDatabaseName,
|
||||||
|
["ASPNETCORE_ENVIRONMENT"] = "Testing"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Remove existing MongoDB services
|
||||||
|
services.RemoveAll(typeof(IMongoClient));
|
||||||
|
services.RemoveAll(typeof(IMongoDatabase));
|
||||||
|
|
||||||
|
// Add test MongoDB services
|
||||||
|
services.AddSingleton<IMongoClient>(serviceProvider =>
|
||||||
|
{
|
||||||
|
return new MongoClient(_mongoContainer.GetConnectionString());
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddScoped(serviceProvider =>
|
||||||
|
{
|
||||||
|
var client = serviceProvider.GetRequiredService<IMongoClient>();
|
||||||
|
TestDatabase = client.GetDatabase(TestDatabaseName);
|
||||||
|
return TestDatabase;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override Stripe settings for testing
|
||||||
|
services.Configure<StripeSettings>(options =>
|
||||||
|
{
|
||||||
|
options.PublishableKey = "pk_test_51234567890abcdef";
|
||||||
|
options.SecretKey = "sk_test_51234567890abcdef";
|
||||||
|
options.WebhookSecret = "whsec_test_1234567890abcdef";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock external services that we don't want to test
|
||||||
|
services.RemoveAll(typeof(IEmailService));
|
||||||
|
services.AddScoped<IEmailService, MockEmailService>();
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.UseEnvironment("Testing");
|
||||||
|
|
||||||
|
// Reduce logging noise during tests
|
||||||
|
builder.ConfigureLogging(logging =>
|
||||||
|
{
|
||||||
|
logging.ClearProviders();
|
||||||
|
logging.AddConsole();
|
||||||
|
logging.SetMinimumLevel(LogLevel.Warning);
|
||||||
|
logging.AddFilter("BCards", LogLevel.Information);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _mongoContainer.StartAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public new async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await _mongoContainer.DisposeAsync();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CleanDatabaseAsync()
|
||||||
|
{
|
||||||
|
if (TestDatabase != null)
|
||||||
|
{
|
||||||
|
var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" };
|
||||||
|
|
||||||
|
foreach (var collectionName in collections)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TestDatabase.DropCollectionAsync(collectionName);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Ignore errors if collection doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock email service to avoid external dependencies in tests
|
||||||
|
public class MockEmailService : IEmailService
|
||||||
|
{
|
||||||
|
public Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> SendEmailAsync(string to, string subject, string htmlContent)
|
||||||
|
{
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs
Normal file
182
src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
|
|
||||||
|
namespace BCards.IntegrationTests.Fixtures;
|
||||||
|
|
||||||
|
public class MongoDbTestFixture
|
||||||
|
{
|
||||||
|
public IMongoDatabase Database { get; }
|
||||||
|
public IUserRepository UserRepository { get; }
|
||||||
|
public IUserPageRepository UserPageRepository { get; }
|
||||||
|
public ICategoryRepository CategoryRepository { get; }
|
||||||
|
|
||||||
|
public MongoDbTestFixture(IMongoDatabase database)
|
||||||
|
{
|
||||||
|
Database = database;
|
||||||
|
UserRepository = new UserRepository(database);
|
||||||
|
UserPageRepository = new UserPageRepository(database);
|
||||||
|
CategoryRepository = new CategoryRepository(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeTestDataAsync()
|
||||||
|
{
|
||||||
|
// Initialize test categories
|
||||||
|
var categories = new List<Category>
|
||||||
|
{
|
||||||
|
new() { Id = "tecnologia", Name = "Tecnologia", Description = "Empresas e profissionais de tecnologia" },
|
||||||
|
new() { Id = "negocios", Name = "Negócios", Description = "Empresas e empreendedores" },
|
||||||
|
new() { Id = "pessoal", Name = "Pessoal", Description = "Páginas pessoais e freelancers" },
|
||||||
|
new() { Id = "saude", Name = "Saúde", Description = "Profissionais da área da saúde" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var existingCategories = await CategoryRepository.GetAllActiveAsync();
|
||||||
|
if (!existingCategories.Any())
|
||||||
|
{
|
||||||
|
foreach (var category in categories)
|
||||||
|
{
|
||||||
|
await CategoryRepository.CreateAsync(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User> CreateTestUserAsync(PlanType planType = PlanType.Basic, string? email = null, string? name = null)
|
||||||
|
{
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
Email = email ?? $"test-{Guid.NewGuid():N}@example.com",
|
||||||
|
Name = name ?? "Test User",
|
||||||
|
CurrentPlan = planType.ToString(),
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await UserRepository.CreateAsync(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserPage> CreateTestUserPageAsync(
|
||||||
|
string userId,
|
||||||
|
PageStatus status = PageStatus.Creating,
|
||||||
|
string category = "tecnologia",
|
||||||
|
int normalLinkCount = 3,
|
||||||
|
int productLinkCount = 1,
|
||||||
|
string? slug = null)
|
||||||
|
{
|
||||||
|
var pageSlug = slug ?? $"test-page-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
var userPage = new UserPage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
UserId = userId,
|
||||||
|
DisplayName = "Test Page",
|
||||||
|
Category = category,
|
||||||
|
Slug = pageSlug,
|
||||||
|
Bio = "Test page for integration testing",
|
||||||
|
Status = status,
|
||||||
|
BusinessType = "individual",
|
||||||
|
Theme = new PageTheme { Name = "minimalist" },
|
||||||
|
Links = new List<LinkItem>(),
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
ModerationAttempts = 0,
|
||||||
|
ModerationHistory = new List<ModerationHistory>()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate preview token for non-Active pages
|
||||||
|
if (status != PageStatus.Active)
|
||||||
|
{
|
||||||
|
userPage.PreviewToken = Guid.NewGuid().ToString("N")[..16];
|
||||||
|
userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add normal links
|
||||||
|
for (int i = 0; i < normalLinkCount; i++)
|
||||||
|
{
|
||||||
|
userPage.Links.Add(new LinkItem
|
||||||
|
{
|
||||||
|
Title = $"Test Link {i + 1}",
|
||||||
|
Url = $"https://example.com/link{i + 1}",
|
||||||
|
Description = $"Description for test link {i + 1}",
|
||||||
|
Icon = "fas fa-link",
|
||||||
|
IsActive = true,
|
||||||
|
Order = i,
|
||||||
|
Type = LinkType.Normal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add product links
|
||||||
|
for (int i = 0; i < productLinkCount; i++)
|
||||||
|
{
|
||||||
|
userPage.Links.Add(new LinkItem
|
||||||
|
{
|
||||||
|
Title = $"Test Product {i + 1}",
|
||||||
|
Url = $"https://example.com/product{i + 1}",
|
||||||
|
Description = $"Description for test product {i + 1}",
|
||||||
|
Icon = "fas fa-shopping-cart",
|
||||||
|
IsActive = true,
|
||||||
|
Order = normalLinkCount + i,
|
||||||
|
Type = LinkType.Product,
|
||||||
|
ProductTitle = $"Amazing Product {i + 1}",
|
||||||
|
ProductPrice = "R$ 99,90",
|
||||||
|
ProductDescription = $"This is an amazing product for testing purposes {i + 1}",
|
||||||
|
ProductImage = $"https://example.com/images/product{i + 1}.jpg"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await UserPageRepository.CreateAsync(userPage);
|
||||||
|
return userPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User> CreateTestUserWithPageAsync(
|
||||||
|
PlanType planType = PlanType.Basic,
|
||||||
|
PageStatus pageStatus = PageStatus.Creating,
|
||||||
|
int normalLinks = 3,
|
||||||
|
int productLinks = 1)
|
||||||
|
{
|
||||||
|
var user = await CreateTestUserAsync(planType);
|
||||||
|
var page = await CreateTestUserPageAsync(user.Id, pageStatus, "tecnologia", normalLinks, productLinks);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CleanAllDataAsync()
|
||||||
|
{
|
||||||
|
var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" };
|
||||||
|
|
||||||
|
foreach (var collectionName in collections)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Database.DropCollectionAsync(collectionName);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Ignore errors if collection doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await InitializeTestDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<UserPage>> GetUserPagesAsync(string userId)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
|
||||||
|
var pages = await UserPageRepository.GetManyAsync(filter);
|
||||||
|
return pages.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserPage?> GetUserPageAsync(string category, string slug)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserPage>.Filter.And(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Category, category),
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Slug, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
var pages = await UserPageRepository.GetManyAsync(filter);
|
||||||
|
return pages.FirstOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs
Normal file
92
src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using BCards.Web.Models;
|
||||||
|
|
||||||
|
namespace BCards.IntegrationTests.Helpers;
|
||||||
|
|
||||||
|
public static class AuthenticationHelper
|
||||||
|
{
|
||||||
|
public static async Task<HttpClient> CreateAuthenticatedClientAsync(
|
||||||
|
WebApplicationFactory<Program> factory,
|
||||||
|
User testUser)
|
||||||
|
{
|
||||||
|
var client = factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.AddAuthentication("Test")
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(
|
||||||
|
"Test", options => { });
|
||||||
|
});
|
||||||
|
}).CreateClient(new WebApplicationFactoryClientOptions
|
||||||
|
{
|
||||||
|
AllowAutoRedirect = false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the test user in headers for the TestAuthenticationHandler
|
||||||
|
client.DefaultRequestHeaders.Add("TestUserId", testUser.Id);
|
||||||
|
client.DefaultRequestHeaders.Add("TestUserEmail", testUser.Email);
|
||||||
|
client.DefaultRequestHeaders.Add("TestUserName", testUser.Name);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ClaimsPrincipal CreateTestClaimsPrincipal(User user)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, user.Id),
|
||||||
|
new(ClaimTypes.Email, user.Email),
|
||||||
|
new(ClaimTypes.Name, user.Name),
|
||||||
|
new("sub", user.Id),
|
||||||
|
new("email", user.Email),
|
||||||
|
new("name", user.Name)
|
||||||
|
};
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, "Test");
|
||||||
|
return new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
|
{
|
||||||
|
public TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger, UrlEncoder encoder)
|
||||||
|
: base(options, logger, encoder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var userId = Context.Request.Headers["TestUserId"].FirstOrDefault();
|
||||||
|
var userEmail = Context.Request.Headers["TestUserEmail"].FirstOrDefault();
|
||||||
|
var userName = Context.Request.Headers["TestUserName"].FirstOrDefault();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(userEmail))
|
||||||
|
{
|
||||||
|
return Task.FromResult(AuthenticateResult.Fail("No test user provided"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, userId),
|
||||||
|
new(ClaimTypes.Email, userEmail),
|
||||||
|
new(ClaimTypes.Name, userName ?? "Test User"),
|
||||||
|
new("sub", userId),
|
||||||
|
new("email", userEmail),
|
||||||
|
new("name", userName ?? "Test User")
|
||||||
|
};
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, "Test");
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
var ticket = new AuthenticationTicket(principal, "Test");
|
||||||
|
|
||||||
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs
Normal file
195
src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
using PuppeteerSharp;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
namespace BCards.IntegrationTests.Helpers;
|
||||||
|
|
||||||
|
public class PuppeteerTestHelper : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private IBrowser? _browser;
|
||||||
|
private IPage? _page;
|
||||||
|
private readonly string _baseUrl;
|
||||||
|
|
||||||
|
public PuppeteerTestHelper(WebApplicationFactory<Program> factory)
|
||||||
|
{
|
||||||
|
_baseUrl = factory.Server.BaseAddress?.ToString() ?? "https://localhost:49178";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
// Download Chrome if not available
|
||||||
|
await new BrowserFetcher().DownloadAsync();
|
||||||
|
|
||||||
|
_browser = await Puppeteer.LaunchAsync(new LaunchOptions
|
||||||
|
{
|
||||||
|
Headless = true, // Set to false for debugging
|
||||||
|
Args = new[]
|
||||||
|
{
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-web-security",
|
||||||
|
"--allow-running-insecure-content",
|
||||||
|
"--ignore-certificate-errors"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_page = await _browser.NewPageAsync();
|
||||||
|
|
||||||
|
// Set viewport for consistent testing
|
||||||
|
await _page.SetViewportAsync(new ViewPortOptions
|
||||||
|
{
|
||||||
|
Width = 1920,
|
||||||
|
Height = 1080
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public IPage Page => _page ?? throw new InvalidOperationException("PuppeteerTestHelper not initialized. Call InitializeAsync first.");
|
||||||
|
|
||||||
|
public async Task NavigateToAsync(string relativeUrl)
|
||||||
|
{
|
||||||
|
var fullUrl = new Uri(new Uri(_baseUrl), relativeUrl).ToString();
|
||||||
|
await Page.GoToAsync(fullUrl, new NavigationOptions
|
||||||
|
{
|
||||||
|
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetPageContentAsync()
|
||||||
|
{
|
||||||
|
return await Page.GetContentAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetPageTitleAsync()
|
||||||
|
{
|
||||||
|
return await Page.GetTitleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ElementExistsAsync(string selector)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Timeout = 5000
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (WaitTaskTimeoutException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClickAsync(string selector)
|
||||||
|
{
|
||||||
|
await Page.WaitForSelectorAsync(selector);
|
||||||
|
await Page.ClickAsync(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TypeAsync(string selector, string text)
|
||||||
|
{
|
||||||
|
await Page.WaitForSelectorAsync(selector);
|
||||||
|
await Page.TypeAsync(selector, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task FillFormAsync(Dictionary<string, string> formData)
|
||||||
|
{
|
||||||
|
foreach (var kvp in formData)
|
||||||
|
{
|
||||||
|
await Page.WaitForSelectorAsync(kvp.Key);
|
||||||
|
await Page.EvaluateExpressionAsync($"document.querySelector('{kvp.Key}').value = ''");
|
||||||
|
await Page.TypeAsync(kvp.Key, kvp.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SubmitFormAsync(string formSelector)
|
||||||
|
{
|
||||||
|
await Page.ClickAsync($"{formSelector} button[type='submit'], {formSelector} input[type='submit']");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitForNavigationAsync()
|
||||||
|
{
|
||||||
|
await Page.WaitForNavigationAsync(new NavigationOptions
|
||||||
|
{
|
||||||
|
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitForElementAsync(string selector, int timeoutMs = 10000)
|
||||||
|
{
|
||||||
|
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Timeout = timeoutMs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetElementTextAsync(string selector)
|
||||||
|
{
|
||||||
|
await Page.WaitForSelectorAsync(selector);
|
||||||
|
var element = await Page.QuerySelectorAsync(selector);
|
||||||
|
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
|
||||||
|
return text?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetElementValueAsync(string selector)
|
||||||
|
{
|
||||||
|
await Page.WaitForSelectorAsync(selector);
|
||||||
|
var element = await Page.QuerySelectorAsync(selector);
|
||||||
|
var value = await Page.EvaluateFunctionAsync<string>("el => el.value", element);
|
||||||
|
return value ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsElementVisibleAsync(string selector)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Visible = true,
|
||||||
|
Timeout = 2000
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (WaitTaskTimeoutException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TakeScreenshotAsync(string fileName)
|
||||||
|
{
|
||||||
|
await Page.ScreenshotAsync(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetCurrentUrlAsync()
|
||||||
|
{
|
||||||
|
return Page.Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetAllElementTextsAsync(string selector)
|
||||||
|
{
|
||||||
|
var elements = await Page.QuerySelectorAllAsync(selector);
|
||||||
|
var texts = new List<string>();
|
||||||
|
|
||||||
|
foreach (var element in elements)
|
||||||
|
{
|
||||||
|
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
|
||||||
|
texts.Add(text?.Trim() ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
return texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_page != null)
|
||||||
|
{
|
||||||
|
await _page.CloseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_browser != null)
|
||||||
|
{
|
||||||
|
await _browser.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/BCards.IntegrationTests/README.md
Normal file
157
src/BCards.IntegrationTests/README.md
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# BCards Integration Tests
|
||||||
|
|
||||||
|
Este projeto contém testes integrados para o sistema BCards, validando workflows completos desde a criação de páginas até o sistema de moderação.
|
||||||
|
|
||||||
|
## Estrutura dos Testes
|
||||||
|
|
||||||
|
### Fixtures
|
||||||
|
- **BCardsWebApplicationFactory**: Factory personalizada que configura ambiente de teste com MongoDB container
|
||||||
|
- **MongoDbTestFixture**: Helper para criar e gerenciar dados de teste no MongoDB
|
||||||
|
- **StripeTestFixture**: Mock para integração Stripe (futuro)
|
||||||
|
|
||||||
|
### Helpers
|
||||||
|
- **AuthenticationHelper**: Mock para autenticação OAuth (Google/Microsoft)
|
||||||
|
- **PuppeteerTestHelper**: Automação de browser para testes E2E
|
||||||
|
- **TestDataBuilder**: Builders para criar objetos de teste (futuro)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- **PageCreationTests**: Validação de criação de páginas e limites por plano
|
||||||
|
- **PreviewTokenTests**: Sistema de preview tokens para páginas não-ativas
|
||||||
|
- **ModerationWorkflowTests**: Workflow completo de moderação
|
||||||
|
- **PlanLimitationTests**: Validação de limitações por plano (futuro)
|
||||||
|
- **StripeIntegrationTests**: Testes de upgrade via Stripe (futuro)
|
||||||
|
|
||||||
|
## Cenários Testados
|
||||||
|
|
||||||
|
### Sistema de Páginas
|
||||||
|
1. **Criação de páginas** respeitando limites dos planos (Trial: 1, Basic: 3, etc.)
|
||||||
|
2. **Status de páginas**: Creating → PendingModeration → Active/Rejected
|
||||||
|
3. **Preview tokens**: Acesso a páginas em desenvolvimento (4h de validade)
|
||||||
|
4. **Validação de limites**: Links normais vs produto por plano
|
||||||
|
|
||||||
|
### Workflow de Moderação
|
||||||
|
1. **Submissão para moderação**: Creating → PendingModeration
|
||||||
|
2. **Aprovação**: PendingModeration → Active (page vira pública)
|
||||||
|
3. **Rejeição**: PendingModeration → Inactive/Rejected
|
||||||
|
4. **Preview system**: Acesso via token para pages não-Active
|
||||||
|
|
||||||
|
### Plan Limitations (Basic vs Professional)
|
||||||
|
- **Basic**: 5 links máximo
|
||||||
|
- **Professional**: 15 links máximo
|
||||||
|
- **Trial**: 1 página, 3 links + 1 produto
|
||||||
|
|
||||||
|
## Tecnologias Utilizadas
|
||||||
|
|
||||||
|
- **xUnit**: Framework de testes
|
||||||
|
- **FluentAssertions**: Assertions expressivas
|
||||||
|
- **WebApplicationFactory**: Testes integrados ASP.NET Core
|
||||||
|
- **Testcontainers**: MongoDB container para isolamento
|
||||||
|
- **PuppeteerSharp**: Automação de browser (Chrome)
|
||||||
|
- **MongoDB.Driver**: Acesso direto ao banco para validações
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
### Pré-requisitos
|
||||||
|
- .NET 8 SDK
|
||||||
|
- Docker (para MongoDB container)
|
||||||
|
- Chrome/Chromium (baixado automaticamente pelo PuppeteerSharp)
|
||||||
|
|
||||||
|
### Executar Testes
|
||||||
|
```bash
|
||||||
|
# Todos os testes
|
||||||
|
dotnet test src/BCards.IntegrationTests/
|
||||||
|
|
||||||
|
# Testes específicos
|
||||||
|
dotnet test src/BCards.IntegrationTests/ --filter "PageCreationTests"
|
||||||
|
dotnet test src/BCards.IntegrationTests/ --filter "PreviewTokenTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuração Manual (MongoDB local)
|
||||||
|
Se preferir usar MongoDB local em vez do container:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// appsettings.Testing.json
|
||||||
|
{
|
||||||
|
"MongoDb": {
|
||||||
|
"ConnectionString": "mongodb://localhost:27017",
|
||||||
|
"DatabaseName": "BCardsDB_Test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estrutura de Dados de Teste
|
||||||
|
|
||||||
|
### User
|
||||||
|
- **Trial**: 1 página máx, links limitados
|
||||||
|
- **Basic**: 3 páginas, 5 links por página
|
||||||
|
- **Professional**: 5 páginas, 15 links por página
|
||||||
|
|
||||||
|
### UserPage
|
||||||
|
- **Status**: Creating, PendingModeration, Active, Rejected
|
||||||
|
- **Preview Tokens**: 4h de validade para access não-Active
|
||||||
|
- **Links**: Normal vs Product (limites diferentes por plano)
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
- **tecnologia**: Empresas de tech
|
||||||
|
- **negocios**: Empresas e empreendedores
|
||||||
|
- **pessoal**: Freelancers e páginas pessoais
|
||||||
|
- **saude**: Profissionais da área da saúde
|
||||||
|
|
||||||
|
## Padrões de Teste
|
||||||
|
|
||||||
|
### Arrange-Act-Assert
|
||||||
|
Todos os testes seguem o padrão AAA:
|
||||||
|
```csharp
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleanup Automático
|
||||||
|
- Cada teste usa database isolada (GUID no nome)
|
||||||
|
- Container MongoDB é destruído após os testes
|
||||||
|
- Sem interferência entre testes
|
||||||
|
|
||||||
|
### Mocks
|
||||||
|
- **EmailService**: Mockado para evitar envios reais
|
||||||
|
- **StripeService**: Mockado para evitar cobrança real
|
||||||
|
- **OAuth**: Mockado para evitar dependência externa
|
||||||
|
|
||||||
|
## Debug e Troubleshooting
|
||||||
|
|
||||||
|
### PuppeteerSharp
|
||||||
|
Para debug visual dos testes de browser:
|
||||||
|
```csharp
|
||||||
|
_browser = await Puppeteer.LaunchAsync(new LaunchOptions
|
||||||
|
{
|
||||||
|
Headless = false, // Mostra o browser
|
||||||
|
SlowMo = 100 // Delay entre ações
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### MongoDB
|
||||||
|
Para inspecionar dados durante testes, conecte no container:
|
||||||
|
```bash
|
||||||
|
docker exec -it <container-id> mongosh BCardsDB_Test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
Logs são configurados para mostrar apenas warnings/errors durante testes.
|
||||||
|
Para debug detalhado, altere em `BCardsWebApplicationFactory`:
|
||||||
|
```csharp
|
||||||
|
logging.SetMinimumLevel(LogLevel.Information);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Próximos Passos
|
||||||
|
|
||||||
|
1. **PlanLimitationTests**: Validar todas as limitações por plano
|
||||||
|
2. **StripeIntegrationTests**: Testar upgrades via webhook
|
||||||
|
3. **PerformanceTests**: Testar carga no sistema de moderação
|
||||||
|
4. **E2E Tests**: Testes completos com PuppeteerSharp
|
||||||
|
5. **TrialExpirationTests**: Validar exclusão automática após 7 dias
|
||||||
204
src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs
Normal file
204
src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
using Xunit;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
|
using BCards.Web.Services;
|
||||||
|
using BCards.IntegrationTests.Fixtures;
|
||||||
|
using BCards.IntegrationTests.Helpers;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace BCards.IntegrationTests.Tests;
|
||||||
|
|
||||||
|
public class ModerationWorkflowTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly BCardsWebApplicationFactory _factory;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private MongoDbTestFixture _dbFixture = null!;
|
||||||
|
|
||||||
|
public ModerationWorkflowTests(BCardsWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_client = _factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
|
||||||
|
_dbFixture = new MongoDbTestFixture(database);
|
||||||
|
|
||||||
|
await _factory.CleanDatabaseAsync();
|
||||||
|
await _dbFixture.InitializeTestDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitPageForModeration_ShouldChangeStatusToPendingModeration()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
|
// Act - Submit page for moderation
|
||||||
|
var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
|
// Verify page status changed in database
|
||||||
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
|
updatedPage.Status.Should().Be(PageStatus.PendingModeration);
|
||||||
|
updatedPage.ModerationAttempts.Should().BeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitPageForModeration_WithoutActiveLinks_ShouldFail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 0, 0); // No links
|
||||||
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.IsSuccessStatusCode.Should().BeFalse();
|
||||||
|
|
||||||
|
// Verify page status didn't change
|
||||||
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
|
updatedPage.Status.Should().Be(PageStatus.Creating);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApprovePage_ShouldChangeStatusToActive()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
||||||
|
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
// Act - Approve the page
|
||||||
|
await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Page looks good");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
|
updatedPage.Status.Should().Be(PageStatus.Active);
|
||||||
|
updatedPage.ApprovedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));
|
||||||
|
updatedPage.ModerationHistory.Should().HaveCount(1);
|
||||||
|
updatedPage.ModerationHistory.First().Status.Should().Be("approved");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RejectPage_ShouldChangeStatusToRejected()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
||||||
|
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
// Act - Reject the page
|
||||||
|
await moderationService.RejectPageAsync(page.Id, "test-moderator-id", "Inappropriate content", new List<string> { "spam", "offensive" });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
|
updatedPage.Status.Should().Be(PageStatus.Inactive); // First rejection goes to Inactive
|
||||||
|
updatedPage.ModerationHistory.Should().HaveCount(1);
|
||||||
|
|
||||||
|
var rejectionHistory = updatedPage.ModerationHistory.First();
|
||||||
|
rejectionHistory.Status.Should().Be("rejected");
|
||||||
|
rejectionHistory.Reason.Should().Be("Inappropriate content");
|
||||||
|
rejectionHistory.Issues.Should().Contain("spam");
|
||||||
|
rejectionHistory.Issues.Should().Contain("offensive");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccessApprovedPage_WithoutPreviewToken_ShouldSucceed()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
||||||
|
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
// Approve the page
|
||||||
|
await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Approved");
|
||||||
|
|
||||||
|
// Act - Access the page without preview token
|
||||||
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain(page.DisplayName);
|
||||||
|
content.Should().NotContain("MODO PREVIEW"); // Should not show preview banner
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPendingModerationPages_ShouldReturnCorrectPages()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
||||||
|
|
||||||
|
var user1 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user1@example.com");
|
||||||
|
var user2 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user2@example.com");
|
||||||
|
|
||||||
|
// Create pages in different statuses
|
||||||
|
await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
||||||
|
await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.PendingModeration, "negocios", 4, 2);
|
||||||
|
await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.Creating, "pessoal", 2, 0); // Should not appear
|
||||||
|
await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.Active, "saude", 5, 1); // Should not appear
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var pendingPages = await moderationService.GetPendingModerationAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
pendingPages.Should().HaveCount(2);
|
||||||
|
pendingPages.Should().OnlyContain(p => p.Status == PageStatus.PendingModeration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ModerationStats_ShouldReturnCorrectCounts()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
||||||
|
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
|
||||||
|
// Create pages with different statuses
|
||||||
|
var pendingPage1 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
||||||
|
var pendingPage2 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "negocios", 3, 1);
|
||||||
|
var activePage = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "pessoal", 3, 1);
|
||||||
|
|
||||||
|
// Approve one page today
|
||||||
|
await moderationService.ApprovePageAsync(activePage.Id, "moderator", "Good");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var stats = await moderationService.GetModerationStatsAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
stats["pending"].Should().Be(2);
|
||||||
|
stats["approvedToday"].Should().Be(1);
|
||||||
|
stats["rejectedToday"].Should().Be(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/BCards.IntegrationTests/Tests/PageCreationTests.cs
Normal file
238
src/BCards.IntegrationTests/Tests/PageCreationTests.cs
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
using Xunit;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
|
using BCards.IntegrationTests.Fixtures;
|
||||||
|
using BCards.IntegrationTests.Helpers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace BCards.IntegrationTests.Tests;
|
||||||
|
|
||||||
|
public class PageCreationTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly BCardsWebApplicationFactory _factory;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private MongoDbTestFixture _dbFixture = null!;
|
||||||
|
|
||||||
|
public PageCreationTests(BCardsWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_client = _factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
|
||||||
|
_dbFixture = new MongoDbTestFixture(database);
|
||||||
|
|
||||||
|
await _factory.CleanDatabaseAsync();
|
||||||
|
await _dbFixture.InitializeTestDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreatePage_WithBasicPlan_ShouldAllowUpTo5Links()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
|
// Act - Create a page with 5 links (should succeed)
|
||||||
|
var pageData = new
|
||||||
|
{
|
||||||
|
DisplayName = "Test Business Page",
|
||||||
|
Category = "tecnologia",
|
||||||
|
BusinessType = "company",
|
||||||
|
Bio = "A test business page",
|
||||||
|
Slug = "test-business",
|
||||||
|
SelectedTheme = "minimalist",
|
||||||
|
Links = new[]
|
||||||
|
{
|
||||||
|
new { Title = "Website", Url = "https://example.com", Description = "Main website", Icon = "fas fa-globe" },
|
||||||
|
new { Title = "Email", Url = "mailto:contact@example.com", Description = "Contact email", Icon = "fas fa-envelope" },
|
||||||
|
new { Title = "Phone", Url = "tel:+5511999999999", Description = "Contact phone", Icon = "fas fa-phone" },
|
||||||
|
new { Title = "LinkedIn", Url = "https://linkedin.com/company/example", Description = "LinkedIn profile", Icon = "fab fa-linkedin" },
|
||||||
|
new { Title = "Instagram", Url = "https://instagram.com/example", Description = "Instagram profile", Icon = "fab fa-instagram" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
createResponse.IsSuccessStatusCode.Should().BeTrue("Basic plan should allow 5 links");
|
||||||
|
|
||||||
|
// Verify page was created in database
|
||||||
|
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
createdPages.Should().HaveCount(1);
|
||||||
|
|
||||||
|
var createdPage = createdPages.First();
|
||||||
|
createdPage.DisplayName.Should().Be("Test Business Page");
|
||||||
|
createdPage.Category.Should().Be("tecnologia");
|
||||||
|
createdPage.Status.Should().Be(PageStatus.Creating);
|
||||||
|
createdPage.Links.Should().HaveCount(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreatePage_WithBasicPlanExceedingLimits_ShouldFail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
|
// Act - Try to create a page with 6 links (should fail for Basic plan)
|
||||||
|
var pageData = new
|
||||||
|
{
|
||||||
|
DisplayName = "Test Page Exceeding Limits",
|
||||||
|
Category = "tecnologia",
|
||||||
|
BusinessType = "individual",
|
||||||
|
Bio = "A test page with too many links",
|
||||||
|
Slug = "test-exceeding",
|
||||||
|
SelectedTheme = "minimalist",
|
||||||
|
Links = new[]
|
||||||
|
{
|
||||||
|
new { Title = "Link 1", Url = "https://example1.com", Description = "Link 1", Icon = "fas fa-link" },
|
||||||
|
new { Title = "Link 2", Url = "https://example2.com", Description = "Link 2", Icon = "fas fa-link" },
|
||||||
|
new { Title = "Link 3", Url = "https://example3.com", Description = "Link 3", Icon = "fas fa-link" },
|
||||||
|
new { Title = "Link 4", Url = "https://example4.com", Description = "Link 4", Icon = "fas fa-link" },
|
||||||
|
new { Title = "Link 5", Url = "https://example5.com", Description = "Link 5", Icon = "fas fa-link" },
|
||||||
|
new { Title = "Link 6", Url = "https://example6.com", Description = "Link 6", Icon = "fas fa-link" } // This should fail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
createResponse.IsSuccessStatusCode.Should().BeFalse("Basic plan should not allow more than 5 links");
|
||||||
|
|
||||||
|
// Verify no page was created
|
||||||
|
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
createdPages.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreatePage_ShouldStartInCreatingStatus()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var pageData = new
|
||||||
|
{
|
||||||
|
DisplayName = "New Page",
|
||||||
|
Category = "pessoal",
|
||||||
|
BusinessType = "individual",
|
||||||
|
Bio = "Test page bio",
|
||||||
|
Slug = "new-page",
|
||||||
|
SelectedTheme = "minimalist",
|
||||||
|
Links = new[]
|
||||||
|
{
|
||||||
|
new { Title = "Portfolio", Url = "https://myportfolio.com", Description = "My work", Icon = "fas fa-briefcase" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
createResponse.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
|
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
var page = createdPages.First();
|
||||||
|
|
||||||
|
page.Status.Should().Be(PageStatus.Creating);
|
||||||
|
page.PreviewToken.Should().NotBeNullOrEmpty("Creating pages should have preview tokens");
|
||||||
|
page.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3), "Preview token should be valid for ~4 hours");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreatePage_WithTrialPlan_ShouldAllowOnePageOnly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Trial);
|
||||||
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
|
// Act - Create first page (should succeed)
|
||||||
|
var firstPageData = new
|
||||||
|
{
|
||||||
|
DisplayName = "First Trial Page",
|
||||||
|
Category = "pessoal",
|
||||||
|
BusinessType = "individual",
|
||||||
|
Bio = "First page in trial",
|
||||||
|
Slug = "first-trial",
|
||||||
|
SelectedTheme = "minimalist",
|
||||||
|
Links = new[]
|
||||||
|
{
|
||||||
|
new { Title = "Website", Url = "https://example.com", Description = "My website", Icon = "fas fa-globe" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var firstResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", firstPageData);
|
||||||
|
firstResponse.IsSuccessStatusCode.Should().BeTrue("Trial should allow first page");
|
||||||
|
|
||||||
|
// Act - Try to create second page (should fail)
|
||||||
|
var secondPageData = new
|
||||||
|
{
|
||||||
|
DisplayName = "Second Trial Page",
|
||||||
|
Category = "tecnologia",
|
||||||
|
BusinessType = "individual",
|
||||||
|
Bio = "Second page in trial - should fail",
|
||||||
|
Slug = "second-trial",
|
||||||
|
SelectedTheme = "minimalist",
|
||||||
|
Links = new[]
|
||||||
|
{
|
||||||
|
new { Title = "LinkedIn", Url = "https://linkedin.com/in/test", Description = "LinkedIn", Icon = "fab fa-linkedin" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var secondResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", secondPageData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
secondResponse.IsSuccessStatusCode.Should().BeFalse("Trial should not allow second page");
|
||||||
|
|
||||||
|
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
createdPages.Should().HaveCount(1, "Trial should only have one page");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreatePage_ShouldGenerateUniqueSlug()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Professional);
|
||||||
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
|
// Create first page with specific slug
|
||||||
|
await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1, "test-slug");
|
||||||
|
|
||||||
|
// Act - Try to create another page with same name (should get different slug)
|
||||||
|
var pageData = new
|
||||||
|
{
|
||||||
|
DisplayName = "Test Page", // Same display name, should generate different slug
|
||||||
|
Category = "tecnologia",
|
||||||
|
BusinessType = "individual",
|
||||||
|
Bio = "Another test page",
|
||||||
|
Slug = "test-slug", // Try to use same slug
|
||||||
|
SelectedTheme = "minimalist",
|
||||||
|
Links = new[]
|
||||||
|
{
|
||||||
|
new { Title = "Website", Url = "https://example.com", Description = "Website", Icon = "fas fa-globe" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
createResponse.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
|
var userPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
userPages.Should().HaveCount(2);
|
||||||
|
|
||||||
|
var slugs = userPages.Select(p => p.Slug).ToList();
|
||||||
|
slugs.Should().OnlyHaveUniqueItems("All pages should have unique slugs");
|
||||||
|
slugs.Should().Contain("test-slug");
|
||||||
|
slugs.Should().Contain(slug => slug.StartsWith("test-slug-") || slug == "test-page");
|
||||||
|
}
|
||||||
|
}
|
||||||
240
src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs
Normal file
240
src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
using Xunit;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
|
using BCards.IntegrationTests.Fixtures;
|
||||||
|
using BCards.IntegrationTests.Helpers;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace BCards.IntegrationTests.Tests;
|
||||||
|
|
||||||
|
public class PreviewTokenTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly BCardsWebApplicationFactory _factory;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private MongoDbTestFixture _dbFixture = null!;
|
||||||
|
|
||||||
|
public PreviewTokenTests(BCardsWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_client = _factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
|
||||||
|
_dbFixture = new MongoDbTestFixture(database);
|
||||||
|
|
||||||
|
await _factory.CleanDatabaseAsync();
|
||||||
|
await _dbFixture.InitializeTestDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccessPageInCreatingStatus_WithValidPreviewToken_ShouldSucceed()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain(page.DisplayName);
|
||||||
|
content.Should().Contain("MODO PREVIEW"); // Preview banner should be shown
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccessPageInCreatingStatus_WithoutPreviewToken_ShouldReturn404()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccessPageInCreatingStatus_WithInvalidPreviewToken_ShouldReturn404()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview=invalid-token");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccessPageInCreatingStatus_WithExpiredPreviewToken_ShouldReturn404()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
// Simulate expired token
|
||||||
|
page.PreviewTokenExpiry = DateTime.UtcNow.AddHours(-1); // Expired 1 hour ago
|
||||||
|
await _dbFixture.UserPageRepository.UpdateAsync(page);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GeneratePreviewToken_ForCreatingPage_ShouldReturnNewToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
|
var oldToken = page.PreviewToken;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await authenticatedClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
|
var jsonResponse = await response.Content.ReadAsStringAsync();
|
||||||
|
jsonResponse.Should().Contain("success");
|
||||||
|
jsonResponse.Should().Contain("previewToken");
|
||||||
|
|
||||||
|
// Verify new token is different and works
|
||||||
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
|
updatedPage.PreviewToken.Should().NotBe(oldToken);
|
||||||
|
updatedPage.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3));
|
||||||
|
|
||||||
|
// Test new token works
|
||||||
|
var pageResponse = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={updatedPage.PreviewToken}");
|
||||||
|
pageResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GeneratePreviewToken_ForActivePageByNonOwner_ShouldFail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var pageOwner = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "owner@example.com");
|
||||||
|
var otherUser = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "other@example.com");
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(pageOwner.Id, PageStatus.Active, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
var otherUserClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, otherUser);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await otherUserClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.IsSuccessStatusCode.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(PageStatus.Creating)]
|
||||||
|
[InlineData(PageStatus.PendingModeration)]
|
||||||
|
[InlineData(PageStatus.Rejected)]
|
||||||
|
public async Task AccessPage_WithPreviewToken_ShouldWorkForNonActiveStatuses(PageStatus status)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK, $"Preview token should work for {status} status");
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain(page.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(PageStatus.Creating)]
|
||||||
|
[InlineData(PageStatus.PendingModeration)]
|
||||||
|
[InlineData(PageStatus.Rejected)]
|
||||||
|
public async Task AccessPage_WithoutPreviewToken_ShouldFailForNonActiveStatuses(PageStatus status)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound, $"Access without preview token should fail for {status} status");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccessActivePage_WithoutPreviewToken_ShouldSucceed()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
content.Should().Contain(page.DisplayName);
|
||||||
|
content.Should().NotContain("MODO PREVIEW"); // No preview banner for active pages
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshPreviewToken_ShouldExtendExpiry()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
|
// Make token close to expiry
|
||||||
|
page.PreviewTokenExpiry = DateTime.UtcNow.AddMinutes(30);
|
||||||
|
await _dbFixture.UserPageRepository.UpdateAsync(page);
|
||||||
|
|
||||||
|
var oldExpiry = page.PreviewTokenExpiry;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await authenticatedClient.PostAsync($"/Admin/RefreshPreviewToken/{page.Id}", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
|
updatedPage.PreviewTokenExpiry.Should().BeAfter(oldExpiry.Value.AddHours(3));
|
||||||
|
updatedPage.PreviewToken.Should().NotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/BCards.IntegrationTests/appsettings.Testing.json
Normal file
43
src/BCards.IntegrationTests/appsettings.Testing.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "mongodb://localhost:27017/BCardsDB_Test"
|
||||||
|
},
|
||||||
|
"MongoDb": {
|
||||||
|
"ConnectionString": "mongodb://localhost:27017",
|
||||||
|
"DatabaseName": "BCardsDB_Test"
|
||||||
|
},
|
||||||
|
"Stripe": {
|
||||||
|
"PublishableKey": "pk_test_51234567890abcdef",
|
||||||
|
"SecretKey": "sk_test_51234567890abcdef",
|
||||||
|
"WebhookSecret": "whsec_test_1234567890abcdef"
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Google": {
|
||||||
|
"ClientId": "test-google-client-id.apps.googleusercontent.com",
|
||||||
|
"ClientSecret": "GOCSPX-test-google-client-secret"
|
||||||
|
},
|
||||||
|
"Microsoft": {
|
||||||
|
"ClientId": "test-microsoft-client-id",
|
||||||
|
"ClientSecret": "test-microsoft-client-secret"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SendGrid": {
|
||||||
|
"ApiKey": "SG.test-sendgrid-api-key"
|
||||||
|
},
|
||||||
|
"Moderation": {
|
||||||
|
"RequireApproval": true,
|
||||||
|
"AuthKey": "test-moderation-auth-key",
|
||||||
|
"MaxPendingPages": 100,
|
||||||
|
"MaxRejectionsBeforeBan": 3
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"BCards": "Information",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Testing"
|
||||||
|
}
|
||||||
33
src/BCards.Web/Attributes/ModeratorAuthorizeAttribute.cs
Normal file
33
src/BCards.Web/Attributes/ModeratorAuthorizeAttribute.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
using BCards.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BCards.Web.Attributes
|
||||||
|
{
|
||||||
|
public class ModeratorAuthorizeAttribute : Attribute, IAuthorizationFilter
|
||||||
|
{
|
||||||
|
public void OnAuthorization(AuthorizationFilterContext context)
|
||||||
|
{
|
||||||
|
var user = context.HttpContext.User;
|
||||||
|
|
||||||
|
if (!user.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
context.Result = new RedirectToActionResult("Login", "Auth",
|
||||||
|
new { returnUrl = context.HttpContext.Request.Path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var moderationAuth = context.HttpContext.RequestServices
|
||||||
|
.GetRequiredService<IModerationAuthService>();
|
||||||
|
|
||||||
|
if (!moderationAuth.IsUserModerator(user))
|
||||||
|
{
|
||||||
|
context.Result = new ForbidResult();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar flag para views
|
||||||
|
context.HttpContext.Items["IsModerator"] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
@ -17,14 +17,12 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
||||||
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
|
||||||
|
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="Resources\**\*.resx" />
|
<EmbeddedResource Include="Resources\**\*.resx" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Views\Payment\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
10
src/BCards.Web/Configuration/ModerationSettings.cs
Normal file
10
src/BCards.Web/Configuration/ModerationSettings.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace BCards.Web.Configuration
|
||||||
|
{
|
||||||
|
public class ModerationSettings
|
||||||
|
{
|
||||||
|
public Dictionary<string, TimeSpan> PriorityTimeframes { get; set; } = new();
|
||||||
|
public int MaxAttempts { get; set; } = 3;
|
||||||
|
public string ModeratorEmail { get; set; } = "";
|
||||||
|
public List<string> ModeratorEmails { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
|
using BCards.Web.Utils;
|
||||||
using BCards.Web.ViewModels;
|
using BCards.Web.ViewModels;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -15,6 +16,9 @@ public class AdminController : Controller
|
|||||||
private readonly IUserPageService _userPageService;
|
private readonly IUserPageService _userPageService;
|
||||||
private readonly ICategoryService _categoryService;
|
private readonly ICategoryService _categoryService;
|
||||||
private readonly IThemeService _themeService;
|
private readonly IThemeService _themeService;
|
||||||
|
private readonly IModerationService _moderationService;
|
||||||
|
private readonly IEmailService _emailService;
|
||||||
|
private readonly ILivePageService _livePageService;
|
||||||
private readonly ILogger<AdminController> _logger;
|
private readonly ILogger<AdminController> _logger;
|
||||||
|
|
||||||
public AdminController(
|
public AdminController(
|
||||||
@ -22,12 +26,18 @@ public class AdminController : Controller
|
|||||||
IUserPageService userPageService,
|
IUserPageService userPageService,
|
||||||
ICategoryService categoryService,
|
ICategoryService categoryService,
|
||||||
IThemeService themeService,
|
IThemeService themeService,
|
||||||
|
IModerationService moderationService,
|
||||||
|
IEmailService emailService,
|
||||||
|
ILivePageService livePageService,
|
||||||
ILogger<AdminController> logger)
|
ILogger<AdminController> logger)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
_userPageService = userPageService;
|
_userPageService = userPageService;
|
||||||
_categoryService = categoryService;
|
_categoryService = categoryService;
|
||||||
_themeService = themeService;
|
_themeService = themeService;
|
||||||
|
_moderationService = moderationService;
|
||||||
|
_emailService = emailService;
|
||||||
|
_livePageService = livePageService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,6 +46,8 @@ public class AdminController : Controller
|
|||||||
[Route("Dashboard")]
|
[Route("Dashboard")]
|
||||||
public async Task<IActionResult> Dashboard()
|
public async Task<IActionResult> Dashboard()
|
||||||
{
|
{
|
||||||
|
ViewBag.IsHomePage = false; // Menu normal do dashboard
|
||||||
|
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return RedirectToAction("Login", "Auth");
|
return RedirectToAction("Login", "Auth");
|
||||||
@ -55,7 +67,14 @@ public class AdminController : Controller
|
|||||||
Status = p.Status,
|
Status = p.Status,
|
||||||
TotalClicks = p.Analytics?.TotalClicks ?? 0,
|
TotalClicks = p.Analytics?.TotalClicks ?? 0,
|
||||||
TotalViews = p.Analytics?.TotalViews ?? 0,
|
TotalViews = p.Analytics?.TotalViews ?? 0,
|
||||||
CreatedAt = p.CreatedAt
|
PreviewToken = p.PreviewToken,
|
||||||
|
CreatedAt = p.CreatedAt,
|
||||||
|
LastModerationStatus = p.ModerationHistory == null || p.ModerationHistory.Count == 0 || p.ModerationHistory.Last().Status == "rejected"
|
||||||
|
? null
|
||||||
|
: Enum.Parse<PageStatus>(p.ModerationHistory.Last().Status, true),
|
||||||
|
Motive = p.ModerationHistory == null || p.ModerationHistory.Count == 0 || p.ModerationHistory.Last().Status != "rejected"
|
||||||
|
? ""
|
||||||
|
: p.ModerationHistory.Last().Reason
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
CurrentPlan = new PlanInfo
|
CurrentPlan = new PlanInfo
|
||||||
{
|
{
|
||||||
@ -86,6 +105,8 @@ public class AdminController : Controller
|
|||||||
[Route("ManagePage")]
|
[Route("ManagePage")]
|
||||||
public async Task<IActionResult> ManagePage(string id = null)
|
public async Task<IActionResult> ManagePage(string id = null)
|
||||||
{
|
{
|
||||||
|
ViewBag.IsHomePage = false;
|
||||||
|
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return RedirectToAction("Login", "Auth");
|
return RedirectToAction("Login", "Auth");
|
||||||
@ -132,6 +153,8 @@ public class AdminController : Controller
|
|||||||
[Route("ManagePage")]
|
[Route("ManagePage")]
|
||||||
public async Task<IActionResult> ManagePage(ManagePageViewModel model)
|
public async Task<IActionResult> ManagePage(ManagePageViewModel model)
|
||||||
{
|
{
|
||||||
|
ViewBag.IsHomePage = false;
|
||||||
|
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return RedirectToAction("Login", "Auth");
|
return RedirectToAction("Login", "Auth");
|
||||||
@ -185,10 +208,20 @@ public class AdminController : Controller
|
|||||||
var userPage = await MapToUserPage(model, user.Id);
|
var userPage = await MapToUserPage(model, user.Id);
|
||||||
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
|
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
|
||||||
|
|
||||||
|
// Set status to Creating for new pages
|
||||||
|
userPage.Status = ViewModels.PageStatus.Creating;
|
||||||
|
|
||||||
await _userPageService.CreatePageAsync(userPage);
|
await _userPageService.CreatePageAsync(userPage);
|
||||||
_logger.LogInformation("Page created successfully!");
|
_logger.LogInformation("Page created successfully!");
|
||||||
|
|
||||||
TempData["Success"] = "Página criada com sucesso!";
|
// 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -196,6 +229,7 @@ public class AdminController : Controller
|
|||||||
ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
|
ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
|
||||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||||
|
TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}";
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -206,9 +240,36 @@ public class AdminController : Controller
|
|||||||
if (existingPage == null || existingPage.UserId != user.Id)
|
if (existingPage == null || existingPage.UserId != user.Id)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
|
// Check if user can create pages (for users with rejected pages)
|
||||||
|
var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id);
|
||||||
|
if (!canCreatePage)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Você não pode editar páginas devido a muitas rejeições. Entre em contato com o suporte.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
UpdateUserPageFromModel(existingPage, model);
|
UpdateUserPageFromModel(existingPage, model);
|
||||||
|
|
||||||
|
// Set status to PendingModeration for updates
|
||||||
|
existingPage.Status = ViewModels.PageStatus.Creating;
|
||||||
|
existingPage.ModerationAttempts = existingPage.ModerationAttempts;
|
||||||
|
|
||||||
await _userPageService.UpdatePageAsync(existingPage);
|
await _userPageService.UpdatePageAsync(existingPage);
|
||||||
TempData["Success"] = "Página atualizada com sucesso!";
|
|
||||||
|
// Generate new preview token
|
||||||
|
var previewToken = await _moderationService.GeneratePreviewTokenAsync(existingPage.Id);
|
||||||
|
var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{existingPage.Category}/{existingPage.Slug}?preview={previewToken}";
|
||||||
|
|
||||||
|
// Send email to user
|
||||||
|
await _emailService.SendModerationStatusAsync(
|
||||||
|
user.Email,
|
||||||
|
user.Name,
|
||||||
|
existingPage.DisplayName,
|
||||||
|
"pending",
|
||||||
|
null,
|
||||||
|
previewUrl);
|
||||||
|
|
||||||
|
TempData["Success"] = "Página atualizada e enviada para moderação!";
|
||||||
}
|
}
|
||||||
|
|
||||||
return RedirectToAction("Dashboard");
|
return RedirectToAction("Dashboard");
|
||||||
@ -435,16 +496,19 @@ public class AdminController : Controller
|
|||||||
public async Task<IActionResult> GenerateSlug(string category, string name)
|
public async Task<IActionResult> GenerateSlug(string category, string name)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name))
|
||||||
return Json(new { slug = "" });
|
return Json(new { slug = "", category = "" });
|
||||||
|
|
||||||
var slug = await _userPageService.GenerateSlugAsync(category, name);
|
var slug = await _userPageService.GenerateSlugAsync(category, name);
|
||||||
return Json(new { slug });
|
var categorySlug = SlugHelper.CreateCategorySlug(category).ToLower();
|
||||||
|
return Json(new { slug = slug, category = categorySlug });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("Analytics")]
|
[Route("Analytics")]
|
||||||
public async Task<IActionResult> Analytics()
|
public async Task<IActionResult> Analytics()
|
||||||
{
|
{
|
||||||
|
ViewBag.IsHomePage = false;
|
||||||
|
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return RedirectToAction("Login", "Auth");
|
return RedirectToAction("Login", "Auth");
|
||||||
@ -457,19 +521,23 @@ public class AdminController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> DeletePage()
|
[Route("DeletePage/{id}")]
|
||||||
|
public async Task<IActionResult> DeletePage(string id)
|
||||||
{
|
{
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return RedirectToAction("Login", "Auth");
|
return RedirectToAction("Login", "Auth");
|
||||||
|
|
||||||
var userPage = await _userPageService.GetUserPageAsync(user.Id);
|
var userPage = await _userPageService.GetPageByIdAsync(id);
|
||||||
if (userPage != null)
|
if (userPage == null || userPage.UserId != user.Id)
|
||||||
{
|
{
|
||||||
await _userPageService.DeletePageAsync(userPage.Id);
|
TempData["Error"] = "Página não encontrada!";
|
||||||
TempData["Success"] = "Página excluída com sucesso!";
|
return RedirectToAction("Dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _userPageService.DeletePageAsync(userPage.Id);
|
||||||
|
TempData["Success"] = "Página excluída com sucesso!";
|
||||||
|
|
||||||
return RedirectToAction("Dashboard");
|
return RedirectToAction("Dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -493,7 +561,13 @@ public class AdminController : Controller
|
|||||||
Description = l.Description,
|
Description = l.Description,
|
||||||
Icon = l.Icon,
|
Icon = l.Icon,
|
||||||
Order = l.Order,
|
Order = l.Order,
|
||||||
IsActive = l.IsActive
|
IsActive = l.IsActive,
|
||||||
|
Type = l.Type,
|
||||||
|
ProductTitle = l.ProductTitle,
|
||||||
|
ProductImage = l.ProductImage,
|
||||||
|
ProductPrice = l.ProductPrice,
|
||||||
|
ProductDescription = l.ProductDescription,
|
||||||
|
ProductDataCachedAt = l.ProductDataCachedAt
|
||||||
}).ToList() ?? new List<ManageLinkViewModel>(),
|
}).ToList() ?? new List<ManageLinkViewModel>(),
|
||||||
AvailableCategories = categories,
|
AvailableCategories = categories,
|
||||||
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
|
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
|
||||||
@ -509,10 +583,10 @@ public class AdminController : Controller
|
|||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
DisplayName = model.DisplayName,
|
DisplayName = model.DisplayName,
|
||||||
Category = model.Category.ToLower(),
|
Category = SlugHelper.ConvertCategory(model.Category.ToLower()),
|
||||||
BusinessType = model.BusinessType,
|
BusinessType = model.BusinessType,
|
||||||
Bio = model.Bio,
|
Bio = model.Bio,
|
||||||
Slug = model.Slug.ToLower(),
|
Slug = SlugHelper.CreateSlug(model.Slug.ToLower()),
|
||||||
Theme = theme,
|
Theme = theme,
|
||||||
Status = ViewModels.PageStatus.Active,
|
Status = ViewModels.PageStatus.Active,
|
||||||
Links = new List<LinkItem>()
|
Links = new List<LinkItem>()
|
||||||
@ -529,7 +603,13 @@ public class AdminController : Controller
|
|||||||
Description = l.Description,
|
Description = l.Description,
|
||||||
Icon = l.Icon,
|
Icon = l.Icon,
|
||||||
IsActive = l.IsActive,
|
IsActive = l.IsActive,
|
||||||
Order = index
|
Order = index,
|
||||||
|
Type = l.Type,
|
||||||
|
ProductTitle = l.ProductTitle,
|
||||||
|
ProductImage = l.ProductImage,
|
||||||
|
ProductPrice = l.ProductPrice,
|
||||||
|
ProductDescription = l.ProductDescription,
|
||||||
|
ProductDataCachedAt = l.ProductDataCachedAt
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -612,7 +692,13 @@ public class AdminController : Controller
|
|||||||
Description = l.Description,
|
Description = l.Description,
|
||||||
Icon = l.Icon,
|
Icon = l.Icon,
|
||||||
IsActive = l.IsActive,
|
IsActive = l.IsActive,
|
||||||
Order = index
|
Order = index,
|
||||||
|
Type = l.Type,
|
||||||
|
ProductTitle = l.ProductTitle,
|
||||||
|
ProductImage = l.ProductImage,
|
||||||
|
ProductPrice = l.ProductPrice,
|
||||||
|
ProductDescription = l.ProductDescription,
|
||||||
|
ProductDataCachedAt = l.ProductDataCachedAt
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -670,4 +756,206 @@ public class AdminController : Controller
|
|||||||
|
|
||||||
page.Links.AddRange(socialLinks);
|
page.Links.AddRange(socialLinks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Route("SubmitForModeration/{id}")]
|
||||||
|
public async Task<IActionResult> SubmitForModeration(string id)
|
||||||
|
{
|
||||||
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
|
if (user == null)
|
||||||
|
return Json(new { success = false, message = "Usuário não autenticado" });
|
||||||
|
|
||||||
|
var pageItem = await _userPageService.GetPageByIdAsync(id);
|
||||||
|
if (pageItem == null || pageItem.UserId != user.Id)
|
||||||
|
return Json(new { success = false, message = "Página não encontrada" });
|
||||||
|
|
||||||
|
// Validar status atual
|
||||||
|
if (pageItem.Status != ViewModels.PageStatus.Creating && pageItem.Status != ViewModels.PageStatus.Rejected)
|
||||||
|
return Json(new { success = false, message = "Página não pode ser enviada para moderação neste momento" });
|
||||||
|
|
||||||
|
// Validar se tem pelo menos 1 link ativo
|
||||||
|
var activeLinksCount = pageItem.Links?.Count(l => l.IsActive) ?? 0;
|
||||||
|
if (activeLinksCount < 1)
|
||||||
|
return Json(new { success = false, message = "Página deve ter pelo menos 1 link ativo para ser enviada" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Mudar status para PendingModeration
|
||||||
|
pageItem.Status = ViewModels.PageStatus.PendingModeration;
|
||||||
|
pageItem.ModerationAttempts++;
|
||||||
|
pageItem.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _userPageService.UpdatePageAsync(pageItem);
|
||||||
|
|
||||||
|
// Enviar email de notificação ao usuário
|
||||||
|
await _emailService.SendModerationStatusAsync(
|
||||||
|
user.Email,
|
||||||
|
user.Name,
|
||||||
|
pageItem.DisplayName,
|
||||||
|
"pending",
|
||||||
|
null,
|
||||||
|
$"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={pageItem.PreviewToken}");
|
||||||
|
|
||||||
|
_logger.LogInformation($"Page {pageItem.Id} submitted for moderation by user {user.Id}");
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
success = true,
|
||||||
|
message = "Página enviada para moderação com sucesso! Você receberá um email quando for processada.",
|
||||||
|
newStatus = "PendingModeration"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error submitting page {id} for moderation");
|
||||||
|
return Json(new { success = false, message = "Erro interno. Tente novamente." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Route("RefreshPreviewToken/{id}")]
|
||||||
|
public async Task<IActionResult> RefreshPreviewToken(string id)
|
||||||
|
{
|
||||||
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
|
if (user == null)
|
||||||
|
return Json(new { success = false, message = "Não autorizado" });
|
||||||
|
|
||||||
|
var pageItem = await _userPageService.GetPageByIdAsync(id);
|
||||||
|
if (pageItem == null || pageItem.UserId != user.Id)
|
||||||
|
return Json(new { success = false, message = "Página não encontrada" });
|
||||||
|
|
||||||
|
// Só renovar token para páginas "Creating"
|
||||||
|
if (pageItem.Status != ViewModels.PageStatus.Creating)
|
||||||
|
return Json(new { success = false, message = "Token só pode ser renovado para páginas em desenvolvimento" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Gerar novo token com 4 horas de validade
|
||||||
|
var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id);
|
||||||
|
|
||||||
|
var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={newToken}";
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
success = true,
|
||||||
|
previewToken = newToken,
|
||||||
|
previewUrl = previewUrl,
|
||||||
|
expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error refreshing preview token for page {id}");
|
||||||
|
return Json(new { success = false, message = "Erro ao renovar token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Route("GeneratePreviewToken/{id}")]
|
||||||
|
public async Task<IActionResult> GeneratePreviewToken(string id)
|
||||||
|
{
|
||||||
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
|
if (user == null)
|
||||||
|
return Json(new { success = false, message = "Usuário não autenticado" });
|
||||||
|
|
||||||
|
var pageItem = await _userPageService.GetPageByIdAsync(id);
|
||||||
|
if (pageItem == null || pageItem.UserId != user.Id)
|
||||||
|
return Json(new { success = false, message = "Página não encontrada" });
|
||||||
|
|
||||||
|
// Verificar se página pode ter preview
|
||||||
|
if (pageItem.Status != ViewModels.PageStatus.Creating &&
|
||||||
|
pageItem.Status != ViewModels.PageStatus.PendingModeration &&
|
||||||
|
pageItem.Status != ViewModels.PageStatus.Rejected)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, message = "Preview não disponível para este status" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Gerar novo token com 4 horas de validade
|
||||||
|
var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id);
|
||||||
|
|
||||||
|
_logger.LogInformation($"Preview token generated for page {pageItem.Id} by user {user.Id}");
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
success = true,
|
||||||
|
previewToken = newToken,
|
||||||
|
message = "Preview gerado com sucesso!",
|
||||||
|
expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"Error generating preview token for page {id}");
|
||||||
|
return Json(new { success = false, message = "Erro interno. Tente novamente." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Route("MigrateToLivePages")]
|
||||||
|
public async Task<IActionResult> MigrateToLivePages()
|
||||||
|
{
|
||||||
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
|
if (user == null)
|
||||||
|
return Json(new { success = false, message = "Usuário não autenticado" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Buscar todas as páginas ativas do usuário atual
|
||||||
|
var activePages = await _userPageService.GetUserPagesAsync(user.Id);
|
||||||
|
var eligiblePages = activePages.Where(p => p.Status == ViewModels.PageStatus.Active).ToList();
|
||||||
|
|
||||||
|
if (!eligiblePages.Any())
|
||||||
|
{
|
||||||
|
return Json(new {
|
||||||
|
success = false,
|
||||||
|
message = "Nenhuma página ativa encontrada para migração"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int successCount = 0;
|
||||||
|
int errorCount = 0;
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
foreach (var page in eligiblePages)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _livePageService.SyncFromUserPageAsync(page.Id);
|
||||||
|
successCount++;
|
||||||
|
_logger.LogInformation($"Successfully migrated page {page.Id} ({page.DisplayName}) to LivePages");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorCount++;
|
||||||
|
var errorMsg = $"Erro ao migrar '{page.DisplayName}': {ex.Message}";
|
||||||
|
errors.Add(errorMsg);
|
||||||
|
_logger.LogError(ex, $"Failed to migrate page {page.Id} to LivePages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = $"Migração concluída: {successCount} páginas migradas com sucesso";
|
||||||
|
if (errorCount > 0)
|
||||||
|
{
|
||||||
|
message += $", {errorCount} erros encontrados";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
success = errorCount == 0,
|
||||||
|
message = message,
|
||||||
|
details = new {
|
||||||
|
totalPages = eligiblePages.Count,
|
||||||
|
successCount = successCount,
|
||||||
|
errorCount = errorCount,
|
||||||
|
errors = errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during LivePages migration");
|
||||||
|
return Json(new {
|
||||||
|
success = false,
|
||||||
|
message = $"Erro durante migração: {ex.Message}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -110,8 +110,26 @@ public class AuthController : Controller
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> Logout()
|
public async Task<IActionResult> Logout()
|
||||||
{
|
{
|
||||||
|
// Identifica qual provedor foi usado
|
||||||
|
var authResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
var loginProvider = authResult.Principal?.FindFirst("LoginProvider")?.Value;
|
||||||
|
|
||||||
|
// Faz logout local primeiro
|
||||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
TempData["Success"] = "Logout realizado com sucesso";
|
TempData["Success"] = "Logout realizado com sucesso";
|
||||||
|
|
||||||
|
// Se foi Microsoft, faz logout completo no provedor
|
||||||
|
if (loginProvider == "Microsoft")
|
||||||
|
{
|
||||||
|
return SignOut(MicrosoftAccountDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
// Se foi Google, faz logout completo no provedor
|
||||||
|
else if (loginProvider == "Google")
|
||||||
|
{
|
||||||
|
return SignOut(GoogleDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
|
||||||
return RedirectToAction("Index", "Home");
|
return RedirectToAction("Index", "Home");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ public class HomeController : Controller
|
|||||||
|
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
|
ViewBag.IsHomePage = true; // Flag para identificar home
|
||||||
ViewBag.Categories = await _categoryService.GetAllCategoriesAsync();
|
ViewBag.Categories = await _categoryService.GetAllCategoriesAsync();
|
||||||
ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6);
|
ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6);
|
||||||
return View();
|
return View();
|
||||||
@ -24,18 +25,21 @@ public class HomeController : Controller
|
|||||||
[Route("Privacy")]
|
[Route("Privacy")]
|
||||||
public IActionResult Privacy()
|
public IActionResult Privacy()
|
||||||
{
|
{
|
||||||
|
ViewBag.IsHomePage = true;
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("Pricing")]
|
[Route("Pricing")]
|
||||||
public IActionResult Pricing()
|
public IActionResult Pricing()
|
||||||
{
|
{
|
||||||
|
ViewBag.IsHomePage = true;
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("categoria/{categorySlug}")]
|
[Route("categoria/{categorySlug}")]
|
||||||
public async Task<IActionResult> Category(string categorySlug)
|
public async Task<IActionResult> Category(string categorySlug)
|
||||||
{
|
{
|
||||||
|
ViewBag.IsHomePage = true;
|
||||||
var category = await _categoryService.GetCategoryBySlugAsync(categorySlug);
|
var category = await _categoryService.GetCategoryBySlugAsync(categorySlug);
|
||||||
if (category == null)
|
if (category == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|||||||
93
src/BCards.Web/Controllers/LivePageController.cs
Normal file
93
src/BCards.Web/Controllers/LivePageController.cs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
using BCards.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
|
[Route("page")]
|
||||||
|
public class LivePageController : Controller
|
||||||
|
{
|
||||||
|
private readonly ILivePageService _livePageService;
|
||||||
|
private readonly ILogger<LivePageController> _logger;
|
||||||
|
|
||||||
|
public LivePageController(ILivePageService livePageService, ILogger<LivePageController> logger)
|
||||||
|
{
|
||||||
|
_livePageService = livePageService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("{category}/{slug}")]
|
||||||
|
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })]
|
||||||
|
public async Task<IActionResult> Display(string category, string slug)
|
||||||
|
{
|
||||||
|
// Se tem parâmetro preview, redirecionar para sistema de preview
|
||||||
|
if (HttpContext.Request.Query.ContainsKey("preview"))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Redirecting preview request for {Category}/{Slug} to UserPageController", category, slug);
|
||||||
|
return RedirectToAction("Display", "UserPage", new {
|
||||||
|
category = category,
|
||||||
|
slug = slug,
|
||||||
|
preview = HttpContext.Request.Query["preview"].ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug);
|
||||||
|
if (livePage == null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("LivePage not found for {Category}/{Slug}, falling back to UserPageController", category, slug);
|
||||||
|
// Fallback: tentar no sistema antigo
|
||||||
|
return RedirectToAction("Display", "UserPage", new { category = category, slug = slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrementar view de forma assíncrona (não bloquear response)
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _livePageService.IncrementViewAsync(livePage.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePage.Id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configurar ViewBag para indicar que é uma live page
|
||||||
|
ViewBag.IsLivePage = true;
|
||||||
|
ViewBag.PageUrl = $"https://vcart.me/page/{category}/{slug}";
|
||||||
|
ViewBag.Title = $"{livePage.DisplayName} - {livePage.Category} | BCards";
|
||||||
|
|
||||||
|
_logger.LogInformation("Serving LivePage {LivePageId} for {Category}/{Slug}", livePage.Id, category, slug);
|
||||||
|
|
||||||
|
// Usar a mesma view do UserPage mas com dados da LivePage
|
||||||
|
return View("~/Views/UserPage/Display.cshtml", livePage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("{category}/{slug}/link/{linkIndex}")]
|
||||||
|
public async Task<IActionResult> TrackLinkClick(string category, string slug, int linkIndex)
|
||||||
|
{
|
||||||
|
var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug);
|
||||||
|
if (livePage == null || linkIndex < 0 || linkIndex >= livePage.Links.Count)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var link = livePage.Links[linkIndex];
|
||||||
|
|
||||||
|
// Track click de forma assíncrona
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _livePageService.IncrementLinkClickAsync(livePage.Id, linkIndex);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to track click for LivePage {LivePageId} link {LinkIndex}", livePage.Id, linkIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.LogInformation("Tracking click for LivePage {LivePageId} link {LinkIndex} -> {Url}", livePage.Id, linkIndex, link.Url);
|
||||||
|
|
||||||
|
return Redirect(link.Url);
|
||||||
|
}
|
||||||
|
}
|
||||||
230
src/BCards.Web/Controllers/ModerationController.cs
Normal file
230
src/BCards.Web/Controllers/ModerationController.cs
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using BCards.Web.Services;
|
||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using BCards.Web.Attributes;
|
||||||
|
|
||||||
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
|
[ModeratorAuthorize]
|
||||||
|
[Route("Moderation")]
|
||||||
|
public class ModerationController : Controller
|
||||||
|
{
|
||||||
|
private readonly IModerationService _moderationService;
|
||||||
|
private readonly IEmailService _emailService;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ILogger<ModerationController> _logger;
|
||||||
|
|
||||||
|
public ModerationController(
|
||||||
|
IModerationService moderationService,
|
||||||
|
IEmailService emailService,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ILogger<ModerationController> logger)
|
||||||
|
{
|
||||||
|
_moderationService = moderationService;
|
||||||
|
_emailService = emailService;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("Dashboard")]
|
||||||
|
public async Task<IActionResult> Dashboard(int page = 1, int size = 20)
|
||||||
|
{
|
||||||
|
var skip = (page - 1) * size;
|
||||||
|
var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size);
|
||||||
|
var stats = await _moderationService.GetModerationStatsAsync();
|
||||||
|
|
||||||
|
var viewModel = new ModerationDashboardViewModel
|
||||||
|
{
|
||||||
|
PendingPages = pendingPages.Select(p => new PendingPageViewModel
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
DisplayName = p.DisplayName,
|
||||||
|
Category = p.Category,
|
||||||
|
Slug = p.Slug,
|
||||||
|
CreatedAt = p.CreatedAt,
|
||||||
|
ModerationAttempts = p.ModerationAttempts,
|
||||||
|
PlanType = p.PlanLimitations.PlanType.ToString(),
|
||||||
|
PreviewUrl = !string.IsNullOrEmpty(p.PreviewToken)
|
||||||
|
? $"/page/{p.Category}/{p.Slug}?preview={p.PreviewToken}"
|
||||||
|
: null
|
||||||
|
}).ToList(),
|
||||||
|
Stats = stats,
|
||||||
|
CurrentPage = page,
|
||||||
|
PageSize = size,
|
||||||
|
HasNextPage = pendingPages.Count == size
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("Review/{id}")]
|
||||||
|
public async Task<IActionResult> Review(string id)
|
||||||
|
{
|
||||||
|
var page = await _moderationService.GetPageForModerationAsync(id);
|
||||||
|
if (page == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Página não encontrada ou não está pendente de moderação.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByIdAsync(page.UserId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Usuário não encontrado.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewModel = new ModerationReviewViewModel
|
||||||
|
{
|
||||||
|
Page = page,
|
||||||
|
User = user,
|
||||||
|
PreviewUrl = !string.IsNullOrEmpty(page.PreviewToken)
|
||||||
|
? $"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"
|
||||||
|
: null,
|
||||||
|
ModerationCriteria = GetModerationCriteria()
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("Approve/{id}")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Approve(string id, string notes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = await _moderationService.GetPageForModerationAsync(id);
|
||||||
|
if (page == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Página não encontrada.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByIdAsync(page.UserId);
|
||||||
|
var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
|
||||||
|
|
||||||
|
await _moderationService.ApprovePageAsync(id, moderatorId, notes);
|
||||||
|
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
await _emailService.SendModerationStatusAsync(
|
||||||
|
user.Email,
|
||||||
|
user.Name,
|
||||||
|
page.DisplayName,
|
||||||
|
PageStatus.Active.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error approving page {PageId}", id);
|
||||||
|
TempData["Error"] = "Erro ao aprovar página.";
|
||||||
|
return RedirectToAction("Review", new { id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("Reject/{id}")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Reject(string id, string reason, List<string> issues)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = await _moderationService.GetPageForModerationAsync(id);
|
||||||
|
if (page == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Página não encontrada.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByIdAsync(page.UserId);
|
||||||
|
var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
|
||||||
|
|
||||||
|
await _moderationService.RejectPageAsync(id, moderatorId, reason, issues);
|
||||||
|
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
await _emailService.SendModerationStatusAsync(
|
||||||
|
user.Email,
|
||||||
|
user.Name,
|
||||||
|
page.DisplayName,
|
||||||
|
"rejected",
|
||||||
|
reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData["Success"] = $"Página '{page.DisplayName}' rejeitada.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error rejecting page {PageId}", id);
|
||||||
|
TempData["Error"] = "Erro ao rejeitar página.";
|
||||||
|
return RedirectToAction("Review", new { id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("History")]
|
||||||
|
public async Task<IActionResult> History(int page = 1, int size = 20)
|
||||||
|
{
|
||||||
|
var skip = (page - 1) * size;
|
||||||
|
var historyPages = await _moderationService.GetModerationHistoryAsync(skip, size);
|
||||||
|
|
||||||
|
var viewModel = new ModerationHistoryViewModel
|
||||||
|
{
|
||||||
|
Pages = historyPages.Select(p => new ModerationPageViewModel
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
DisplayName = p.DisplayName,
|
||||||
|
Category = p.Category,
|
||||||
|
Slug = p.Slug,
|
||||||
|
CreatedAt = p.CreatedAt,
|
||||||
|
Status = p.Status.ToString(),
|
||||||
|
ModerationAttempts = p.ModerationAttempts,
|
||||||
|
PlanType = p.PlanLimitations.PlanType.ToString(),
|
||||||
|
ApprovedAt = p.ApprovedAt,
|
||||||
|
LastModerationEntry = p.ModerationHistory.LastOrDefault()
|
||||||
|
}).ToList(),
|
||||||
|
CurrentPage = page,
|
||||||
|
PageSize = size,
|
||||||
|
HasNextPage = historyPages.Count == size
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ModerationCriterion> GetModerationCriteria()
|
||||||
|
{
|
||||||
|
return new List<ModerationCriterion>
|
||||||
|
{
|
||||||
|
new() { Category = "Conteúdo Proibido", Items = new List<string>
|
||||||
|
{
|
||||||
|
"Pornografia e conteúdo sexual explícito",
|
||||||
|
"Drogas ilegais e substâncias controladas",
|
||||||
|
"Armas e explosivos",
|
||||||
|
"Atividades ilegais (fraudes, pirataria)",
|
||||||
|
"Apostas e jogos de azar",
|
||||||
|
"Criptomoedas e esquemas de pirâmide",
|
||||||
|
"Conteúdo que promove violência ou ódio",
|
||||||
|
"Spam e links suspeitos/maliciosos"
|
||||||
|
}},
|
||||||
|
new() { Category = "Conteúdo Suspeito", Items = new List<string>
|
||||||
|
{
|
||||||
|
"Excesso de anúncios (>30% dos links)",
|
||||||
|
"Sites com pop-ups excessivos",
|
||||||
|
"Links encurtados suspeitos",
|
||||||
|
"Conteúdo que imita marcas conhecidas",
|
||||||
|
"Produtos \"milagrosos\""
|
||||||
|
}},
|
||||||
|
new() { Category = "Verificações Técnicas", Items = new List<string>
|
||||||
|
{
|
||||||
|
"Links funcionais (não quebrados)",
|
||||||
|
"Sites com SSL válido",
|
||||||
|
"Não redirecionamentos maliciosos"
|
||||||
|
}}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,9 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace BCards.Web.Controllers;
|
namespace BCards.Web.Controllers;
|
||||||
@ -9,16 +13,15 @@ public class PaymentController : Controller
|
|||||||
{
|
{
|
||||||
private readonly IPaymentService _paymentService;
|
private readonly IPaymentService _paymentService;
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
|
private readonly IUserRepository _userService;
|
||||||
|
private readonly ISubscriptionRepository _subscriptionRepository;
|
||||||
|
|
||||||
public PaymentController(IPaymentService paymentService, IAuthService authService)
|
public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository)
|
||||||
{
|
{
|
||||||
_paymentService = paymentService;
|
_paymentService = paymentService;
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
}
|
_userService = userService;
|
||||||
|
_subscriptionRepository = subscriptionRepository;
|
||||||
public IActionResult Plans()
|
|
||||||
{
|
|
||||||
return View();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@ -31,6 +34,8 @@ public class PaymentController : Controller
|
|||||||
var successUrl = Url.Action("Success", "Payment", null, Request.Scheme);
|
var successUrl = Url.Action("Success", "Payment", null, Request.Scheme);
|
||||||
var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme);
|
var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme);
|
||||||
|
|
||||||
|
TempData[$"PlanType|{user.Id}"] = planType;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
|
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
|
||||||
@ -44,20 +49,39 @@ public class PaymentController : Controller
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
|
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
|
||||||
return RedirectToAction("Plans");
|
return RedirectToAction("Pricing", "Home");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IActionResult Success()
|
public async Task<IActionResult> Success()
|
||||||
{
|
{
|
||||||
TempData["Success"] = "Assinatura ativada com sucesso! Agora você pode aproveitar todos os recursos do seu plano.";
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
return RedirectToAction("Dashboard", "Admin");
|
var planType = TempData[$"PlanType|{user.Id}"].ToString();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(planType) && Enum.TryParse<PlanType>(planType, out var plan))
|
||||||
|
{
|
||||||
|
user.CurrentPlan = plan.ToString();
|
||||||
|
user.SubscriptionStatus = "active";
|
||||||
|
await _userService.UpdateAsync(user); // ou o método equivalente
|
||||||
|
|
||||||
|
TempData["Success"] = $"Assinatura {planType} ativada com sucesso!";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToAction("Dashboard", "Admin");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
|
||||||
|
return RedirectToAction("Dashboard", "Admin");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IActionResult Cancel()
|
public IActionResult Cancel()
|
||||||
{
|
{
|
||||||
TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser.";
|
TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser.";
|
||||||
return RedirectToAction("Plans");
|
return RedirectToAction("Pricing", "Home");
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@ -92,7 +116,36 @@ public class PaymentController : Controller
|
|||||||
if (user == null)
|
if (user == null)
|
||||||
return RedirectToAction("Login", "Auth");
|
return RedirectToAction("Login", "Auth");
|
||||||
|
|
||||||
return View(user);
|
try
|
||||||
|
{
|
||||||
|
var viewModel = new ManageSubscriptionViewModel
|
||||||
|
{
|
||||||
|
User = user,
|
||||||
|
StripeSubscription = await _paymentService.GetSubscriptionDetailsAsync(user.Id),
|
||||||
|
PaymentHistory = await _paymentService.GetPaymentHistoryAsync(user.Id),
|
||||||
|
AvailablePlans = GetAvailablePlans(user.CurrentPlan)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pegar assinatura local se existir
|
||||||
|
if (!string.IsNullOrEmpty(user.StripeCustomerId))
|
||||||
|
{
|
||||||
|
// Aqui você poderia buscar a subscription local se necessário
|
||||||
|
// viewModel.LocalSubscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return View(viewModel);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var errorViewModel = new ManageSubscriptionViewModel
|
||||||
|
{
|
||||||
|
User = user,
|
||||||
|
ErrorMessage = $"Erro ao carregar dados da assinatura: {ex.Message}",
|
||||||
|
AvailablePlans = GetAvailablePlans(user.CurrentPlan)
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(errorViewModel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@ -110,4 +163,111 @@ public class PaymentController : Controller
|
|||||||
|
|
||||||
return RedirectToAction("ManageSubscription");
|
return RedirectToAction("ManageSubscription");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> ChangePlan(string newPlanType)
|
||||||
|
{
|
||||||
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
|
if (user == null)
|
||||||
|
return RedirectToAction("Login", "Auth");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Para mudanças de plano, vamos usar o Stripe Checkout
|
||||||
|
var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
|
||||||
|
var cancelUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
|
||||||
|
|
||||||
|
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
|
||||||
|
user.Id,
|
||||||
|
newPlanType,
|
||||||
|
returnUrl!,
|
||||||
|
cancelUrl!);
|
||||||
|
|
||||||
|
return Redirect(checkoutUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
TempData["Error"] = $"Erro ao alterar plano: {ex.Message}";
|
||||||
|
return RedirectToAction("ManageSubscription");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> OpenStripePortal()
|
||||||
|
{
|
||||||
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
|
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Erro: dados de assinatura não encontrados.";
|
||||||
|
return RedirectToAction("ManageSubscription");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
|
||||||
|
var portalUrl = await _paymentService.CreatePortalSessionAsync(user.StripeCustomerId, returnUrl!);
|
||||||
|
|
||||||
|
return Redirect(portalUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
TempData["Error"] = $"Erro ao abrir portal de pagamento: {ex.Message}";
|
||||||
|
return RedirectToAction("ManageSubscription");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<AvailablePlanViewModel> GetAvailablePlans(string currentPlan)
|
||||||
|
{
|
||||||
|
var plans = new List<AvailablePlanViewModel>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
PlanType = "basic",
|
||||||
|
DisplayName = "Básico",
|
||||||
|
Price = 9.90m,
|
||||||
|
PriceId = "price_basic", // Substitua pelos IDs reais do Stripe
|
||||||
|
MaxLinks = 5,
|
||||||
|
AllowAnalytics = true,
|
||||||
|
Features = new List<string> { "5 links", "Temas básicos", "Análises básicas" },
|
||||||
|
IsCurrentPlan = currentPlan == "basic"
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
PlanType = "professional",
|
||||||
|
DisplayName = "Profissional",
|
||||||
|
Price = 24.90m,
|
||||||
|
PriceId = "price_professional", // Substitua pelos IDs reais do Stripe
|
||||||
|
MaxLinks = 15,
|
||||||
|
AllowAnalytics = true,
|
||||||
|
AllowCustomDomain = true,
|
||||||
|
Features = new List<string> { "15 links", "Todos os temas", "Domínio personalizado", "Análises avançadas" },
|
||||||
|
IsCurrentPlan = currentPlan == "professional"
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
PlanType = "premium",
|
||||||
|
DisplayName = "Premium",
|
||||||
|
Price = 29.90m,
|
||||||
|
PriceId = "price_premium", // Substitua pelos IDs reais do Stripe
|
||||||
|
MaxLinks = -1, // Ilimitado
|
||||||
|
AllowCustomThemes = true,
|
||||||
|
AllowAnalytics = true,
|
||||||
|
AllowCustomDomain = true,
|
||||||
|
Features = new List<string> { "Links ilimitados", "Temas personalizados", "Múltiplos domínios", "Suporte prioritário" },
|
||||||
|
IsCurrentPlan = currentPlan == "premium"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Marcar upgrades e downgrades
|
||||||
|
var currentPlanIndex = plans.FindIndex(p => p.IsCurrentPlan);
|
||||||
|
for (int i = 0; i < plans.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > currentPlanIndex)
|
||||||
|
plans[i].IsUpgrade = true;
|
||||||
|
else if (i < currentPlanIndex)
|
||||||
|
plans[i].IsDowngrade = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return plans;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
131
src/BCards.Web/Controllers/ProductController.cs
Normal file
131
src/BCards.Web/Controllers/ProductController.cs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ProductController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IOpenGraphService _openGraphService;
|
||||||
|
private readonly ILogger<ProductController> _logger;
|
||||||
|
|
||||||
|
public ProductController(
|
||||||
|
IOpenGraphService openGraphService,
|
||||||
|
ILogger<ProductController> logger)
|
||||||
|
{
|
||||||
|
_openGraphService = openGraphService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("extract")]
|
||||||
|
public async Task<IActionResult> ExtractProduct([FromBody] ExtractProductRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Url))
|
||||||
|
{
|
||||||
|
return BadRequest(new { success = false, message = "URL é obrigatória." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return BadRequest(new { success = false, message = "URL inválida." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
return Unauthorized(new { success = false, message = "Usuário não autenticado." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar rate limiting antes de tentar extrair
|
||||||
|
var isRateLimited = await _openGraphService.IsRateLimitedAsync(userId);
|
||||||
|
if (isRateLimited)
|
||||||
|
{
|
||||||
|
return this.TooManyRequests(new {
|
||||||
|
success = false,
|
||||||
|
message = "Aguarde 1 minuto antes de extrair dados de outro produto."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var ogData = await _openGraphService.ExtractDataAsync(request.Url, userId);
|
||||||
|
|
||||||
|
if (!ogData.IsValid)
|
||||||
|
{
|
||||||
|
return BadRequest(new {
|
||||||
|
success = false,
|
||||||
|
message = string.IsNullOrEmpty(ogData.ErrorMessage)
|
||||||
|
? "Não foi possível extrair dados desta página."
|
||||||
|
: ogData.ErrorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
success = true,
|
||||||
|
title = ogData.Title,
|
||||||
|
description = ogData.Description,
|
||||||
|
image = ogData.Image,
|
||||||
|
price = ogData.Price,
|
||||||
|
currency = ogData.Currency
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Operação inválida na extração de produto para usuário {UserId}",
|
||||||
|
User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||||
|
return BadRequest(new { success = false, message = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro interno na extração de produto para usuário {UserId}",
|
||||||
|
User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||||
|
return StatusCode(500, new {
|
||||||
|
success = false,
|
||||||
|
message = "Erro interno do servidor. Tente novamente em alguns instantes."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("cache/{urlHash}")]
|
||||||
|
public Task<IActionResult> GetCachedData(string urlHash)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Por segurança, vamos reconstruir a URL a partir do hash (se necessário)
|
||||||
|
// Por agora, apenas retornamos erro se não encontrado
|
||||||
|
return Task.FromResult<IActionResult>(NotFound(new { success = false, message = "Cache não encontrado." }));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao buscar cache para hash {UrlHash}", urlHash);
|
||||||
|
return Task.FromResult<IActionResult>(StatusCode(500, new { success = false, message = "Erro interno do servidor." }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExtractProductRequest
|
||||||
|
{
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom result for 429 Too Many Requests
|
||||||
|
public class TooManyRequestsResult : ObjectResult
|
||||||
|
{
|
||||||
|
public TooManyRequestsResult(object value) : base(value)
|
||||||
|
{
|
||||||
|
StatusCode = 429;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ControllerBaseExtensions
|
||||||
|
{
|
||||||
|
public static TooManyRequestsResult TooManyRequests(this ControllerBase controller, object value)
|
||||||
|
{
|
||||||
|
return new TooManyRequestsResult(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,11 +8,16 @@ namespace BCards.Web.Controllers;
|
|||||||
public class SitemapController : Controller
|
public class SitemapController : Controller
|
||||||
{
|
{
|
||||||
private readonly IUserPageService _userPageService;
|
private readonly IUserPageService _userPageService;
|
||||||
|
private readonly ILivePageService _livePageService;
|
||||||
private readonly ILogger<SitemapController> _logger;
|
private readonly ILogger<SitemapController> _logger;
|
||||||
|
|
||||||
public SitemapController(IUserPageService userPageService, ILogger<SitemapController> logger)
|
public SitemapController(
|
||||||
|
IUserPageService userPageService,
|
||||||
|
ILivePageService livePageService,
|
||||||
|
ILogger<SitemapController> logger)
|
||||||
{
|
{
|
||||||
_userPageService = userPageService;
|
_userPageService = userPageService;
|
||||||
|
_livePageService = livePageService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,7 +27,8 @@ public class SitemapController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var activePages = await _userPageService.GetActivePagesAsync();
|
// 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages
|
||||||
|
var livePages = await _livePageService.GetAllActiveAsync();
|
||||||
|
|
||||||
var sitemap = new XDocument(
|
var sitemap = new XDocument(
|
||||||
new XDeclaration("1.0", "utf-8", "yes"),
|
new XDeclaration("1.0", "utf-8", "yes"),
|
||||||
@ -43,11 +49,11 @@ public class SitemapController : Controller
|
|||||||
new XElement("priority", "0.9")
|
new XElement("priority", "0.9")
|
||||||
),
|
),
|
||||||
|
|
||||||
// Add user pages (only active ones)
|
// Add live pages (SEO-optimized URLs only)
|
||||||
activePages.Select(page =>
|
livePages.Select(page =>
|
||||||
new XElement("url",
|
new XElement("url",
|
||||||
new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"),
|
new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"),
|
||||||
new XElement("lastmod", page.UpdatedAt.ToString("yyyy-MM-dd")),
|
new XElement("lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")),
|
||||||
new XElement("changefreq", "weekly"),
|
new XElement("changefreq", "weekly"),
|
||||||
new XElement("priority", "0.8")
|
new XElement("priority", "0.8")
|
||||||
)
|
)
|
||||||
@ -55,7 +61,7 @@ public class SitemapController : Controller
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
_logger.LogInformation($"Generated sitemap with {activePages.Count} user pages");
|
_logger.LogInformation($"Generated sitemap with {livePages.Count} live pages");
|
||||||
|
|
||||||
return Content(sitemap.ToString(), "application/xml", Encoding.UTF8);
|
return Content(sitemap.ToString(), "application/xml", Encoding.UTF8);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
|
using BCards.Web.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace BCards.Web.Controllers;
|
namespace BCards.Web.Controllers;
|
||||||
@ -9,36 +11,88 @@ public class UserPageController : Controller
|
|||||||
private readonly IUserPageService _userPageService;
|
private readonly IUserPageService _userPageService;
|
||||||
private readonly ICategoryService _categoryService;
|
private readonly ICategoryService _categoryService;
|
||||||
private readonly ISeoService _seoService;
|
private readonly ISeoService _seoService;
|
||||||
|
private readonly IThemeService _themeService;
|
||||||
|
private readonly IModerationService _moderationService;
|
||||||
|
|
||||||
public UserPageController(
|
public UserPageController(
|
||||||
IUserPageService userPageService,
|
IUserPageService userPageService,
|
||||||
ICategoryService categoryService,
|
ICategoryService categoryService,
|
||||||
ISeoService seoService)
|
ISeoService seoService,
|
||||||
|
IThemeService themeService,
|
||||||
|
IModerationService moderationService)
|
||||||
{
|
{
|
||||||
_userPageService = userPageService;
|
_userPageService = userPageService;
|
||||||
_categoryService = categoryService;
|
_categoryService = categoryService;
|
||||||
_seoService = seoService;
|
_seoService = seoService;
|
||||||
|
_themeService = themeService;
|
||||||
|
_moderationService = moderationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
//[Route("{category}/{slug}")]
|
//[Route("{category}/{slug}")]
|
||||||
[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })]
|
//VOltar a linha abaixo em prod
|
||||||
|
//[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })]
|
||||||
public async Task<IActionResult> Display(string category, string slug)
|
public async Task<IActionResult> Display(string category, string slug)
|
||||||
{
|
{
|
||||||
var userPage = await _userPageService.GetPageAsync(category, slug);
|
var userPage = await _userPageService.GetPageAsync(category, slug);
|
||||||
if (userPage == null || !userPage.IsActive)
|
if (userPage == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
||||||
if (categoryObj == null)
|
if (categoryObj == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
|
// Check if it's a preview request
|
||||||
|
var isPreview = HttpContext.Items.ContainsKey("IsPreview") && (bool)HttpContext.Items["IsPreview"];
|
||||||
|
var previewToken = Request.Query["preview"].FirstOrDefault();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(previewToken))
|
||||||
|
{
|
||||||
|
// Handle preview request
|
||||||
|
var isValidPreview = await _moderationService.ValidatePreviewTokenAsync(userPage.Id, previewToken);
|
||||||
|
if (!isValidPreview)
|
||||||
|
{
|
||||||
|
return View("PreviewExpired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set preview flag
|
||||||
|
ViewBag.IsPreview = true;
|
||||||
|
ViewBag.PreviewToken = previewToken;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Regular request - check if page is active
|
||||||
|
if (userPage.Status == ViewModels.PageStatus.PendingModeration)
|
||||||
|
{
|
||||||
|
return View("PendingModeration");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userPage.Status == ViewModels.PageStatus.Rejected)
|
||||||
|
{
|
||||||
|
return View("PageRejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userPage.Status == ViewModels.PageStatus.Inactive || !userPage.IsActive)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure theme is loaded - critical fix for theme display issue
|
||||||
|
if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor))
|
||||||
|
{
|
||||||
|
userPage.Theme = _themeService.GetDefaultTheme();
|
||||||
|
}
|
||||||
|
|
||||||
// Generate SEO settings
|
// Generate SEO settings
|
||||||
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
|
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
|
||||||
|
|
||||||
// Record page view (async, don't wait)
|
// Record page view (async, don't wait) - only for non-preview requests
|
||||||
var referrer = Request.Headers["Referer"].FirstOrDefault();
|
if (!isPreview)
|
||||||
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
|
{
|
||||||
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
|
var referrer = Request.Headers["Referer"].FirstOrDefault();
|
||||||
|
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
|
||||||
|
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
|
||||||
|
}
|
||||||
|
|
||||||
ViewBag.SeoSettings = seoSettings;
|
ViewBag.SeoSettings = seoSettings;
|
||||||
ViewBag.Category = categoryObj;
|
ViewBag.Category = categoryObj;
|
||||||
@ -65,9 +119,16 @@ public class UserPageController : Controller
|
|||||||
if (categoryObj == null)
|
if (categoryObj == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
|
// Ensure theme is loaded for preview too
|
||||||
|
if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor))
|
||||||
|
{
|
||||||
|
userPage.Theme = _themeService.GetDefaultTheme();
|
||||||
|
}
|
||||||
|
|
||||||
ViewBag.Category = categoryObj;
|
ViewBag.Category = categoryObj;
|
||||||
ViewBag.IsPreview = true;
|
ViewBag.IsPreview = true;
|
||||||
|
|
||||||
return View("Display", userPage);
|
return View("Display", userPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
45
src/BCards.Web/Middleware/ModerationAuthMiddleware.cs
Normal file
45
src/BCards.Web/Middleware/ModerationAuthMiddleware.cs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
using BCards.Web.Services;
|
||||||
|
|
||||||
|
namespace BCards.Web.Middleware
|
||||||
|
{
|
||||||
|
public class ModerationAuthMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly IModerationAuthService _moderationAuth;
|
||||||
|
|
||||||
|
public ModerationAuthMiddleware(RequestDelegate next, IModerationAuthService moderationAuth)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_moderationAuth = moderationAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var path = context.Request.Path.Value?.ToLowerInvariant();
|
||||||
|
|
||||||
|
// Verificar se é uma rota de moderação
|
||||||
|
if (path != null && path.StartsWith("/moderation"))
|
||||||
|
{
|
||||||
|
// Verificar se usuário está autenticado
|
||||||
|
if (!context.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
context.Response.Redirect("/Auth/Login?returnUrl=" + Uri.EscapeDataString(context.Request.Path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é moderador
|
||||||
|
if (!_moderationAuth.IsUserModerator(context.User))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = 403;
|
||||||
|
await context.Response.WriteAsync("Acesso negado. Você não tem permissão para acessar esta área.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar flag para usar nas views
|
||||||
|
context.Items["IsModerator"] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -51,9 +51,33 @@ public class PageStatusMiddleware
|
|||||||
await context.Response.WriteAsync("Página temporariamente indisponível.");
|
await context.Response.WriteAsync("Página temporariamente indisponível.");
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
case PageStatus.Creating:
|
||||||
|
case PageStatus.PendingModeration:
|
||||||
|
case PageStatus.Rejected:
|
||||||
|
// Páginas em desenvolvimento/moderação requerem preview token
|
||||||
|
var previewToken = context.Request.Query["preview"].FirstOrDefault();
|
||||||
|
if (string.IsNullOrEmpty(previewToken) ||
|
||||||
|
string.IsNullOrEmpty(page.PreviewToken) ||
|
||||||
|
previewToken != page.PreviewToken ||
|
||||||
|
page.PreviewTokenExpiry < DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"Page {category}/{slug} requires valid preview token");
|
||||||
|
context.Response.StatusCode = 404;
|
||||||
|
await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case PageStatus.Active:
|
case PageStatus.Active:
|
||||||
// Continuar processamento normal
|
// Continuar processamento normal
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Status desconhecido - tratar como inativo
|
||||||
|
_logger.LogWarning($"Unknown page status: {page.Status} for page {category}/{slug}");
|
||||||
|
context.Response.StatusCode = 404;
|
||||||
|
await context.Response.WriteAsync("Página temporariamente indisponível.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
121
src/BCards.Web/Middleware/PreviewTokenMiddleware.cs
Normal file
121
src/BCards.Web/Middleware/PreviewTokenMiddleware.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/BCards.Web/Models/IPageDisplay.cs
Normal file
26
src/BCards.Web/Models/IPageDisplay.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
namespace BCards.Web.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Interface comum para páginas que podem ser exibidas publicamente
|
||||||
|
/// Facilita o envio de dados para views sem duplicação de código
|
||||||
|
/// </summary>
|
||||||
|
public interface IPageDisplay
|
||||||
|
{
|
||||||
|
string Id { get; }
|
||||||
|
string UserId { get; }
|
||||||
|
string Category { get; }
|
||||||
|
string Slug { get; }
|
||||||
|
string DisplayName { get; }
|
||||||
|
string Bio { get; }
|
||||||
|
string ProfileImage { get; }
|
||||||
|
string BusinessType { get; }
|
||||||
|
PageTheme Theme { get; }
|
||||||
|
List<LinkItem> Links { get; }
|
||||||
|
SeoSettings SeoSettings { get; }
|
||||||
|
string Language { get; }
|
||||||
|
DateTime CreatedAt { get; }
|
||||||
|
|
||||||
|
// Propriedade calculada comum
|
||||||
|
string FullUrl { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,12 @@ using MongoDB.Bson.Serialization.Attributes;
|
|||||||
|
|
||||||
namespace BCards.Web.Models;
|
namespace BCards.Web.Models;
|
||||||
|
|
||||||
|
public enum LinkType
|
||||||
|
{
|
||||||
|
Normal = 0, // Link comum
|
||||||
|
Product = 1 // Link de produto com preview
|
||||||
|
}
|
||||||
|
|
||||||
public class LinkItem
|
public class LinkItem
|
||||||
{
|
{
|
||||||
[BsonElement("title")]
|
[BsonElement("title")]
|
||||||
@ -27,4 +33,23 @@ public class LinkItem
|
|||||||
|
|
||||||
[BsonElement("createdAt")]
|
[BsonElement("createdAt")]
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Campos para Link de Produto
|
||||||
|
[BsonElement("type")]
|
||||||
|
public LinkType Type { get; set; } = LinkType.Normal;
|
||||||
|
|
||||||
|
[BsonElement("productTitle")]
|
||||||
|
public string ProductTitle { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("productImage")]
|
||||||
|
public string ProductImage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("productPrice")]
|
||||||
|
public string ProductPrice { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("productDescription")]
|
||||||
|
public string ProductDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("productDataCachedAt")]
|
||||||
|
public DateTime? ProductDataCachedAt { get; set; }
|
||||||
}
|
}
|
||||||
75
src/BCards.Web/Models/LivePage.cs
Normal file
75
src/BCards.Web/Models/LivePage.cs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace BCards.Web.Models;
|
||||||
|
|
||||||
|
public class LivePage : IPageDisplay
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("originalPageId")]
|
||||||
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
|
public string OriginalPageId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("userId")]
|
||||||
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("category")]
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("slug")]
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("displayName")]
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("bio")]
|
||||||
|
public string Bio { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("profileImage")]
|
||||||
|
public string ProfileImage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("businessType")]
|
||||||
|
public string BusinessType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("theme")]
|
||||||
|
public PageTheme Theme { get; set; } = new();
|
||||||
|
|
||||||
|
[BsonElement("links")]
|
||||||
|
public List<LinkItem> Links { get; set; } = new();
|
||||||
|
|
||||||
|
[BsonElement("seoSettings")]
|
||||||
|
public SeoSettings SeoSettings { get; set; } = new();
|
||||||
|
|
||||||
|
[BsonElement("language")]
|
||||||
|
public string Language { get; set; } = "pt-BR";
|
||||||
|
|
||||||
|
[BsonElement("analytics")]
|
||||||
|
public LivePageAnalytics Analytics { get; set; } = new();
|
||||||
|
|
||||||
|
[BsonElement("publishedAt")]
|
||||||
|
public DateTime PublishedAt { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("lastSyncAt")]
|
||||||
|
public DateTime LastSyncAt { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("createdAt")]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public string FullUrl => $"page/{Category}/{Slug}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LivePageAnalytics
|
||||||
|
{
|
||||||
|
[BsonElement("totalViews")]
|
||||||
|
public long TotalViews { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("totalClicks")]
|
||||||
|
public long TotalClicks { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("lastViewedAt")]
|
||||||
|
public DateTime? LastViewedAt { get; set; }
|
||||||
|
}
|
||||||
25
src/BCards.Web/Models/ModerationHistory.cs
Normal file
25
src/BCards.Web/Models/ModerationHistory.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace BCards.Web.Models;
|
||||||
|
|
||||||
|
public class ModerationHistory
|
||||||
|
{
|
||||||
|
[BsonElement("attempt")]
|
||||||
|
public int Attempt { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("status")]
|
||||||
|
public string Status { get; set; } = "pending"; // "pending", "approved", "rejected"
|
||||||
|
|
||||||
|
[BsonElement("reason")]
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("moderatorId")]
|
||||||
|
public string? ModeratorId { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("date")]
|
||||||
|
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[BsonElement("issues")]
|
||||||
|
public List<string> Issues { get; set; } = new();
|
||||||
|
}
|
||||||
55
src/BCards.Web/Models/OpenGraphCache.cs
Normal file
55
src/BCards.Web/Models/OpenGraphCache.cs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace BCards.Web.Models;
|
||||||
|
|
||||||
|
public class OpenGraphCache
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("url")]
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("urlHash")]
|
||||||
|
public string UrlHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("description")]
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("image")]
|
||||||
|
public string Image { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("price")]
|
||||||
|
public string Price { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("currency")]
|
||||||
|
public string Currency { get; set; } = "BRL";
|
||||||
|
|
||||||
|
[BsonElement("isValid")]
|
||||||
|
public bool IsValid { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("errorMessage")]
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("cachedAt")]
|
||||||
|
public DateTime CachedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[BsonElement("expiresAt")]
|
||||||
|
public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(24);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OpenGraphData
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Image { get; set; } = string.Empty;
|
||||||
|
public string Price { get; set; } = string.Empty;
|
||||||
|
public string Currency { get; set; } = "BRL";
|
||||||
|
public bool IsValid { get; set; }
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -24,4 +24,20 @@ public class PlanLimitations
|
|||||||
|
|
||||||
[BsonElement("planType")]
|
[BsonElement("planType")]
|
||||||
public string PlanType { get; set; } = "free";
|
public string PlanType { get; set; } = "free";
|
||||||
|
|
||||||
|
// Novos campos para Links de Produto
|
||||||
|
[BsonElement("maxProductLinks")]
|
||||||
|
public int MaxProductLinks { get; set; } = 0;
|
||||||
|
|
||||||
|
[BsonElement("maxOGExtractionsPerDay")]
|
||||||
|
public int MaxOGExtractionsPerDay { get; set; } = 0;
|
||||||
|
|
||||||
|
[BsonElement("allowProductLinks")]
|
||||||
|
public bool AllowProductLinks { get; set; } = false;
|
||||||
|
|
||||||
|
[BsonElement("ogExtractionsUsedToday")]
|
||||||
|
public int OGExtractionsUsedToday { get; set; } = 0;
|
||||||
|
|
||||||
|
[BsonElement("lastExtractionDate")]
|
||||||
|
public DateTime? LastExtractionDate { get; set; }
|
||||||
}
|
}
|
||||||
@ -91,4 +91,33 @@ public static class PlanTypeExtensions
|
|||||||
{
|
{
|
||||||
return planType == PlanType.Trial ? 7 : 0;
|
return planType == PlanType.Trial ? 7 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int GetMaxProductLinks(this PlanType planType)
|
||||||
|
{
|
||||||
|
return planType switch
|
||||||
|
{
|
||||||
|
PlanType.Trial => 1, // 1 link de produto para trial
|
||||||
|
PlanType.Basic => 3, // 3 links de produto
|
||||||
|
PlanType.Professional => 8, // DECOY - mais caro para poucos benefícios
|
||||||
|
PlanType.Premium => int.MaxValue, // Ilimitado
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetMaxOGExtractionsPerDay(this PlanType planType)
|
||||||
|
{
|
||||||
|
return planType switch
|
||||||
|
{
|
||||||
|
PlanType.Trial => 2, // 2 extrações por dia no trial
|
||||||
|
PlanType.Basic => 5, // 5 extrações por dia
|
||||||
|
PlanType.Professional => 15, // 15 extrações por dia
|
||||||
|
PlanType.Premium => int.MaxValue, // Ilimitado
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool AllowsProductLinks(this PlanType planType)
|
||||||
|
{
|
||||||
|
return GetMaxProductLinks(planType) > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -4,7 +4,7 @@ using BCards.Web.ViewModels;
|
|||||||
|
|
||||||
namespace BCards.Web.Models;
|
namespace BCards.Web.Models;
|
||||||
|
|
||||||
public class UserPage
|
public class UserPage : IPageDisplay
|
||||||
{
|
{
|
||||||
[BsonId]
|
[BsonId]
|
||||||
[BsonRepresentation(BsonType.ObjectId)]
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
@ -65,5 +65,26 @@ public class UserPage
|
|||||||
[BsonElement("status")]
|
[BsonElement("status")]
|
||||||
public PageStatus Status { get; set; } = PageStatus.Active;
|
public PageStatus Status { get; set; } = PageStatus.Active;
|
||||||
|
|
||||||
|
[BsonElement("previewToken")]
|
||||||
|
public string? PreviewToken { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("previewTokenExpiry")]
|
||||||
|
public DateTime? PreviewTokenExpiry { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("moderationAttempts")]
|
||||||
|
public int ModerationAttempts { get; set; } = 0;
|
||||||
|
|
||||||
|
[BsonElement("moderationHistory")]
|
||||||
|
public List<ModerationHistory> ModerationHistory { get; set; } = new();
|
||||||
|
|
||||||
|
[BsonElement("approvedAt")]
|
||||||
|
public DateTime? ApprovedAt { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("userScore")]
|
||||||
|
public int UserScore { get; set; } = 100;
|
||||||
|
|
||||||
|
[BsonElement("previewViewCount")]
|
||||||
|
public int PreviewViewCount { get; set; } = 0;
|
||||||
|
|
||||||
public string FullUrl => $"page/{Category}/{Slug}";
|
public string FullUrl => $"page/{Category}/{Slug}";
|
||||||
}
|
}
|
||||||
@ -8,6 +8,10 @@ using Microsoft.AspNetCore.Localization;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Stripe;
|
||||||
|
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||||
|
using SendGrid;
|
||||||
|
using BCards.Web.Middleware;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@ -45,6 +49,10 @@ builder.Services.Configure<GoogleAuthSettings>(
|
|||||||
builder.Services.Configure<MicrosoftAuthSettings>(
|
builder.Services.Configure<MicrosoftAuthSettings>(
|
||||||
builder.Configuration.GetSection("Authentication:Microsoft"));
|
builder.Configuration.GetSection("Authentication:Microsoft"));
|
||||||
|
|
||||||
|
// Adicionar configurações
|
||||||
|
builder.Services.Configure<ModerationSettings>(
|
||||||
|
builder.Configuration.GetSection("Moderation"));
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
@ -69,6 +77,19 @@ builder.Services.AddAuthentication(options =>
|
|||||||
var msAuth = builder.Configuration.GetSection("Authentication:Microsoft");
|
var msAuth = builder.Configuration.GetSection("Authentication:Microsoft");
|
||||||
options.ClientId = msAuth["ClientId"] ?? "";
|
options.ClientId = msAuth["ClientId"] ?? "";
|
||||||
options.ClientSecret = msAuth["ClientSecret"] ?? "";
|
options.ClientSecret = msAuth["ClientSecret"] ?? "";
|
||||||
|
|
||||||
|
// Força seleção de conta a cada login
|
||||||
|
options.AuthorizationEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
||||||
|
options.TokenEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
||||||
|
|
||||||
|
options.Events = new OAuthEvents
|
||||||
|
{
|
||||||
|
OnRedirectToAuthorizationEndpoint = context =>
|
||||||
|
{
|
||||||
|
context.Response.Redirect(context.RedirectUri + "&prompt=select_account");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Localization
|
// Localization
|
||||||
@ -92,6 +113,8 @@ builder.Services.AddScoped<IUserRepository, UserRepository>();
|
|||||||
builder.Services.AddScoped<IUserPageRepository, UserPageRepository>();
|
builder.Services.AddScoped<IUserPageRepository, UserPageRepository>();
|
||||||
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||||
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
||||||
|
builder.Services.AddSingleton<IModerationAuthService, ModerationAuthService>();
|
||||||
|
//builder.Services.AddScoped<IModerationAuthService, ModerationAuthService>();
|
||||||
|
|
||||||
builder.Services.AddScoped<IUserPageService, UserPageService>();
|
builder.Services.AddScoped<IUserPageService, UserPageService>();
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
@ -99,6 +122,23 @@ builder.Services.AddScoped<ISeoService, SeoService>();
|
|||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
||||||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||||||
|
builder.Services.AddScoped<IOpenGraphService, OpenGraphService>();
|
||||||
|
builder.Services.AddScoped<IModerationService, ModerationService>();
|
||||||
|
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||||
|
|
||||||
|
// 🔥 NOVO: LivePage Services
|
||||||
|
builder.Services.AddScoped<ILivePageRepository, LivePageRepository>();
|
||||||
|
builder.Services.AddScoped<ILivePageService, LivePageService>();
|
||||||
|
|
||||||
|
// Add HttpClient for OpenGraphService
|
||||||
|
builder.Services.AddHttpClient<OpenGraphService>();
|
||||||
|
|
||||||
|
// Add SendGrid
|
||||||
|
builder.Services.AddSingleton<ISendGridClient>(provider =>
|
||||||
|
{
|
||||||
|
var apiKey = builder.Configuration["SendGrid:ApiKey"];
|
||||||
|
return new SendGridClient(apiKey);
|
||||||
|
});
|
||||||
|
|
||||||
// Background Services
|
// Background Services
|
||||||
builder.Services.AddHostedService<TrialExpirationService>();
|
builder.Services.AddHostedService<TrialExpirationService>();
|
||||||
@ -131,45 +171,61 @@ app.UseAuthorization();
|
|||||||
// Add custom middleware
|
// Add custom middleware
|
||||||
app.UseMiddleware<BCards.Web.Middleware.PlanLimitationMiddleware>();
|
app.UseMiddleware<BCards.Web.Middleware.PlanLimitationMiddleware>();
|
||||||
app.UseMiddleware<BCards.Web.Middleware.PageStatusMiddleware>();
|
app.UseMiddleware<BCards.Web.Middleware.PageStatusMiddleware>();
|
||||||
|
app.UseMiddleware<BCards.Web.Middleware.PreviewTokenMiddleware>();
|
||||||
|
app.UseMiddleware<ModerationAuthMiddleware>();
|
||||||
|
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
Console.WriteLine($"=== REQUEST DEBUG ===");
|
||||||
|
Console.WriteLine($"Path: {context.Request.Path}");
|
||||||
|
Console.WriteLine($"Query: {context.Request.QueryString}");
|
||||||
|
Console.WriteLine($"Method: {context.Request.Method}");
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
app.UseResponseCaching();
|
app.UseResponseCaching();
|
||||||
|
|
||||||
// Rota padr<64>o primeiro (mais espec<65>fica)
|
// Rotas específicas primeiro
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "default",
|
name: "userpage-preview-path",
|
||||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
|
||||||
|
|
||||||
//Rota customizada depois (mais gen<65>rica)
|
|
||||||
//app.MapControllerRoute(
|
|
||||||
// name: "userpage",
|
|
||||||
// pattern: "page/{category}/{slug}",
|
|
||||||
// defaults: new { controller = "UserPage", action = "Display" },
|
|
||||||
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
|
||||||
// Rota para preview
|
|
||||||
app.MapControllerRoute(
|
|
||||||
name: "userpage-preview",
|
|
||||||
pattern: "page/preview/{category}/{slug}",
|
pattern: "page/preview/{category}/{slug}",
|
||||||
defaults: new { controller = "UserPage", action = "Preview" },
|
defaults: new { controller = "UserPage", action = "Preview" },
|
||||||
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||||
|
|
||||||
// Rota para click
|
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "userpage-click",
|
name: "userpage-click",
|
||||||
pattern: "page/click/{pageId}",
|
pattern: "page/click/{pageId}",
|
||||||
defaults: new { controller = "UserPage", action = "RecordClick" });
|
defaults: new { controller = "UserPage", action = "RecordClick" });
|
||||||
|
|
||||||
// Rota principal (deve vir por último)
|
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "userpage",
|
name: "moderation",
|
||||||
pattern: "page/{category}/{slug}",
|
pattern: "moderation/{action=Dashboard}/{id?}",
|
||||||
defaults: new { controller = "UserPage", action = "Display" },
|
defaults: new { controller = "Moderation" });
|
||||||
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
|
||||||
|
|
||||||
// Rota padrão
|
// Rota principal que vai pegar ?preview=token
|
||||||
|
//app.MapControllerRoute(
|
||||||
|
// name: "userpage",
|
||||||
|
// pattern: "page/{category}/{slug}",
|
||||||
|
// defaults: new { controller = "UserPage", action = "Display" },
|
||||||
|
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||||
|
|
||||||
|
// 🔥 NOVA ROTA: LivePageController para páginas otimizadas de SEO
|
||||||
|
app.MapControllerRoute(
|
||||||
|
name: "livepage",
|
||||||
|
pattern: "page/{category}/{slug}",
|
||||||
|
defaults: new { controller = "LivePage", action = "Display" },
|
||||||
|
constraints: new
|
||||||
|
{
|
||||||
|
category = @"^[a-zA-Z0-9\-\u00C0-\u017F]+$", // ← Aceita acentos
|
||||||
|
slug = @"^[a-z0-9-]+$"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rota padrão por último
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "default",
|
name: "default",
|
||||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||||
|
|
||||||
|
|
||||||
// Initialize default data
|
// Initialize default data
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
@ -200,3 +256,6 @@ using (var scope = app.Services.CreateScope())
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
// Make Program accessible for integration tests
|
||||||
|
public partial class Program { }
|
||||||
17
src/BCards.Web/Repositories/ILivePageRepository.cs
Normal file
17
src/BCards.Web/Repositories/ILivePageRepository.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.Repositories;
|
||||||
|
|
||||||
|
public interface ILivePageRepository
|
||||||
|
{
|
||||||
|
Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
|
||||||
|
Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId);
|
||||||
|
Task<List<LivePage>> GetAllActiveAsync();
|
||||||
|
Task<LivePage> CreateAsync(LivePage livePage);
|
||||||
|
Task<LivePage> UpdateAsync(LivePage livePage);
|
||||||
|
Task<bool> DeleteAsync(string id);
|
||||||
|
Task<bool> DeleteByOriginalPageIdAsync(string originalPageId);
|
||||||
|
Task<bool> ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null);
|
||||||
|
Task IncrementViewAsync(string id);
|
||||||
|
Task IncrementLinkClickAsync(string id, int linkIndex);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
namespace BCards.Web.Repositories;
|
namespace BCards.Web.Repositories;
|
||||||
|
|
||||||
@ -16,4 +17,15 @@ public interface IUserPageRepository
|
|||||||
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
|
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
|
||||||
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
|
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
|
||||||
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
|
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
|
||||||
|
Task<List<UserPage>> GetManyAsync(
|
||||||
|
FilterDefinition<UserPage> filter,
|
||||||
|
SortDefinition<UserPage>? sort = null,
|
||||||
|
int skip = 0,
|
||||||
|
int take = 20);
|
||||||
|
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20);
|
||||||
|
Task<long> CountAsync(FilterDefinition<UserPage> filter);
|
||||||
|
Task<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update);
|
||||||
|
Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update);
|
||||||
|
Task<bool> ApprovePageAsync(string pageId);
|
||||||
|
Task<bool> RejectPageAsync(string pageId, string reason, List<string> issues);
|
||||||
}
|
}
|
||||||
122
src/BCards.Web/Repositories/LivePageRepository.cs
Normal file
122
src/BCards.Web/Repositories/LivePageRepository.cs
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
namespace BCards.Web.Repositories;
|
||||||
|
|
||||||
|
public class LivePageRepository : ILivePageRepository
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<LivePage> _collection;
|
||||||
|
|
||||||
|
public LivePageRepository(IMongoDatabase database)
|
||||||
|
{
|
||||||
|
_collection = database.GetCollection<LivePage>("livepages");
|
||||||
|
|
||||||
|
// Criar índices essenciais
|
||||||
|
CreateIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateIndexes()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Índice único para category + slug
|
||||||
|
var categorySlugIndex = Builders<LivePage>.IndexKeys
|
||||||
|
.Ascending(x => x.Category)
|
||||||
|
.Ascending(x => x.Slug);
|
||||||
|
|
||||||
|
var uniqueOptions = new CreateIndexOptions { Unique = true };
|
||||||
|
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(categorySlugIndex, uniqueOptions));
|
||||||
|
|
||||||
|
// Outros índices importantes
|
||||||
|
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
|
||||||
|
Builders<LivePage>.IndexKeys.Ascending(x => x.UserId)));
|
||||||
|
|
||||||
|
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
|
||||||
|
Builders<LivePage>.IndexKeys.Descending(x => x.PublishedAt)));
|
||||||
|
|
||||||
|
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
|
||||||
|
Builders<LivePage>.IndexKeys.Ascending(x => x.OriginalPageId)));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignora erros de criação de índices (já podem existir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
|
||||||
|
{
|
||||||
|
return await _collection.Find(x => x.Category == category && x.Slug == slug).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId)
|
||||||
|
{
|
||||||
|
return await _collection.Find(x => x.OriginalPageId == originalPageId).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<LivePage>> GetAllActiveAsync()
|
||||||
|
{
|
||||||
|
return await _collection.Find(x => true)
|
||||||
|
.Sort(Builders<LivePage>.Sort.Descending(x => x.PublishedAt))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LivePage> CreateAsync(LivePage livePage)
|
||||||
|
{
|
||||||
|
livePage.CreatedAt = DateTime.UtcNow;
|
||||||
|
livePage.LastSyncAt = DateTime.UtcNow;
|
||||||
|
await _collection.InsertOneAsync(livePage);
|
||||||
|
return livePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LivePage> UpdateAsync(LivePage livePage)
|
||||||
|
{
|
||||||
|
livePage.LastSyncAt = DateTime.UtcNow;
|
||||||
|
await _collection.ReplaceOneAsync(x => x.Id == livePage.Id, livePage);
|
||||||
|
return livePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(string id)
|
||||||
|
{
|
||||||
|
var result = await _collection.DeleteOneAsync(x => x.Id == id);
|
||||||
|
return result.DeletedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteByOriginalPageIdAsync(string originalPageId)
|
||||||
|
{
|
||||||
|
var result = await _collection.DeleteOneAsync(x => x.OriginalPageId == originalPageId);
|
||||||
|
return result.DeletedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null)
|
||||||
|
{
|
||||||
|
var filter = Builders<LivePage>.Filter.And(
|
||||||
|
Builders<LivePage>.Filter.Eq(x => x.Category, category),
|
||||||
|
Builders<LivePage>.Filter.Eq(x => x.Slug, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(excludeId))
|
||||||
|
{
|
||||||
|
filter = Builders<LivePage>.Filter.And(filter,
|
||||||
|
Builders<LivePage>.Filter.Ne(x => x.Id, excludeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _collection.Find(filter).AnyAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task IncrementViewAsync(string id)
|
||||||
|
{
|
||||||
|
var update = Builders<LivePage>.Update
|
||||||
|
.Inc(x => x.Analytics.TotalViews, 1)
|
||||||
|
.Set(x => x.Analytics.LastViewedAt, DateTime.UtcNow);
|
||||||
|
|
||||||
|
await _collection.UpdateOneAsync(x => x.Id == id, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task IncrementLinkClickAsync(string id, int linkIndex)
|
||||||
|
{
|
||||||
|
var update = Builders<LivePage>.Update
|
||||||
|
.Inc(x => x.Analytics.TotalClicks, 1);
|
||||||
|
|
||||||
|
await _collection.UpdateOneAsync(x => x.Id == id, update);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,7 +31,7 @@ public class UserPageRepository : IUserPageRepository
|
|||||||
|
|
||||||
public async Task<UserPage?> GetBySlugAsync(string category, string slug)
|
public async Task<UserPage?> GetBySlugAsync(string category, string slug)
|
||||||
{
|
{
|
||||||
return await _pages.Find(x => x.Category == category.ToLower() && x.Slug == slug && x.IsActive).FirstOrDefaultAsync();
|
return await _pages.Find(x => x.Category.ToLower() == category.ToLower() && x.Slug == slug && x.IsActive).FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserPage?> GetByUserIdAsync(string userId)
|
public async Task<UserPage?> GetByUserIdAsync(string userId)
|
||||||
@ -116,4 +116,105 @@ public class UserPageRepository : IUserPageRepository
|
|||||||
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adicione estes métodos no UserPageRepository.cs
|
||||||
|
|
||||||
|
public async Task<List<UserPage>> GetManyAsync(
|
||||||
|
FilterDefinition<UserPage> filter,
|
||||||
|
SortDefinition<UserPage>? sort = null,
|
||||||
|
int skip = 0,
|
||||||
|
int take = 20)
|
||||||
|
{
|
||||||
|
var query = _pages.Find(filter);
|
||||||
|
|
||||||
|
if (sort != null)
|
||||||
|
query = query.Sort(sort);
|
||||||
|
|
||||||
|
return await query.Skip(skip).Limit(take).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> CountAsync(FilterDefinition<UserPage> filter)
|
||||||
|
{
|
||||||
|
return await _pages.CountDocumentsAsync(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Método específico para moderação (mais simples)
|
||||||
|
public async Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserPage>.Filter.Eq(x => x.Status, BCards.Web.ViewModels.PageStatus.PendingModeration);
|
||||||
|
|
||||||
|
var sort = Builders<UserPage>.Sort
|
||||||
|
.Ascending("planLimitations.planType") // Premium primeiro
|
||||||
|
.Ascending(x => x.CreatedAt); // Mais antigos primeiro
|
||||||
|
|
||||||
|
return await _pages.Find(filter)
|
||||||
|
.Sort(sort)
|
||||||
|
.Skip(skip)
|
||||||
|
.Limit(take)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicione estes métodos no UserPageRepository.cs
|
||||||
|
|
||||||
|
public async Task<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update)
|
||||||
|
{
|
||||||
|
var combinedUpdate = Builders<UserPage>.Update
|
||||||
|
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
|
||||||
|
|
||||||
|
return await _pages.UpdateOneAsync(x => x.Id == id, combinedUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update)
|
||||||
|
{
|
||||||
|
var combinedUpdate = Builders<UserPage>.Update
|
||||||
|
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
|
||||||
|
|
||||||
|
return await _pages.UpdateManyAsync(filter, combinedUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Métodos específicos para moderação (mais fáceis de usar)
|
||||||
|
public async Task<bool> ApprovePageAsync(string pageId)
|
||||||
|
{
|
||||||
|
var update = Builders<UserPage>.Update
|
||||||
|
.Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Active)
|
||||||
|
.Set(x => x.ApprovedAt, DateTime.UtcNow)
|
||||||
|
.Set(x => x.PublishedAt, DateTime.UtcNow)
|
||||||
|
.Unset(x => x.PreviewToken)
|
||||||
|
.Unset(x => x.PreviewTokenExpiry)
|
||||||
|
.Set(x => x.UpdatedAt, DateTime.UtcNow);
|
||||||
|
|
||||||
|
var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update);
|
||||||
|
return result.ModifiedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RejectPageAsync(string pageId, string reason, List<string> issues)
|
||||||
|
{
|
||||||
|
var page = await GetByIdAsync(pageId);
|
||||||
|
if (page == null) return false;
|
||||||
|
|
||||||
|
// Adicionar à história de moderação
|
||||||
|
var historyEntry = new ModerationHistory
|
||||||
|
{
|
||||||
|
Attempt = page.ModerationAttempts + 1,
|
||||||
|
Status = "rejected",
|
||||||
|
Reason = reason,
|
||||||
|
Date = DateTime.UtcNow,
|
||||||
|
Issues = issues
|
||||||
|
};
|
||||||
|
|
||||||
|
page.ModerationHistory.Add(historyEntry);
|
||||||
|
|
||||||
|
var update = Builders<UserPage>.Update
|
||||||
|
.Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Rejected)
|
||||||
|
.Set(x => x.ModerationAttempts, page.ModerationAttempts + 1)
|
||||||
|
.Set(x => x.ModerationHistory, page.ModerationHistory)
|
||||||
|
.Unset(x => x.PreviewToken)
|
||||||
|
.Unset(x => x.PreviewTokenExpiry)
|
||||||
|
.Set(x => x.UpdatedAt, DateTime.UtcNow);
|
||||||
|
|
||||||
|
var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update);
|
||||||
|
return result.ModifiedCount > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ using BCards.Web.Repositories;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using BCards.Web.Utils;
|
||||||
|
|
||||||
namespace BCards.Web.Services;
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
@ -22,7 +23,7 @@ public class CategoryService : ICategoryService
|
|||||||
|
|
||||||
public async Task<Category?> GetCategoryBySlugAsync(string slug)
|
public async Task<Category?> GetCategoryBySlugAsync(string slug)
|
||||||
{
|
{
|
||||||
return await _categoryRepository.GetBySlugAsync(slug);
|
return await _categoryRepository.GetBySlugAsync(SlugHelper.CreateSlug(slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GenerateSlugAsync(string name)
|
public async Task<string> GenerateSlugAsync(string name)
|
||||||
|
|||||||
207
src/BCards.Web/Services/EmailService.cs
Normal file
207
src/BCards.Web/Services/EmailService.cs
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
using SendGrid;
|
||||||
|
using SendGrid.Helpers.Mail;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public class EmailService : IEmailService
|
||||||
|
{
|
||||||
|
private readonly ISendGridClient _sendGridClient;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<EmailService> _logger;
|
||||||
|
|
||||||
|
public EmailService(
|
||||||
|
ISendGridClient sendGridClient,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<EmailService> logger)
|
||||||
|
{
|
||||||
|
_sendGridClient = sendGridClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null)
|
||||||
|
{
|
||||||
|
var (subject, htmlContent) = status switch
|
||||||
|
{
|
||||||
|
"pending" => GetPendingModerationTemplate(userName, pageTitle, previewUrl),
|
||||||
|
"approved" => GetApprovedTemplate(userName, pageTitle),
|
||||||
|
"rejected" => GetRejectedTemplate(userName, pageTitle, reason),
|
||||||
|
_ => throw new ArgumentException($"Unknown status: {status}")
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendEmailAsync(userEmail, subject, htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName)
|
||||||
|
{
|
||||||
|
var moderatorEmail = _configuration["Moderation:ModeratorEmail"];
|
||||||
|
if (string.IsNullOrEmpty(moderatorEmail))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var priority = GetPriorityLabel(planType);
|
||||||
|
var subject = $"[{priority}] Nova página para moderação - {pageTitle}";
|
||||||
|
|
||||||
|
var htmlContent = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||||
|
<h2 style='color: #333;'>Nova página para moderação</h2>
|
||||||
|
<div style='background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||||
|
<p><strong>Título:</strong> {pageTitle}</p>
|
||||||
|
<p><strong>Usuário:</strong> {userName}</p>
|
||||||
|
<p><strong>Plano:</strong> {planType}</p>
|
||||||
|
<p><strong>Prioridade:</strong> <span style='color: {GetPriorityColor(planType)};'>{priority}</span></p>
|
||||||
|
<p><strong>ID da Página:</strong> {pageId}</p>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<a href='{_configuration["BaseUrl"]}/moderation/review/{pageId}'
|
||||||
|
style='background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;'>
|
||||||
|
Moderar Página
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
await SendEmailAsync(moderatorEmail, subject, htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SendEmailAsync(string to, string subject, string htmlContent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var from = new EmailAddress(
|
||||||
|
_configuration["SendGrid:FromEmail"] ?? "ricardo.carneiro@jobmaker.com.br",
|
||||||
|
_configuration["SendGrid:FromName"] ?? "BCards");
|
||||||
|
|
||||||
|
var toEmail = new EmailAddress(to);
|
||||||
|
var msg = MailHelper.CreateSingleEmail(from, toEmail, subject, null, htmlContent);
|
||||||
|
|
||||||
|
var response = await _sendGridClient.SendEmailAsync(msg);
|
||||||
|
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.Accepted)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Email sent successfully to {Email}", to);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var content = await response.Body.ReadAsStringAsync();
|
||||||
|
_logger.LogWarning("Failed to send email to {Email}. Status: {StatusCode}", to, response.StatusCode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error sending email to {Email}", to);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string subject, string htmlContent) GetPendingModerationTemplate(string userName, string pageTitle, string? previewUrl)
|
||||||
|
{
|
||||||
|
var subject = "📋 Sua página está sendo analisada - bcards.site";
|
||||||
|
var previewButton = !string.IsNullOrEmpty(previewUrl)
|
||||||
|
? $"<p><a href='{previewUrl}' style='background: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>Ver Preview</a></p>"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
var htmlContent = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||||
|
<h2 style='color: #333;'>Olá {userName}!</h2>
|
||||||
|
<p>Sua página <strong>'{pageTitle}'</strong> foi enviada para análise e estará disponível em breve!</p>
|
||||||
|
|
||||||
|
<div style='background: #e3f2fd; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||||
|
<p>🔍 <strong>Tempo estimado:</strong> 3-7 dias úteis</p>
|
||||||
|
<p>👀 <strong>Status:</strong> Em análise</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Nossa equipe verifica se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.</p>
|
||||||
|
|
||||||
|
{previewButton}
|
||||||
|
|
||||||
|
<hr style='margin: 30px 0;'>
|
||||||
|
<p style='color: #666; font-size: 14px;'>
|
||||||
|
Você receberá outro email assim que sua página for aprovada ou se precisar de ajustes.
|
||||||
|
</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
return (subject, htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string subject, string htmlContent) GetApprovedTemplate(string userName, string pageTitle)
|
||||||
|
{
|
||||||
|
var subject = "✅ Sua página foi aprovada! - bcards.site";
|
||||||
|
var htmlContent = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||||
|
<h2 style='color: #28a745;'>Parabéns {userName}! 🎉</h2>
|
||||||
|
<p>Sua página <strong>'{pageTitle}'</strong> foi aprovada e já está no ar!</p>
|
||||||
|
|
||||||
|
<div style='background: #d4edda; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||||
|
<p>✅ <strong>Status:</strong> Aprovada</p>
|
||||||
|
<p>🌐 <strong>Sua página está online!</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Agora você pode:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Compartilhar sua página nas redes sociais</li>
|
||||||
|
<li>Adicionar o link na sua bio</li>
|
||||||
|
<li>Acompanhar as estatísticas no painel</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
|
||||||
|
style='background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
|
||||||
|
Acessar Painel
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
return (subject, htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string subject, string htmlContent) GetRejectedTemplate(string userName, string pageTitle, string? reason)
|
||||||
|
{
|
||||||
|
var subject = "⚠️ Sua página precisa de ajustes - bcards.site";
|
||||||
|
var reasonText = !string.IsNullOrEmpty(reason) ? $"<p><strong>Motivo:</strong> {reason}</p>" : "";
|
||||||
|
|
||||||
|
var htmlContent = $@"
|
||||||
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||||
|
<h2 style='color: #dc3545;'>Olá {userName}</h2>
|
||||||
|
<p>Sua página <strong>'{pageTitle}'</strong> não foi aprovada, mas você pode corrigir e reenviar!</p>
|
||||||
|
|
||||||
|
<div style='background: #f8d7da; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||||
|
<p>❌ <strong>Status:</strong> Necessita ajustes</p>
|
||||||
|
{reasonText}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Para que sua página seja aprovada, certifique-se de que:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Não contém conteúdo proibido ou suspeito</li>
|
||||||
|
<li>Todos os links estão funcionando</li>
|
||||||
|
<li>As informações são precisas</li>
|
||||||
|
<li>Segue nossos termos de uso</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
|
||||||
|
style='background: #dc3545; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
|
||||||
|
Editar Página
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>";
|
||||||
|
|
||||||
|
return (subject, htmlContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPriorityLabel(string planType) => planType.ToLower() switch
|
||||||
|
{
|
||||||
|
"premium" => "ALTA",
|
||||||
|
"professional" => "ALTA",
|
||||||
|
"basic" => "MÉDIA",
|
||||||
|
_ => "BAIXA"
|
||||||
|
};
|
||||||
|
|
||||||
|
private string GetPriorityColor(string planType) => planType.ToLower() switch
|
||||||
|
{
|
||||||
|
"premium" => "#dc3545",
|
||||||
|
"professional" => "#fd7e14",
|
||||||
|
"basic" => "#ffc107",
|
||||||
|
_ => "#6c757d"
|
||||||
|
};
|
||||||
|
}
|
||||||
8
src/BCards.Web/Services/IEmailService.cs
Normal file
8
src/BCards.Web/Services/IEmailService.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public interface IEmailService
|
||||||
|
{
|
||||||
|
Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null);
|
||||||
|
Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName);
|
||||||
|
Task<bool> SendEmailAsync(string to, string subject, string htmlContent);
|
||||||
|
}
|
||||||
13
src/BCards.Web/Services/ILivePageService.cs
Normal file
13
src/BCards.Web/Services/ILivePageService.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public interface ILivePageService
|
||||||
|
{
|
||||||
|
Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
|
||||||
|
Task<List<LivePage>> GetAllActiveAsync();
|
||||||
|
Task<LivePage> SyncFromUserPageAsync(string userPageId);
|
||||||
|
Task<bool> DeleteByOriginalPageIdAsync(string originalPageId);
|
||||||
|
Task IncrementViewAsync(string livePageId);
|
||||||
|
Task IncrementLinkClickAsync(string livePageId, int linkIndex);
|
||||||
|
}
|
||||||
11
src/BCards.Web/Services/IModerationAuthService.cs
Normal file
11
src/BCards.Web/Services/IModerationAuthService.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services
|
||||||
|
{
|
||||||
|
public interface IModerationAuthService
|
||||||
|
{
|
||||||
|
bool IsUserModerator(ClaimsPrincipal user);
|
||||||
|
bool IsEmailModerator(string email);
|
||||||
|
List<string> GetModeratorEmails();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/BCards.Web/Services/IModerationService.cs
Normal file
19
src/BCards.Web/Services/IModerationService.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public interface IModerationService
|
||||||
|
{
|
||||||
|
Task<string> GeneratePreviewTokenAsync(string pageId);
|
||||||
|
Task<bool> ValidatePreviewTokenAsync(string pageId, string token);
|
||||||
|
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20);
|
||||||
|
Task<UserPage?> GetPageForModerationAsync(string pageId);
|
||||||
|
Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null);
|
||||||
|
Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues);
|
||||||
|
Task<bool> CanUserCreatePageAsync(string userId);
|
||||||
|
Task<bool> IncrementPreviewViewAsync(string pageId);
|
||||||
|
Task<Dictionary<string, int>> GetModerationStatsAsync();
|
||||||
|
Task<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20);
|
||||||
|
Task<UserPage?> GetPageByPreviewTokenAsync(string token);
|
||||||
|
Task DeleteForModerationAsync(string pageId);
|
||||||
|
}
|
||||||
10
src/BCards.Web/Services/IOpenGraphService.cs
Normal file
10
src/BCards.Web/Services/IOpenGraphService.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public interface IOpenGraphService
|
||||||
|
{
|
||||||
|
Task<OpenGraphData> ExtractDataAsync(string url, string userId);
|
||||||
|
Task<bool> IsRateLimitedAsync(string userId);
|
||||||
|
Task<OpenGraphCache?> GetCachedDataAsync(string url);
|
||||||
|
}
|
||||||
@ -12,4 +12,9 @@ public interface IPaymentService
|
|||||||
Task<bool> CancelSubscriptionAsync(string subscriptionId);
|
Task<bool> CancelSubscriptionAsync(string subscriptionId);
|
||||||
Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId);
|
Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId);
|
||||||
Task<PlanLimitations> GetPlanLimitationsAsync(string planType);
|
Task<PlanLimitations> GetPlanLimitationsAsync(string planType);
|
||||||
|
|
||||||
|
// Novos métodos para gerenciamento de assinatura
|
||||||
|
Task<Stripe.Subscription?> GetSubscriptionDetailsAsync(string userId);
|
||||||
|
Task<List<Invoice>> GetPaymentHistoryAsync(string userId);
|
||||||
|
Task<string> CreatePortalSessionAsync(string customerId, string returnUrl);
|
||||||
}
|
}
|
||||||
@ -8,6 +8,7 @@ public interface IThemeService
|
|||||||
Task<PageTheme?> GetThemeByIdAsync(string themeId);
|
Task<PageTheme?> GetThemeByIdAsync(string themeId);
|
||||||
Task<PageTheme?> GetThemeByNameAsync(string themeName);
|
Task<PageTheme?> GetThemeByNameAsync(string themeName);
|
||||||
Task<string> GenerateCustomCssAsync(PageTheme theme);
|
Task<string> GenerateCustomCssAsync(PageTheme theme);
|
||||||
|
Task<string> GenerateThemeCSSAsync(PageTheme theme, UserPage page);
|
||||||
Task InitializeDefaultThemesAsync();
|
Task InitializeDefaultThemesAsync();
|
||||||
PageTheme GetDefaultTheme();
|
PageTheme GetDefaultTheme();
|
||||||
}
|
}
|
||||||
113
src/BCards.Web/Services/LivePageService.cs
Normal file
113
src/BCards.Web/Services/LivePageService.cs
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public class LivePageService : ILivePageService
|
||||||
|
{
|
||||||
|
private readonly ILivePageRepository _livePageRepository;
|
||||||
|
private readonly IUserPageRepository _userPageRepository;
|
||||||
|
private readonly ILogger<LivePageService> _logger;
|
||||||
|
|
||||||
|
public LivePageService(
|
||||||
|
ILivePageRepository livePageRepository,
|
||||||
|
IUserPageRepository userPageRepository,
|
||||||
|
ILogger<LivePageService> logger)
|
||||||
|
{
|
||||||
|
_livePageRepository = livePageRepository;
|
||||||
|
_userPageRepository = userPageRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
|
||||||
|
{
|
||||||
|
return await _livePageRepository.GetByCategoryAndSlugAsync(category, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<LivePage>> GetAllActiveAsync()
|
||||||
|
{
|
||||||
|
return await _livePageRepository.GetAllActiveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LivePage> SyncFromUserPageAsync(string userPageId)
|
||||||
|
{
|
||||||
|
var userPage = await _userPageRepository.GetByIdAsync(userPageId);
|
||||||
|
if (userPage == null)
|
||||||
|
throw new InvalidOperationException($"UserPage {userPageId} not found");
|
||||||
|
|
||||||
|
if (userPage.Status != PageStatus.Active)
|
||||||
|
throw new InvalidOperationException("UserPage must be Active to sync to LivePage");
|
||||||
|
|
||||||
|
// Verificar se já existe LivePage para este UserPage
|
||||||
|
var existingLivePage = await _livePageRepository.GetByOriginalPageIdAsync(userPageId);
|
||||||
|
|
||||||
|
var livePage = new LivePage
|
||||||
|
{
|
||||||
|
OriginalPageId = userPageId,
|
||||||
|
UserId = userPage.UserId,
|
||||||
|
Category = userPage.Category,
|
||||||
|
Slug = userPage.Slug,
|
||||||
|
DisplayName = userPage.DisplayName,
|
||||||
|
Bio = userPage.Bio,
|
||||||
|
ProfileImage = userPage.ProfileImage,
|
||||||
|
BusinessType = userPage.BusinessType,
|
||||||
|
Theme = userPage.Theme,
|
||||||
|
Links = userPage.Links,
|
||||||
|
SeoSettings = userPage.SeoSettings,
|
||||||
|
Language = userPage.Language,
|
||||||
|
Analytics = new LivePageAnalytics
|
||||||
|
{
|
||||||
|
TotalViews = existingLivePage?.Analytics?.TotalViews ?? 0,
|
||||||
|
TotalClicks = existingLivePage?.Analytics?.TotalClicks ?? 0,
|
||||||
|
LastViewedAt = existingLivePage?.Analytics?.LastViewedAt
|
||||||
|
},
|
||||||
|
PublishedAt = userPage.ApprovedAt ?? DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingLivePage != null)
|
||||||
|
{
|
||||||
|
// Atualizar existente
|
||||||
|
livePage.Id = existingLivePage.Id;
|
||||||
|
livePage.CreatedAt = existingLivePage.CreatedAt;
|
||||||
|
_logger.LogInformation("Updating existing LivePage {LivePageId} from UserPage {UserPageId}", livePage.Id, userPageId);
|
||||||
|
return await _livePageRepository.UpdateAsync(livePage);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Criar nova
|
||||||
|
_logger.LogInformation("Creating new LivePage from UserPage {UserPageId}", userPageId);
|
||||||
|
return await _livePageRepository.CreateAsync(livePage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteByOriginalPageIdAsync(string originalPageId)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Deleting LivePage for UserPage {UserPageId}", originalPageId);
|
||||||
|
return await _livePageRepository.DeleteByOriginalPageIdAsync(originalPageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task IncrementViewAsync(string livePageId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _livePageRepository.IncrementViewAsync(livePageId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task IncrementLinkClickAsync(string livePageId, int linkIndex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _livePageRepository.IncrementLinkClickAsync(livePageId, linkIndex);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to increment click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/BCards.Web/Services/ModerationAuthService.cs
Normal file
39
src/BCards.Web/Services/ModerationAuthService.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
using BCards.Web.Configuration;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services
|
||||||
|
{
|
||||||
|
public class ModerationAuthService : IModerationAuthService
|
||||||
|
{
|
||||||
|
private readonly ModerationSettings _settings;
|
||||||
|
|
||||||
|
public ModerationAuthService(IOptions<ModerationSettings> settings)
|
||||||
|
{
|
||||||
|
_settings = settings.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsUserModerator(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
if (!user.Identity?.IsAuthenticated == true)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var email = user.FindFirst(ClaimTypes.Email)?.Value;
|
||||||
|
return IsEmailModerator(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEmailModerator(string? email)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return _settings.ModeratorEmails
|
||||||
|
.Any(moderatorEmail => moderatorEmail.Equals(email, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> GetModeratorEmails()
|
||||||
|
{
|
||||||
|
return _settings.ModeratorEmails.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/BCards.Web/Services/ModerationService.cs
Normal file
245
src/BCards.Web/Services/ModerationService.cs
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public class ModerationService : IModerationService
|
||||||
|
{
|
||||||
|
private readonly IUserPageRepository _userPageRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ILivePageService _livePageService;
|
||||||
|
private readonly ILogger<ModerationService> _logger;
|
||||||
|
|
||||||
|
public ModerationService(
|
||||||
|
IUserPageRepository userPageRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ILivePageService livePageService,
|
||||||
|
ILogger<ModerationService> logger)
|
||||||
|
{
|
||||||
|
_userPageRepository = userPageRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_livePageService = livePageService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GeneratePreviewTokenAsync(string pageId)
|
||||||
|
{
|
||||||
|
var token = Guid.NewGuid().ToString("N")[..16];
|
||||||
|
var expiry = DateTime.UtcNow.AddDays(30); // Token válido por 30 dias
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
page.PreviewToken = token;
|
||||||
|
page.PreviewTokenExpiry = expiry;
|
||||||
|
page.PreviewViewCount = 0;
|
||||||
|
|
||||||
|
await _userPageRepository.UpdateAsync(page);
|
||||||
|
|
||||||
|
_logger.LogInformation("Generated preview token for page {PageId}", pageId);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidatePreviewTokenAsync(string pageId, string token)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
if (page == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var isValid = page.PreviewToken == token &&
|
||||||
|
page.PreviewTokenExpiry > DateTime.UtcNow &&
|
||||||
|
page.PreviewViewCount < 50;
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.PendingModeration);
|
||||||
|
|
||||||
|
// Ordenar por prioridade do plano e depois por data
|
||||||
|
var sort = Builders<UserPage>.Sort
|
||||||
|
.Ascending("planLimitations.planType")
|
||||||
|
.Ascending(p => p.CreatedAt);
|
||||||
|
|
||||||
|
var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take);
|
||||||
|
return pages.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserPage?> GetPageForModerationAsync(string pageId)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
if (page?.Status != PageStatus.PendingModeration)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteForModerationAsync(string pageId)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
await _userPageRepository.DeleteAsync(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
if (page == null)
|
||||||
|
throw new ArgumentException("Page not found");
|
||||||
|
|
||||||
|
var moderationEntry = new ModerationHistory
|
||||||
|
{
|
||||||
|
Attempt = page.ModerationAttempts + 1,
|
||||||
|
Status = "approved",
|
||||||
|
ModeratorId = moderatorId,
|
||||||
|
Date = DateTime.UtcNow,
|
||||||
|
Reason = notes
|
||||||
|
};
|
||||||
|
|
||||||
|
page.ModerationHistory.Add(moderationEntry);
|
||||||
|
|
||||||
|
var update = Builders<UserPage>.Update
|
||||||
|
.Set(p => p.Status, PageStatus.Active)
|
||||||
|
.Set(p => p.ApprovedAt, DateTime.UtcNow)
|
||||||
|
.Set(p => p.ModerationAttempts, page.ModerationAttempts + 1)
|
||||||
|
.Set(p => p.ModerationHistory, page.ModerationHistory)
|
||||||
|
.Set(p => p.PublishedAt, DateTime.UtcNow)
|
||||||
|
.Unset(p => p.PreviewToken)
|
||||||
|
.Unset(p => p.PreviewTokenExpiry);
|
||||||
|
|
||||||
|
await _userPageRepository.UpdateAsync(pageId, update);
|
||||||
|
_logger.LogInformation("Page {PageId} approved by moderator {ModeratorId}", pageId, moderatorId);
|
||||||
|
|
||||||
|
// 🔥 NOVA FUNCIONALIDADE: Sincronizar para LivePage
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _livePageService.SyncFromUserPageAsync(pageId);
|
||||||
|
_logger.LogInformation("Page {PageId} synced to LivePages successfully", pageId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to sync page {PageId} to LivePages. Approval completed but sync failed.", pageId);
|
||||||
|
// Não falhar a aprovação se sync falhar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
if (page == null)
|
||||||
|
throw new ArgumentException("Page not found");
|
||||||
|
|
||||||
|
var moderationEntry = new ModerationHistory
|
||||||
|
{
|
||||||
|
Attempt = page.ModerationAttempts + 1,
|
||||||
|
Status = "rejected",
|
||||||
|
ModeratorId = moderatorId,
|
||||||
|
Date = DateTime.UtcNow,
|
||||||
|
Reason = reason,
|
||||||
|
Issues = issues
|
||||||
|
};
|
||||||
|
|
||||||
|
page.ModerationHistory.Add(moderationEntry);
|
||||||
|
|
||||||
|
var newStatus = page.ModerationAttempts >= 2 ? PageStatus.Rejected : PageStatus.Inactive;
|
||||||
|
var userScoreDeduction = Math.Min(20, page.UserScore / 5);
|
||||||
|
|
||||||
|
var update = Builders<UserPage>.Update
|
||||||
|
.Set(p => p.Status, newStatus)
|
||||||
|
.Set(p => p.ModerationAttempts, page.ModerationAttempts + 1)
|
||||||
|
.Set(p => p.ModerationHistory, page.ModerationHistory)
|
||||||
|
.Set(p => p.UserScore, Math.Max(0, page.UserScore - userScoreDeduction))
|
||||||
|
.Unset(p => p.PreviewToken)
|
||||||
|
.Unset(p => p.PreviewTokenExpiry);
|
||||||
|
|
||||||
|
await _userPageRepository.UpdateAsync(pageId, update);
|
||||||
|
_logger.LogInformation("Page {PageId} rejected by moderator {ModeratorId}. Reason: {Reason}",
|
||||||
|
pageId, moderatorId, reason);
|
||||||
|
|
||||||
|
// Remover da LivePages se existir
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _livePageService.DeleteByOriginalPageIdAsync(pageId);
|
||||||
|
_logger.LogInformation("LivePage removed for rejected UserPage {PageId}", pageId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to remove LivePage for UserPage {PageId}", pageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanUserCreatePageAsync(string userId)
|
||||||
|
{
|
||||||
|
var user = await _userRepository.GetByIdAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
//var rejectedPages = userPages.Count(p => p.Status == PageStatus.Rejected);
|
||||||
|
|
||||||
|
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
|
||||||
|
filter &= Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected);
|
||||||
|
|
||||||
|
var rejectedPages = await _userPageRepository.CountAsync(filter);
|
||||||
|
|
||||||
|
// Usuários com mais de 2 páginas rejeitadas não podem criar novas
|
||||||
|
return rejectedPages < 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IncrementPreviewViewAsync(string pageId)
|
||||||
|
{
|
||||||
|
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||||
|
if (page == null || page.PreviewViewCount >= 50)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var update = Builders<UserPage>.Update
|
||||||
|
.Inc(p => p.PreviewViewCount, 1);
|
||||||
|
|
||||||
|
await _userPageRepository.UpdateAsync(pageId, update);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<string, int>> GetModerationStatsAsync()
|
||||||
|
{
|
||||||
|
var stats = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
var pendingCount = await _userPageRepository.CountAsync(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.PendingModeration));
|
||||||
|
|
||||||
|
var approvedToday = await _userPageRepository.CountAsync(
|
||||||
|
Builders<UserPage>.Filter.And(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
|
||||||
|
Builders<UserPage>.Filter.Gte(p => p.ApprovedAt, DateTime.UtcNow.Date)));
|
||||||
|
|
||||||
|
var rejectedToday = await _userPageRepository.CountAsync(
|
||||||
|
Builders<UserPage>.Filter.And(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected),
|
||||||
|
Builders<UserPage>.Filter.Gte(p => p.UpdatedAt, DateTime.UtcNow.Date)));
|
||||||
|
|
||||||
|
stats["pending"] = (int)pendingCount;
|
||||||
|
stats["approvedToday"] = (int)approvedToday;
|
||||||
|
stats["rejectedToday"] = (int)rejectedToday;
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserPage>.Filter.Or(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected));
|
||||||
|
|
||||||
|
var sort = Builders<UserPage>.Sort.Descending(p => p.UpdatedAt);
|
||||||
|
|
||||||
|
var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take);
|
||||||
|
return pages.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserPage?> GetPageByPreviewTokenAsync(string token)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserPage>.Filter.And(
|
||||||
|
Builders<UserPage>.Filter.Eq(p => p.PreviewToken, token),
|
||||||
|
Builders<UserPage>.Filter.Gt(p => p.PreviewTokenExpiry, DateTime.UtcNow));
|
||||||
|
|
||||||
|
var pages = await _userPageRepository.GetManyAsync(filter);
|
||||||
|
return pages.FirstOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
299
src/BCards.Web/Services/OpenGraphService.cs
Normal file
299
src/BCards.Web/Services/OpenGraphService.cs
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Utils;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
|
public class OpenGraphService : IOpenGraphService
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly ILogger<OpenGraphService> _logger;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IMongoCollection<OpenGraphCache> _ogCache;
|
||||||
|
|
||||||
|
public OpenGraphService(
|
||||||
|
IMemoryCache cache,
|
||||||
|
ILogger<OpenGraphService> logger,
|
||||||
|
HttpClient httpClient,
|
||||||
|
IMongoDatabase database)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_ogCache = database.GetCollection<OpenGraphCache>("openGraphCache");
|
||||||
|
|
||||||
|
// Configure HttpClient
|
||||||
|
_httpClient.DefaultRequestHeaders.Clear();
|
||||||
|
//_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||||
|
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36");
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||||
|
"facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)");
|
||||||
|
_httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenGraphData> ExtractDataAsync(string url, string userId)
|
||||||
|
{
|
||||||
|
// 1. Validar domínio
|
||||||
|
if (!AllowedDomains.IsAllowed(url))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Tentativa de extração de domínio não permitido: {Url} pelo usuário {UserId}", url, userId);
|
||||||
|
throw new InvalidOperationException("Domínio não permitido. Use apenas e-commerces conhecidos e seguros.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verificar rate limit (1 request por minuto por usuário)
|
||||||
|
var rateLimitKey = $"og_rate_{userId}";
|
||||||
|
if (_cache.TryGetValue(rateLimitKey, out _))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Rate limit excedido para usuário {UserId}", userId);
|
||||||
|
throw new InvalidOperationException("Aguarde 1 minuto antes de extrair dados de outro produto.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verificar cache no MongoDB
|
||||||
|
var urlHash = GenerateUrlHash(url);
|
||||||
|
var cachedData = await GetCachedDataAsync(url);
|
||||||
|
|
||||||
|
if (cachedData != null && cachedData.ExpiresAt > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Retornando dados do cache MongoDB para URL: {Url}", url);
|
||||||
|
return new OpenGraphData
|
||||||
|
{
|
||||||
|
Title = cachedData.Title,
|
||||||
|
Description = cachedData.Description,
|
||||||
|
Image = cachedData.Image,
|
||||||
|
Price = cachedData.Price,
|
||||||
|
Currency = cachedData.Currency,
|
||||||
|
IsValid = cachedData.IsValid,
|
||||||
|
ErrorMessage = cachedData.ErrorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Extrair dados da URL
|
||||||
|
var extractedData = await ExtractFromUrlAsync(url);
|
||||||
|
|
||||||
|
// 5. Salvar no cache MongoDB
|
||||||
|
await SaveToCacheAsync(url, urlHash, extractedData);
|
||||||
|
|
||||||
|
// 6. Aplicar rate limit (1 minuto)
|
||||||
|
_cache.Set(rateLimitKey, true, TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
_logger.LogInformation("Dados extraídos com sucesso para URL: {Url}", url);
|
||||||
|
return extractedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> IsRateLimitedAsync(string userId)
|
||||||
|
{
|
||||||
|
var rateLimitKey = $"og_rate_{userId}";
|
||||||
|
return Task.FromResult(_cache.TryGetValue(rateLimitKey, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenGraphCache?> GetCachedDataAsync(string url)
|
||||||
|
{
|
||||||
|
var urlHash = GenerateUrlHash(url);
|
||||||
|
return await _ogCache
|
||||||
|
.Find(x => x.UrlHash == urlHash && x.ExpiresAt > DateTime.UtcNow)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OpenGraphData> ExtractFromUrlAsync(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Iniciando extração de dados para URL: {Url}", url);
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var html = await response.Content.ReadAsStringAsync();
|
||||||
|
var doc = new HtmlDocument();
|
||||||
|
doc.LoadHtml(html);
|
||||||
|
|
||||||
|
var title = GetMetaContent(doc, "og:title", "title") ?? GetTitleFromHTML(doc);
|
||||||
|
var description = GetMetaContent(doc, "og:description", "description");
|
||||||
|
var image = GetMetaContent(doc, "og:image");
|
||||||
|
var price = GetMetaContent(doc, "og:price:amount") ?? ExtractPriceFromHTML(html, doc);
|
||||||
|
var currency = GetMetaContent(doc, "og:price:currency") ?? "BRL";
|
||||||
|
|
||||||
|
// Limpar e validar dados
|
||||||
|
title = CleanText(title);
|
||||||
|
description = CleanText(description);
|
||||||
|
price = CleanPrice(price);
|
||||||
|
image = ValidateImageUrl(image, url);
|
||||||
|
|
||||||
|
var isValid = !string.IsNullOrEmpty(title);
|
||||||
|
|
||||||
|
return new OpenGraphData
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Description = description,
|
||||||
|
Image = image,
|
||||||
|
Price = price,
|
||||||
|
Currency = currency,
|
||||||
|
IsValid = isValid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Falha ao extrair dados de {Url}", url);
|
||||||
|
return new OpenGraphData
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
ErrorMessage = $"Erro ao processar a página: {ex.Message}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GetMetaContent(HtmlDocument doc, params string[] properties)
|
||||||
|
{
|
||||||
|
foreach (var property in properties)
|
||||||
|
{
|
||||||
|
var meta = doc.DocumentNode
|
||||||
|
.SelectSingleNode($"//meta[@property='{property}' or @name='{property}' or @itemprop='{property}']");
|
||||||
|
|
||||||
|
var content = meta?.GetAttributeValue("content", null);
|
||||||
|
if (!string.IsNullOrWhiteSpace(content))
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GetTitleFromHTML(HtmlDocument doc)
|
||||||
|
{
|
||||||
|
var titleNode = doc.DocumentNode.SelectSingleNode("//title");
|
||||||
|
return titleNode?.InnerText?.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ExtractPriceFromHTML(string html, HtmlDocument doc)
|
||||||
|
{
|
||||||
|
// Regex patterns para encontrar preços em diferentes formatos
|
||||||
|
var pricePatterns = new[]
|
||||||
|
{
|
||||||
|
@"R\$\s*[\d\.,]+",
|
||||||
|
@"BRL\s*[\d\.,]+",
|
||||||
|
@"[\$]\s*[\d\.,]+",
|
||||||
|
@"price[^>]*>([^<]*[\d\.,]+[^<]*)<",
|
||||||
|
@"valor[^>]*>([^<]*[\d\.,]+[^<]*)<",
|
||||||
|
@"preço[^>]*>([^<]*[\d\.,]+[^<]*)<"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var pattern in pricePatterns)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
return match.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar encontrar por seletores específicos
|
||||||
|
var priceSelectors = new[]
|
||||||
|
{
|
||||||
|
".price", ".valor", ".preco", "[data-price]", ".price-current",
|
||||||
|
".price-value", ".product-price", ".sale-price"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var selector in priceSelectors)
|
||||||
|
{
|
||||||
|
var priceNode = doc.DocumentNode.SelectSingleNode($"//*[contains(@class, '{selector.Replace(".", "")}')]");
|
||||||
|
if (priceNode != null)
|
||||||
|
{
|
||||||
|
var priceText = priceNode.InnerText?.Trim();
|
||||||
|
if (Regex.IsMatch(priceText ?? "", @"[\d\.,]+"))
|
||||||
|
{
|
||||||
|
return priceText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CleanText(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return Regex.Replace(text.Trim(), @"\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CleanPrice(string? price)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(price))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// Limpar e formatar preço
|
||||||
|
var cleanPrice = Regex.Replace(price, @"[^\d\.,R\$]", " ").Trim();
|
||||||
|
return Regex.Replace(cleanPrice, @"\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ValidateImageUrl(string? imageUrl, string baseUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(imageUrl))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Se for URL relativa, converter para absoluta
|
||||||
|
if (imageUrl.StartsWith("/"))
|
||||||
|
{
|
||||||
|
var baseUri = new Uri(baseUrl);
|
||||||
|
return $"{baseUri.Scheme}://{baseUri.Host}{imageUrl}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar se é uma URL válida
|
||||||
|
if (Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return uri.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Erro ao validar URL da imagem: {ImageUrl}", imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateUrlHash(string url)
|
||||||
|
{
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(url.ToLowerInvariant()));
|
||||||
|
return Convert.ToBase64String(hashBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveToCacheAsync(string url, string urlHash, OpenGraphData data)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cacheItem = new OpenGraphCache
|
||||||
|
{
|
||||||
|
Url = url,
|
||||||
|
UrlHash = urlHash,
|
||||||
|
Title = data.Title,
|
||||||
|
Description = data.Description,
|
||||||
|
Image = data.Image,
|
||||||
|
Price = data.Price,
|
||||||
|
Currency = data.Currency,
|
||||||
|
IsValid = data.IsValid,
|
||||||
|
ErrorMessage = data.ErrorMessage,
|
||||||
|
CachedAt = DateTime.UtcNow,
|
||||||
|
ExpiresAt = data.IsValid ? DateTime.UtcNow.AddHours(24) : DateTime.UtcNow.AddHours(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upsert no MongoDB
|
||||||
|
await _ogCache.ReplaceOneAsync(
|
||||||
|
x => x.UrlHash == urlHash,
|
||||||
|
cacheItem,
|
||||||
|
new ReplaceOptions { IsUpsert = true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao salvar cache para URL: {Url}", url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ using BCards.Web.Repositories;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Stripe.Checkout;
|
using Stripe.Checkout;
|
||||||
|
using Stripe.BillingPortal;
|
||||||
|
|
||||||
namespace BCards.Web.Services;
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
@ -41,7 +42,7 @@ public class PaymentService : IPaymentService
|
|||||||
|
|
||||||
var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name);
|
var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name);
|
||||||
|
|
||||||
var options = new SessionCreateOptions
|
var options = new Stripe.Checkout.SessionCreateOptions
|
||||||
{
|
{
|
||||||
PaymentMethodTypes = new List<string> { "card" },
|
PaymentMethodTypes = new List<string> { "card" },
|
||||||
Mode = "subscription",
|
Mode = "subscription",
|
||||||
@ -63,7 +64,7 @@ public class PaymentService : IPaymentService
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var service = new SessionService();
|
var service = new Stripe.Checkout.SessionService();
|
||||||
var session = await service.CreateAsync(options);
|
var session = await service.CreateAsync(options);
|
||||||
|
|
||||||
return session.Url;
|
return session.Url;
|
||||||
@ -119,7 +120,7 @@ public class PaymentService : IPaymentService
|
|||||||
switch (stripeEvent.Type)
|
switch (stripeEvent.Type)
|
||||||
{
|
{
|
||||||
case Events.CheckoutSessionCompleted:
|
case Events.CheckoutSessionCompleted:
|
||||||
var session = stripeEvent.Data.Object as Session;
|
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
|
||||||
await HandleCheckoutSessionCompletedAsync(session!);
|
await HandleCheckoutSessionCompletedAsync(session!);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -246,7 +247,7 @@ public class PaymentService : IPaymentService
|
|||||||
return Task.FromResult(limitations);
|
return Task.FromResult(limitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleCheckoutSessionCompletedAsync(Session session)
|
private async Task HandleCheckoutSessionCompletedAsync(Stripe.Checkout.Session session)
|
||||||
{
|
{
|
||||||
var userId = session.Metadata["user_id"];
|
var userId = session.Metadata["user_id"];
|
||||||
var planType = session.Metadata["plan_type"];
|
var planType = session.Metadata["plan_type"];
|
||||||
@ -319,4 +320,70 @@ public class PaymentService : IPaymentService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Stripe.Subscription?> GetSubscriptionDetailsAsync(string userId)
|
||||||
|
{
|
||||||
|
var user = await _userRepository.GetByIdAsync(userId);
|
||||||
|
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var subscription = await _subscriptionRepository.GetByUserIdAsync(userId);
|
||||||
|
if (subscription == null || string.IsNullOrEmpty(subscription.StripeSubscriptionId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var service = new SubscriptionService();
|
||||||
|
return await service.GetAsync(subscription.StripeSubscriptionId);
|
||||||
|
}
|
||||||
|
catch (StripeException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Invoice>> GetPaymentHistoryAsync(string userId)
|
||||||
|
{
|
||||||
|
var user = await _userRepository.GetByIdAsync(userId);
|
||||||
|
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
|
||||||
|
return new List<Invoice>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var service = new InvoiceService();
|
||||||
|
var options = new InvoiceListOptions
|
||||||
|
{
|
||||||
|
Customer = user.StripeCustomerId,
|
||||||
|
Limit = 50, // Últimas 50 faturas
|
||||||
|
Status = "paid"
|
||||||
|
};
|
||||||
|
|
||||||
|
var invoices = await service.ListAsync(options);
|
||||||
|
return invoices.Data;
|
||||||
|
}
|
||||||
|
catch (StripeException)
|
||||||
|
{
|
||||||
|
return new List<Invoice>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CreatePortalSessionAsync(string customerId, string returnUrl)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new Stripe.BillingPortal.SessionCreateOptions
|
||||||
|
{
|
||||||
|
Customer = customerId,
|
||||||
|
ReturnUrl = returnUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
var service = new Stripe.BillingPortal.SessionService();
|
||||||
|
var session = await service.CreateAsync(options);
|
||||||
|
return session.Url;
|
||||||
|
}
|
||||||
|
catch (StripeException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Erro ao criar sessão do portal: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace BCards.Web.Services;
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
@ -133,6 +134,128 @@ public class ThemeService : IThemeService
|
|||||||
return Task.FromResult(css);
|
return Task.FromResult(css);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> GenerateThemeCSSAsync(PageTheme theme, UserPage page)
|
||||||
|
{
|
||||||
|
var css = new StringBuilder();
|
||||||
|
|
||||||
|
// CSS base com variáveis do tema
|
||||||
|
css.AppendLine($":root {{");
|
||||||
|
css.AppendLine($" --primary-color: {theme.PrimaryColor};");
|
||||||
|
css.AppendLine($" --secondary-color: {theme.SecondaryColor};");
|
||||||
|
css.AppendLine($" --background-color: {theme.BackgroundColor};");
|
||||||
|
css.AppendLine($" --text-color: {theme.TextColor};");
|
||||||
|
css.AppendLine($"}}");
|
||||||
|
|
||||||
|
// CSS específico por tema
|
||||||
|
switch (theme.Name?.ToLower())
|
||||||
|
{
|
||||||
|
case "minimalista":
|
||||||
|
css.AppendLine(GetMinimalistCSS());
|
||||||
|
break;
|
||||||
|
case "corporativo":
|
||||||
|
css.AppendLine(GetCorporateCSS());
|
||||||
|
break;
|
||||||
|
case "dark mode":
|
||||||
|
css.AppendLine(GetDarkCSS());
|
||||||
|
break;
|
||||||
|
case "natureza":
|
||||||
|
css.AppendLine(GetNatureCSS());
|
||||||
|
break;
|
||||||
|
case "vibrante":
|
||||||
|
css.AppendLine(GetVibrantCSS());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
css.AppendLine(await GenerateCustomCssAsync(theme));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return css.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetMinimalistCSS() => @"
|
||||||
|
.profile-card {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private string GetCorporateCSS() => @"
|
||||||
|
.user-page {
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
}
|
||||||
|
.profile-card {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private string GetDarkCSS() => @"
|
||||||
|
.user-page {
|
||||||
|
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||||
|
}
|
||||||
|
.profile-card {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: var(--primary-color);
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.profile-name, .profile-bio {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private string GetNatureCSS() => @"
|
||||||
|
.user-page {
|
||||||
|
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||||
|
background-image: url('data:image/svg+xml,<svg xmlns=""http://www.w3.org/2000/svg"" viewBox=""0 0 100 100""><defs><pattern id=""grain"" width=""100"" height=""100"" patternUnits=""userSpaceOnUse""><circle cx=""25"" cy=""25"" r=""1"" fill=""%23059669"" opacity=""0.1""/><circle cx=""75"" cy=""75"" r=""1"" fill=""%23059669"" opacity=""0.1""/></pattern></defs><rect width=""100"" height=""100"" fill=""url(%23grain)""/></svg>');
|
||||||
|
}
|
||||||
|
.profile-card {
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||||
|
border-radius: 25px;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
private string GetVibrantCSS() => @"
|
||||||
|
.user-page {
|
||||||
|
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 50%, #fecaca 100%);
|
||||||
|
}
|
||||||
|
.profile-card {
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.2);
|
||||||
|
border: 2px solid rgba(220, 38, 38, 0.1);
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||||
|
border-radius: 30px;
|
||||||
|
transform: perspective(1000px) rotateX(0deg);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.link-button:hover {
|
||||||
|
transform: perspective(1000px) rotateX(-5deg) translateY(-5px);
|
||||||
|
box-shadow: 0 15px 30px rgba(220, 38, 38, 0.3);
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
public async Task InitializeDefaultThemesAsync()
|
public async Task InitializeDefaultThemesAsync()
|
||||||
{
|
{
|
||||||
var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync();
|
var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync();
|
||||||
|
|||||||
@ -3,6 +3,7 @@ using BCards.Web.Repositories;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using BCards.Web.Utils;
|
||||||
|
|
||||||
namespace BCards.Web.Services;
|
namespace BCards.Web.Services;
|
||||||
|
|
||||||
@ -76,7 +77,7 @@ public class UserPageService : IUserPageService
|
|||||||
|
|
||||||
public async Task<string> GenerateSlugAsync(string category, string name)
|
public async Task<string> GenerateSlugAsync(string category, string name)
|
||||||
{
|
{
|
||||||
var slug = GenerateSlug(name);
|
var slug = SlugHelper.CreateSlug(GenerateSlug(name));
|
||||||
var originalSlug = slug;
|
var originalSlug = slug;
|
||||||
var counter = 1;
|
var counter = 1;
|
||||||
|
|
||||||
|
|||||||
1
src/BCards.Web/StartStripeCLI.bat
Normal file
1
src/BCards.Web/StartStripeCLI.bat
Normal file
@ -0,0 +1 @@
|
|||||||
|
stripe listen --forward-to localhost:49178/webhook/stripe
|
||||||
69
src/BCards.Web/Utils/AllowedDomains.cs
Normal file
69
src/BCards.Web/Utils/AllowedDomains.cs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
namespace BCards.Web.Utils;
|
||||||
|
|
||||||
|
public static class AllowedDomains
|
||||||
|
{
|
||||||
|
public static readonly HashSet<string> EcommerceWhitelist = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// Principais E-commerces Brasileiros
|
||||||
|
"mercadolivre.com.br", "mercadolibre.com",
|
||||||
|
"amazon.com.br", "amazon.com",
|
||||||
|
"magazineluiza.com.br", "magalu.com.br",
|
||||||
|
"americanas.com", "submarino.com.br",
|
||||||
|
"extra.com.br", "pontofrio.com.br",
|
||||||
|
"casasbahia.com.br", "casas.com.br",
|
||||||
|
"shopee.com.br", "shopee.com", "s.shopee.com.br",
|
||||||
|
"aliexpress.com", "aliexpress.us",
|
||||||
|
"netshoes.com.br", "centauro.com.br",
|
||||||
|
"dafiti.com.br", "kanui.com.br",
|
||||||
|
"fastshop.com.br", "kabum.com.br",
|
||||||
|
"pichau.com.br", "terabyteshop.com.br",
|
||||||
|
|
||||||
|
// Marketplaces Internacionais Seguros
|
||||||
|
"ebay.com", "etsy.com", "walmart.com",
|
||||||
|
"target.com", "bestbuy.com",
|
||||||
|
|
||||||
|
// E-commerces de Moda
|
||||||
|
"zara.com", "hm.com", "gap.com",
|
||||||
|
"uniqlo.com", "forever21.com",
|
||||||
|
|
||||||
|
// Livrarias e Educação
|
||||||
|
"saraiva.com.br", "livrariacultura.com.br",
|
||||||
|
"estantevirtual.com.br",
|
||||||
|
|
||||||
|
// Casa e Decoração
|
||||||
|
"mobly.com.br", "tok-stok.com.br",
|
||||||
|
"westwing.com.br", "madeiramadeira.com.br"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsAllowed(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
var domain = uri.Host.ToLowerInvariant();
|
||||||
|
|
||||||
|
// Remove "www." se existir
|
||||||
|
if (domain.StartsWith("www."))
|
||||||
|
domain = domain.Substring(4);
|
||||||
|
|
||||||
|
return EcommerceWhitelist.Contains(domain);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetDomainFromUrl(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
return uri.Host.ToLowerInvariant().Replace("www.", "");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/BCards.Web/Utils/ConditionalRequiredAttribute.cs
Normal file
72
src/BCards.Web/Utils/ConditionalRequiredAttribute.cs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.ViewModels;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
// Atributo de validação customizado para links
|
||||||
|
public class ConditionalRequiredAttribute : ValidationAttribute
|
||||||
|
{
|
||||||
|
private readonly string _dependentProperty;
|
||||||
|
private readonly object _targetValue;
|
||||||
|
|
||||||
|
public ConditionalRequiredAttribute(string dependentProperty, object targetValue)
|
||||||
|
{
|
||||||
|
_dependentProperty = dependentProperty;
|
||||||
|
_targetValue = targetValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
var dependentProperty = validationContext.ObjectType.GetProperty(_dependentProperty);
|
||||||
|
if (dependentProperty == null)
|
||||||
|
return ValidationResult.Success;
|
||||||
|
|
||||||
|
var dependentValue = dependentProperty.GetValue(validationContext.ObjectInstance);
|
||||||
|
|
||||||
|
// Se o valor dependente não é o target, não valida
|
||||||
|
if (!Equals(dependentValue, _targetValue))
|
||||||
|
return ValidationResult.Success;
|
||||||
|
|
||||||
|
// Se é o target value e o campo está vazio, retorna erro
|
||||||
|
if (value == null || string.IsNullOrWhiteSpace(value.ToString()))
|
||||||
|
return new ValidationResult(ErrorMessage ?? $"{validationContext.DisplayName} é obrigatório.");
|
||||||
|
|
||||||
|
return ValidationResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Método de extensão para validação personalizada no Controller
|
||||||
|
public static class ModelStateExtensions
|
||||||
|
{
|
||||||
|
public static void ValidateLinks(this ModelStateDictionary modelState, List<ManageLinkViewModel> links)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < links.Count; i++)
|
||||||
|
{
|
||||||
|
var link = links[i];
|
||||||
|
|
||||||
|
// Validação condicional baseada no tipo
|
||||||
|
if (link.Type == LinkType.Product)
|
||||||
|
{
|
||||||
|
// Para links de produto, ProductTitle é obrigatório
|
||||||
|
if (string.IsNullOrWhiteSpace(link.ProductTitle))
|
||||||
|
{
|
||||||
|
modelState.AddModelError($"Links[{i}].ProductTitle", "Título do produto é obrigatório");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title pode ser vazio para links de produto (será preenchido automaticamente)
|
||||||
|
modelState.Remove($"Links[{i}].Title");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Para links normais, Title é obrigatório
|
||||||
|
if (string.IsNullOrWhiteSpace(link.Title))
|
||||||
|
{
|
||||||
|
modelState.AddModelError($"Links[{i}].Title", "Título é obrigatório");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campos de produto podem ser vazios para links normais
|
||||||
|
modelState.Remove($"Links[{i}].ProductTitle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/BCards.Web/Utils/ModerationMenuViewComponent.cs
Normal file
25
src/BCards.Web/Utils/ModerationMenuViewComponent.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BCards.Web.Utils
|
||||||
|
{
|
||||||
|
public class ModerationMenuViewComponent : ViewComponent
|
||||||
|
{
|
||||||
|
private readonly IModerationAuthService _moderationAuth;
|
||||||
|
|
||||||
|
public ModerationMenuViewComponent(IModerationAuthService moderationAuth)
|
||||||
|
{
|
||||||
|
_moderationAuth = moderationAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IViewComponentResult Invoke()
|
||||||
|
{
|
||||||
|
var user = HttpContext.User;
|
||||||
|
var isModerator = user.Identity?.IsAuthenticated == true &&
|
||||||
|
_moderationAuth.IsUserModerator(user);
|
||||||
|
|
||||||
|
return View(isModerator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/BCards.Web/Utils/SlugHelper.cs
Normal file
116
src/BCards.Web/Utils/SlugHelper.cs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace BCards.Web.Utils
|
||||||
|
{
|
||||||
|
public static class SlugHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Remove acentos e caracteres especiais, criando um slug limpo
|
||||||
|
/// </summary>
|
||||||
|
public static string RemoveAccents(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// Normalizar para NFD (decompor caracteres acentuados)
|
||||||
|
var normalizedString = text.Normalize(NormalizationForm.FormD);
|
||||||
|
var stringBuilder = new StringBuilder();
|
||||||
|
|
||||||
|
// Filtrar apenas caracteres que não são marcas diacríticas
|
||||||
|
foreach (var c in normalizedString)
|
||||||
|
{
|
||||||
|
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||||
|
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
||||||
|
{
|
||||||
|
stringBuilder.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cria um slug limpo e URL-friendly
|
||||||
|
/// </summary>
|
||||||
|
public static string CreateSlug(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// 1. Remover acentos
|
||||||
|
var slug = RemoveAccents(text);
|
||||||
|
|
||||||
|
// 2. Converter para minúsculas
|
||||||
|
slug = slug.ToLowerInvariant();
|
||||||
|
|
||||||
|
// 3. Substituir espaços e caracteres especiais por hífen
|
||||||
|
slug = Regex.Replace(slug, @"[^a-z0-9\s-]", "");
|
||||||
|
|
||||||
|
// 4. Substituir múltiplos espaços por hífen único
|
||||||
|
slug = Regex.Replace(slug, @"[\s-]+", "-");
|
||||||
|
|
||||||
|
// 5. Remover hífens do início e fim
|
||||||
|
slug = slug.Trim('-');
|
||||||
|
|
||||||
|
// 6. Limitar tamanho (opcional)
|
||||||
|
if (slug.Length > 50)
|
||||||
|
slug = slug.Substring(0, 50).TrimEnd('-');
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cria uma categoria limpa (sem acentos, minúscula)
|
||||||
|
/// </summary>
|
||||||
|
public static string CreateCategorySlug(string category)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(category))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var slug = RemoveAccents(category);
|
||||||
|
slug = slug.ToLowerInvariant();
|
||||||
|
slug = Regex.Replace(slug, @"[^a-z0-9]", "");
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dicionário de conversões comuns para categorias brasileiras
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, string> CategoryMappings = new()
|
||||||
|
{
|
||||||
|
{ "saúde", "saude" },
|
||||||
|
{ "educação", "educacao" },
|
||||||
|
{ "tecnologia", "tecnologia" },
|
||||||
|
{ "negócios", "negocios" },
|
||||||
|
{ "serviços", "servicos" },
|
||||||
|
{ "alimentação", "alimentacao" },
|
||||||
|
{ "construção", "construcao" },
|
||||||
|
{ "automóveis", "automoveis" },
|
||||||
|
{ "beleza", "beleza" },
|
||||||
|
{ "esportes", "esportes" },
|
||||||
|
{ "música", "musica" },
|
||||||
|
{ "fotografia", "fotografia" }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converte categoria com mapeamento personalizado
|
||||||
|
/// </summary>
|
||||||
|
public static string ConvertCategory(string category)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(category))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var lowerCategory = category.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
// Verificar mapeamento direto
|
||||||
|
if (CategoryMappings.ContainsKey(lowerCategory))
|
||||||
|
return CategoryMappings[lowerCategory];
|
||||||
|
|
||||||
|
// Fallback para conversão automática
|
||||||
|
return CreateCategorySlug(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/BCards.Web/Utils/ViewExtensions.cs
Normal file
14
src/BCards.Web/Utils/ViewExtensions.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
using BCards.Web.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Utils
|
||||||
|
{
|
||||||
|
public static class ViewExtensions
|
||||||
|
{
|
||||||
|
public static bool IsModerator(this ClaimsPrincipal user, IServiceProvider services)
|
||||||
|
{
|
||||||
|
var moderationAuth = services.GetRequiredService<IModerationAuthService>();
|
||||||
|
return moderationAuth.IsUserModerator(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -50,19 +50,35 @@ public class ManageLinkViewModel
|
|||||||
public string Id { get; set; } = "new";
|
public string Id { get; set; } = "new";
|
||||||
|
|
||||||
[Required(ErrorMessage = "Título é obrigatório")]
|
[Required(ErrorMessage = "Título é obrigatório")]
|
||||||
[StringLength(50, ErrorMessage = "Título deve ter no máximo 50 caracteres")]
|
[StringLength(200, ErrorMessage = "Título deve ter no máximo 50 caracteres")]
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Required(ErrorMessage = "URL é obrigatória")]
|
[Required(ErrorMessage = "URL é obrigatória")]
|
||||||
[Url(ErrorMessage = "URL inválida")]
|
[Url(ErrorMessage = "URL inválida")]
|
||||||
public string Url { get; set; } = string.Empty;
|
public string Url { get; set; } = string.Empty;
|
||||||
|
|
||||||
[StringLength(100, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")]
|
[StringLength(3000, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")]
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Icon { get; set; } = string.Empty;
|
public string Icon { get; set; } = string.Empty;
|
||||||
public int Order { get; set; } = 0;
|
public int Order { get; set; } = 0;
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
// Campos para Links de Produto
|
||||||
|
public LinkType Type { get; set; } = LinkType.Normal;
|
||||||
|
|
||||||
|
[StringLength(200, ErrorMessage = "Título do produto deve ter no máximo 100 caracteres")]
|
||||||
|
public string ProductTitle { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ProductImage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(50, ErrorMessage = "Preço deve ter no máximo 50 caracteres")]
|
||||||
|
public string? ProductPrice { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(3000, ErrorMessage = "Descrição do produto deve ter no máximo 200 caracteres")]
|
||||||
|
public string ProductDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime? ProductDataCachedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DashboardViewModel
|
public class DashboardViewModel
|
||||||
@ -84,7 +100,10 @@ public class UserPageSummary
|
|||||||
public int TotalClicks { get; set; } = 0;
|
public int TotalClicks { get; set; } = 0;
|
||||||
public int TotalViews { get; set; } = 0;
|
public int TotalViews { get; set; } = 0;
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}";
|
public string? PreviewToken { get; set; } = string.Empty;
|
||||||
|
public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}?preview={PreviewToken}";
|
||||||
|
public PageStatus? LastModerationStatus { get; set; } = PageStatus.PendingModeration;
|
||||||
|
public string? Motive { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PlanInfo
|
public class PlanInfo
|
||||||
@ -104,5 +123,8 @@ public enum PageStatus
|
|||||||
Active, // Funcionando normalmente
|
Active, // Funcionando normalmente
|
||||||
Expired, // Trial vencido -> 301 redirect
|
Expired, // Trial vencido -> 301 redirect
|
||||||
PendingPayment, // Pagamento atrasado -> aviso na página
|
PendingPayment, // Pagamento atrasado -> aviso na página
|
||||||
Inactive // Pausada pelo usuário
|
Inactive, // Pausada pelo usuário
|
||||||
|
PendingModeration = 4, // Aguardando moderação
|
||||||
|
Rejected = 5, // Rejeitada na moderação
|
||||||
|
Creating = 6 // Em desenvolvimento/criação
|
||||||
}
|
}
|
||||||
83
src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs
Normal file
83
src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace BCards.Web.ViewModels;
|
||||||
|
|
||||||
|
public class ManageSubscriptionViewModel
|
||||||
|
{
|
||||||
|
public User User { get; set; } = new();
|
||||||
|
public Stripe.Subscription? StripeSubscription { get; set; }
|
||||||
|
public Models.Subscription? LocalSubscription { get; set; }
|
||||||
|
public List<Invoice> PaymentHistory { get; set; } = new();
|
||||||
|
public List<AvailablePlanViewModel> AvailablePlans { get; set; } = new();
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public string? SuccessMessage { get; set; }
|
||||||
|
|
||||||
|
// Propriedades calculadas
|
||||||
|
public bool HasActiveSubscription => StripeSubscription?.Status == "active";
|
||||||
|
public bool CanUpgrade => HasActiveSubscription && User.CurrentPlan != "premium";
|
||||||
|
public bool CanDowngrade => HasActiveSubscription && User.CurrentPlan != "basic";
|
||||||
|
public bool WillCancelAtPeriodEnd => StripeSubscription?.CancelAtPeriodEnd == true;
|
||||||
|
|
||||||
|
public DateTime? CurrentPeriodEnd => StripeSubscription?.CurrentPeriodEnd;
|
||||||
|
public DateTime? NextBillingDate => !WillCancelAtPeriodEnd ? CurrentPeriodEnd : null;
|
||||||
|
|
||||||
|
public decimal? MonthlyAmount => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.UnitAmount / 100m;
|
||||||
|
public string? Currency => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.Currency?.ToUpper();
|
||||||
|
|
||||||
|
public string StatusDisplayName => (StripeSubscription?.Status) switch
|
||||||
|
{
|
||||||
|
"active" => "Ativa",
|
||||||
|
"past_due" => "Em atraso",
|
||||||
|
"canceled" => "Cancelada",
|
||||||
|
"unpaid" => "Não paga",
|
||||||
|
"incomplete" => "Incompleta",
|
||||||
|
"incomplete_expired" => "Expirada",
|
||||||
|
"trialing" => "Em período de teste",
|
||||||
|
_ => "Desconhecido"
|
||||||
|
};
|
||||||
|
|
||||||
|
public string PlanDisplayName => User.CurrentPlan switch
|
||||||
|
{
|
||||||
|
"basic" => "Básico",
|
||||||
|
"professional" => "Profissional",
|
||||||
|
"premium" => "Premium",
|
||||||
|
_ => "Gratuito"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AvailablePlanViewModel
|
||||||
|
{
|
||||||
|
public string PlanType { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
public string PriceId { get; set; } = string.Empty;
|
||||||
|
public int MaxLinks { get; set; }
|
||||||
|
public bool AllowCustomThemes { get; set; }
|
||||||
|
public bool AllowAnalytics { get; set; }
|
||||||
|
public bool AllowCustomDomain { get; set; }
|
||||||
|
public bool IsCurrentPlan { get; set; }
|
||||||
|
public bool IsUpgrade { get; set; }
|
||||||
|
public bool IsDowngrade { get; set; }
|
||||||
|
public List<string> Features { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PaymentHistoryItemViewModel
|
||||||
|
{
|
||||||
|
public string InvoiceId { get; set; } = string.Empty;
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string Currency { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string? ReceiptUrl { get; set; }
|
||||||
|
|
||||||
|
public string StatusDisplayName => Status switch
|
||||||
|
{
|
||||||
|
"paid" => "Pago",
|
||||||
|
"open" => "Em aberto",
|
||||||
|
"void" => "Cancelado",
|
||||||
|
"uncollectible" => "Incobrável",
|
||||||
|
_ => "Desconhecido"
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/BCards.Web/ViewModels/ModerationViewModel.cs
Normal file
81
src/BCards.Web/ViewModels/ModerationViewModel.cs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
using BCards.Web.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.ViewModels;
|
||||||
|
|
||||||
|
public class ModerationDashboardViewModel
|
||||||
|
{
|
||||||
|
public List<PendingPageViewModel> PendingPages { get; set; } = new();
|
||||||
|
public Dictionary<string, int> Stats { get; set; } = new();
|
||||||
|
public int CurrentPage { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
public bool HasNextPage { get; set; } = false;
|
||||||
|
public bool HasPreviousPage => CurrentPage > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModerationPageViewModel
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public int ModerationAttempts { get; set; }
|
||||||
|
public string PlanType { get; set; } = string.Empty;
|
||||||
|
public string? PreviewUrl { get; set; }
|
||||||
|
public DateTime? ApprovedAt { get; set; }
|
||||||
|
public ModerationHistory? LastModerationEntry { get; set; }
|
||||||
|
|
||||||
|
public string PriorityLabel => PlanType.ToLower() switch
|
||||||
|
{
|
||||||
|
"premium" => "ALTA",
|
||||||
|
"professional" => "ALTA",
|
||||||
|
"basic" => "MÉDIA",
|
||||||
|
_ => "BAIXA"
|
||||||
|
};
|
||||||
|
|
||||||
|
public string PriorityColor => PlanType.ToLower() switch
|
||||||
|
{
|
||||||
|
"premium" => "danger",
|
||||||
|
"professional" => "warning",
|
||||||
|
"basic" => "info",
|
||||||
|
_ => "secondary"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModerationReviewViewModel
|
||||||
|
{
|
||||||
|
public UserPage Page { get; set; } = new();
|
||||||
|
public User User { get; set; } = new();
|
||||||
|
public string? PreviewUrl { get; set; }
|
||||||
|
public List<ModerationCriterion> ModerationCriteria { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModerationHistoryViewModel
|
||||||
|
{
|
||||||
|
public List<ModerationPageViewModel> Pages { get; set; } = new();
|
||||||
|
public int CurrentPage { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
public bool HasNextPage { get; set; } = false;
|
||||||
|
public bool HasPreviousPage => CurrentPage > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ModerationCriterion
|
||||||
|
{
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public List<string> Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PendingPageViewModel
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string DisplayName { get; set; } = "";
|
||||||
|
public string Slug { get; set; } = "";
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string PlanType { get; set; } = "";
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public int ModerationAttempts { get; set; }
|
||||||
|
public string PreviewUrl { get; set; } = "";
|
||||||
|
public string PriorityLabel { get; set; } = "";
|
||||||
|
public string PriorityColor { get; set; } = "";
|
||||||
|
}
|
||||||
@ -16,23 +16,43 @@
|
|||||||
@if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage))
|
@if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage))
|
||||||
{
|
{
|
||||||
<img src="@Model.CurrentUser.ProfileImage" alt="@Model.CurrentUser.Name"
|
<img src="@Model.CurrentUser.ProfileImage" alt="@Model.CurrentUser.Name"
|
||||||
class="rounded-circle" style="width: 60px; height: 60px; object-fit: cover;">
|
class="rounded-circle" style="width: 60px; height: 60px; object-fit: cover;">
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lista de Páginas -->
|
<!-- Lista de Páginas -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@foreach (var page in Model.UserPages)
|
@foreach (var pageItem in Model.UserPages)
|
||||||
{
|
{
|
||||||
<div class="col-md-6 col-lg-4 mb-3">
|
<div class="col-md-6 col-lg-6 mb-4">
|
||||||
<div class="card h-100 @(page.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
|
<div class="card h-100 @(pageItem.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="card-title">@(page.DisplayName)</h6>
|
<h6 class="card-title">
|
||||||
<p class="text-muted small mb-2">@(page.Category)/@(page.Slug)</p>
|
@(pageItem.DisplayName)
|
||||||
|
<form method="post" action="/Admin/DeletePage/@(pageItem.Id)" style="display: inline;" onsubmit="return confirm('Tem certeza que deseja excluir esta página?')">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-link text-danger p-0" title="Excluir página"
|
||||||
|
style="font-size: 12px; text-decoration: none;">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<input type="hidden" id="displayName_@pageItem.Id" value="@(pageItem.DisplayName)" />
|
||||||
|
</h6>
|
||||||
|
<p class="text-muted small mb-2">@(pageItem.Category)/@(pageItem.Slug)</p>
|
||||||
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@switch (page.Status)
|
@{
|
||||||
|
var pageStatus = pageItem.Status;
|
||||||
|
if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Inactive)
|
||||||
|
{
|
||||||
|
if (pageItem.LastModerationStatus.HasValue)
|
||||||
|
{
|
||||||
|
pageStatus = pageItem.LastModerationStatus.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@switch (pageStatus)
|
||||||
{
|
{
|
||||||
case BCards.Web.ViewModels.PageStatus.Active:
|
case BCards.Web.ViewModels.PageStatus.Active:
|
||||||
<span class="badge bg-success">Ativa</span>
|
<span class="badge bg-success">Ativa</span>
|
||||||
@ -46,41 +66,172 @@
|
|||||||
case BCards.Web.ViewModels.PageStatus.Inactive:
|
case BCards.Web.ViewModels.PageStatus.Inactive:
|
||||||
<span class="badge bg-secondary">Inativa</span>
|
<span class="badge bg-secondary">Inativa</span>
|
||||||
break;
|
break;
|
||||||
|
case BCards.Web.ViewModels.PageStatus.PendingModeration:
|
||||||
|
<span class="badge bg-warning">Aguardando</span>
|
||||||
|
break;
|
||||||
|
case BCards.Web.ViewModels.PageStatus.Rejected:
|
||||||
|
<span class="badge bg-danger">Rejeitada</span>
|
||||||
|
break;
|
||||||
|
case BCards.Web.ViewModels.PageStatus.Creating:
|
||||||
|
<span class="badge bg-info">
|
||||||
|
<i class="fas fa-edit me-1"></i>Criando
|
||||||
|
</span>
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.CurrentPlan.AllowsAnalytics)
|
@if (Model.CurrentPlan.AllowsAnalytics)
|
||||||
{
|
{
|
||||||
<div class="row text-center small mb-3">
|
<div class="row text-center small mb-3">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="text-primary fw-bold">@(page.TotalViews)</div>
|
<div class="text-primary fw-bold">@(pageItem.TotalViews)</div>
|
||||||
<div class="text-muted">Visualizações</div>
|
<div class="text-muted">Visualizações</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="text-success fw-bold">@(page.TotalClicks)</div>
|
<div class="text-success fw-bold">@(pageItem.TotalClicks)</div>
|
||||||
<div class="text-muted">Cliques</div>
|
<div class="text-muted">Cliques</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex gap-1 flex-wrap">
|
<div class="d-flex gap-1 align-items-center" data-page-id="@pageItem.Id" data-status="@pageItem.Status">
|
||||||
<a href="@Url.Action("ManagePage", new { id = page.Id })"
|
<!-- Botão Ver - sempre presente quando possível -->
|
||||||
class="btn btn-sm btn-outline-primary flex-fill">Editar</a>
|
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Active)
|
||||||
<a href="@(page.PublicUrl)" target="_blank"
|
{
|
||||||
class="btn btn-sm btn-outline-success flex-fill">Ver</a>
|
<a href="/page/@pageItem.Category/@pageItem.Slug" target="_blank"
|
||||||
|
class="btn btn-sm btn-success">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>Ver
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating ||
|
||||||
|
pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected ||
|
||||||
|
pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-info"
|
||||||
|
onclick="openPreview('@pageItem.Id')"
|
||||||
|
data-page-category="@pageItem.Category"
|
||||||
|
data-page-slug="@pageItem.Slug">
|
||||||
|
<i class="fas fa-eye me-1"></i>Preview
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Dropdown para outras ações -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle"
|
||||||
|
type="button"
|
||||||
|
id="dropdownMenuButton@(pageItem.Id)"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton@(pageItem.Id)">
|
||||||
|
<!-- Editar - sempre presente -->
|
||||||
|
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<span class="dropdown-item disabled">
|
||||||
|
<i class="fas fa-edit me-2"></i>Editar
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<a href="@Url.Action("ManagePage", new { id = pageItem.Id })"
|
||||||
|
class="dropdown-item">
|
||||||
|
<i class="fas fa-edit me-2"></i>Editar
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating ||
|
||||||
|
pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected)
|
||||||
|
{
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<button type="button"
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick="submitForModeration('@pageItem.Id')"
|
||||||
|
data-page-name="@pageItem.DisplayName">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>Enviar para Moderação
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
|
||||||
|
{
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<span class="dropdown-item disabled">
|
||||||
|
<i class="fas fa-hourglass-half me-2"></i>Aguardando Moderação
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-transparent">
|
<div class="card-footer bg-transparent">
|
||||||
<small class="text-muted">Criada em @(page.CreatedAt.ToString("dd/MM/yyyy"))</small>
|
<small class="text-muted">Criada em @(pageItem.CreatedAt.ToString("dd/MM/yyyy"))</small>
|
||||||
|
@if ((pageItem.LastModerationStatus ?? pageItem.Status) == BCards.Web.ViewModels.PageStatus.Rejected && !string.IsNullOrEmpty(pageItem.Motive))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mt-2" role="alert">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2 mt-1"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<strong>Motivo da rejeição:</strong><br>
|
||||||
|
<small>@(pageItem.Motive)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (pageItem.LastModerationStatus == BCards.Web.ViewModels.PageStatus.Active && !string.IsNullOrEmpty(pageItem.Motive))
|
||||||
|
{
|
||||||
|
<div class="alert alert-info alert-dismissible fade show mt-2" role="alert">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2 mt-1"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<strong>Motivo:</strong><br>
|
||||||
|
<small>@(pageItem.Motive)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
@if ((pageItem.LastModerationStatus ?? pageItem.Status) == BCards.Web.ViewModels.PageStatus.Creating)
|
||||||
|
{
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-secondary d-flex align-items-center alert-dismissible alert-permanent fade show">
|
||||||
|
<i class="fas fa-exclamation-triangle me-3"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Página em criação!</strong>
|
||||||
|
Você pode editar e fazer preview quantas vezes quiser. <br />
|
||||||
|
Ao terminar, clique em <i class="fas fa-ellipsis-v"></i> para enviar a página <b><span id="pageNameDisplay"></span></b> para moderação!
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var pageNameDisplay = document.getElementById('pageNameDisplay');
|
||||||
|
var displayName = document.getElementById('displayName_@pageItem.Id');
|
||||||
|
pageNameDisplay.innerHTML = displayName.value;
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Card para Criar Nova Página -->
|
<!-- Card para Criar Nova Página -->
|
||||||
@if (Model.CanCreateNewPage)
|
@if (Model.CanCreateNewPage)
|
||||||
{
|
{
|
||||||
<div class="col-md-6 col-lg-4 mb-3">
|
<div class="col-md-6 col-lg-4 mb-4">
|
||||||
<div class="card h-100 border-dashed text-center" style="border: 2px dashed #dee2e6;">
|
<div class="card h-100 border-dashed text-center" style="border: 2px dashed #dee2e6;">
|
||||||
<div class="card-body d-flex align-items-center justify-content-center">
|
<div class="card-body d-flex align-items-center justify-content-center">
|
||||||
<div>
|
<div>
|
||||||
@ -127,6 +278,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -271,6 +423,140 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
// Função para abrir preview com token fresh
|
||||||
|
async function openPreview(pageId) {
|
||||||
|
const button = event.target.closest('button');
|
||||||
|
const category = button.dataset.pageCategory;
|
||||||
|
const slug = button.dataset.pageSlug;
|
||||||
|
|
||||||
|
// Desabilitar botão temporariamente
|
||||||
|
const originalText = button.innerHTML;
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Carregando...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Gerar novo token
|
||||||
|
const response = await fetch(`/Admin/GeneratePreviewToken/${pageId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Abrir preview em nova aba com token novo
|
||||||
|
const previewUrl = `${window.location.origin}/page/${category}/${slug}?preview=${result.previewToken}`;
|
||||||
|
window.open(previewUrl, '_blank');
|
||||||
|
} else {
|
||||||
|
showToast(result.message || 'Erro ao gerar preview', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar preview:', error);
|
||||||
|
showToast('Erro ao gerar preview. Tente novamente.', 'error');
|
||||||
|
} finally {
|
||||||
|
// Reabilitar botão
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForModeration(pageId) {
|
||||||
|
const pageName = event.target.dataset.pageName || 'esta página';
|
||||||
|
|
||||||
|
if (!confirm(`Enviar "${pageName}" para moderação?\n\nApós enviar, você não poderá mais editá-la até receber o resultado da análise.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desabilitar botão durante envio
|
||||||
|
const button = event.target;
|
||||||
|
const originalText = button.innerHTML;
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/Admin/SubmitForModeration/${pageId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Mostrar toast de sucesso
|
||||||
|
showToast(result.message, 'success');
|
||||||
|
|
||||||
|
// Recarregar página após 2 segundos
|
||||||
|
setTimeout(() => {
|
||||||
|
location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
showToast(result.message || 'Erro ao enviar página', 'error');
|
||||||
|
|
||||||
|
// Reabilitar botão
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro:', error);
|
||||||
|
showToast('Erro ao enviar página para moderação', 'error');
|
||||||
|
|
||||||
|
// Reabilitar botão
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type) {
|
||||||
|
const toastContainer = getOrCreateToastContainer();
|
||||||
|
|
||||||
|
const bgClass = type === 'success' ? 'bg-success' : type === 'error' ? 'bg-danger' : 'bg-info';
|
||||||
|
const icon = type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-triangle' : 'fa-info-circle';
|
||||||
|
|
||||||
|
const toastHtml = `
|
||||||
|
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="5000">
|
||||||
|
<div class="toast-header ${bgClass} text-white">
|
||||||
|
<i class="fas ${icon} me-2"></i>
|
||||||
|
<strong class="me-auto">${type === 'success' ? 'Sucesso' : type === 'error' ? 'Erro' : 'Informação'}</strong>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">${message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
|
||||||
|
|
||||||
|
const newToast = toastContainer.lastElementChild;
|
||||||
|
const toast = new bootstrap.Toast(newToast);
|
||||||
|
toast.show();
|
||||||
|
|
||||||
|
// Remover toast após ser fechado
|
||||||
|
newToast.addEventListener('hidden.bs.toast', function() {
|
||||||
|
newToast.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateToastContainer() {
|
||||||
|
let container = document.querySelector('.toast-container');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'toast-container position-fixed top-0 end-0 p-3';
|
||||||
|
container.style.zIndex = '1055';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
@if (TempData["Success"] != null)
|
@if (TempData["Success"] != null)
|
||||||
{
|
{
|
||||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
@using BCards.Web.Utils
|
||||||
@model BCards.Web.ViewModels.ManagePageViewModel
|
@model BCards.Web.ViewModels.ManagePageViewModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = Model.IsNewPage ? "Criar Página" : "Editar Página";
|
ViewData["Title"] = Model.IsNewPage ? "Criar Página" : "Editar Página";
|
||||||
@ -90,7 +91,7 @@
|
|||||||
<label for="slugPreview" class="form-label">URL da Página</label>
|
<label for="slugPreview" class="form-label">URL da Página</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">page/</span>
|
<span class="input-group-text">page/</span>
|
||||||
<span class="input-group-text" id="categorySlug">categoria</span>
|
<span class="input-group-text" id="categorySlug">@SlugHelper.CreateCategorySlug(Model.Category)</span>
|
||||||
<span class="input-group-text">/</span>
|
<span class="input-group-text">/</span>
|
||||||
<input type="text" class="form-control" id="slugPreview" value="@Model.Slug" readonly>
|
<input type="text" class="form-control" id="slugPreview" value="@Model.Slug" readonly>
|
||||||
<input asp-for="Slug" type="hidden">
|
<input asp-for="Slug" type="hidden">
|
||||||
@ -129,32 +130,48 @@
|
|||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<p class="text-muted mb-4">Escolha um tema que combine com sua personalidade ou marca:</p>
|
<p class="text-muted mb-4">Escolha um tema que combine com sua personalidade ou marca:</p>
|
||||||
|
|
||||||
<div class="row">
|
@{
|
||||||
@foreach (var theme in Model.AvailableThemes)
|
var themeCount = 0;
|
||||||
|
}
|
||||||
|
@foreach (var theme in Model.AvailableThemes)
|
||||||
|
{
|
||||||
|
@if (themeCount % 4 == 0)
|
||||||
{
|
{
|
||||||
<div class="col-md-4 col-lg-3 mb-3">
|
@if (themeCount > 0)
|
||||||
<div class="theme-card @(Model.SelectedTheme == theme.Name.ToLower() ? "selected" : "")" data-theme="@theme.Name.ToLower()">
|
{
|
||||||
<div class="theme-preview" style="background: @theme.BackgroundColor; color: @theme.TextColor;">
|
@:</div>
|
||||||
<div class="theme-header" style="background-color: @theme.PrimaryColor;">
|
}
|
||||||
<div class="theme-avatar"></div>
|
@:<div class="row">
|
||||||
<h6>@theme.Name</h6>
|
}
|
||||||
</div>
|
|
||||||
<div class="theme-links">
|
<div class="col-md-4 col-lg-3 mb-3">
|
||||||
<div class="theme-link" style="background-color: @theme.PrimaryColor;"></div>
|
<div class="theme-card @(Model.SelectedTheme.ToLower() == theme.Name.ToLower() ? "selected" : "")" data-theme="@theme.Name.ToLower()">
|
||||||
<div class="theme-link" style="background-color: @theme.SecondaryColor;"></div>
|
<div class="theme-preview" style="background: @theme.BackgroundColor; color: @theme.TextColor;">
|
||||||
</div>
|
<div class="theme-header" style="background-color: @theme.PrimaryColor;">
|
||||||
|
<div class="theme-avatar"></div>
|
||||||
|
<h6>@theme.Name</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-name">
|
<div class="theme-links">
|
||||||
@theme.Name
|
<div class="theme-link" style="background-color: @theme.PrimaryColor;"></div>
|
||||||
@if (theme.IsPremium)
|
<div class="theme-link" style="background-color: @theme.SecondaryColor;"></div>
|
||||||
{
|
|
||||||
<span class="badge bg-warning">Premium</span>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="theme-name">
|
||||||
|
@theme.Name
|
||||||
|
@if (theme.IsPremium)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning">Premium</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
|
||||||
|
themeCount++;
|
||||||
|
}
|
||||||
|
@if (Model.AvailableThemes.Any())
|
||||||
|
{
|
||||||
|
@:</div>
|
||||||
|
}
|
||||||
|
|
||||||
<input asp-for="SelectedTheme" type="hidden">
|
<input asp-for="SelectedTheme" type="hidden">
|
||||||
|
|
||||||
@ -194,39 +211,109 @@
|
|||||||
"instagram"
|
"instagram"
|
||||||
};
|
};
|
||||||
var match = myList.FirstOrDefault(stringToCheck => Model.Links[i].Icon.Contains(stringToCheck));
|
var match = myList.FirstOrDefault(stringToCheck => Model.Links[i].Icon.Contains(stringToCheck));
|
||||||
if (match==null) {
|
if (match==null)
|
||||||
<div class="link-input-group" data-link="@i">
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
if (Model.Links[i].Type==LinkType.Normal)
|
||||||
<h6 class="mb-0">Link @(i + 1)</h6>
|
{
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
<div class="link-input-group" data-link="@i">
|
||||||
<i class="fas fa-trash"></i>
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
</button>
|
<h6 class="mb-0">Link @(i + 1)</h6>
|
||||||
</div>
|
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
||||||
<div class="row">
|
<i class="fas fa-trash"></i>
|
||||||
<div class="col-md-6">
|
</button>
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Título</label>
|
|
||||||
<input asp-for="Links[i].Title" class="form-control link-title" placeholder="Ex: Meu Site">
|
|
||||||
<span asp-validation-for="Links[i].Title" class="text-danger"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">URL</label>
|
<label class="form-label">Título</label>
|
||||||
<input asp-for="Links[i].Url" class="form-control link-url" placeholder="https://exemplo.com">
|
<input asp-for="Links[i].Title" class="form-control link-title" placeholder="Ex: Meu Site">
|
||||||
<span asp-validation-for="Links[i].Url" class="text-danger"></span>
|
<span asp-validation-for="Links[i].Title" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">URL</label>
|
||||||
|
<input asp-for="Links[i].Url" class="form-control link-url" placeholder="https://exemplo.com">
|
||||||
|
<span asp-validation-for="Links[i].Url" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Descrição (opcional)</label>
|
||||||
|
<input asp-for="Links[i].Description" class="form-control link-description" placeholder="Breve descrição do link">
|
||||||
|
</div>
|
||||||
|
<input asp-for="Links[i].Id" type="hidden">
|
||||||
|
<input asp-for="Links[i].Icon" type="hidden">
|
||||||
|
<input asp-for="Links[i].Order" type="hidden">
|
||||||
|
<input asp-for="Links[i].IsActive" type="hidden" value="true">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
<div class="mb-2">
|
else
|
||||||
<label class="form-label">Descrição (opcional)</label>
|
{
|
||||||
<input asp-for="Links[i].Description" class="form-control link-description" placeholder="Breve descrição do link">
|
<div class="link-input-group product-link-preview" data-link="@i">
|
||||||
</div>
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<input asp-for="Links[i].Id" type="hidden">
|
<h6 class="mb-0">
|
||||||
<input asp-for="Links[i].Icon" type="hidden">
|
<i class="fas fa-shopping-bag me-2 text-success"></i>Link de Produto @(i + 1)
|
||||||
<input asp-for="Links[i].Order" type="hidden">
|
</h6>
|
||||||
<input asp-for="Links[i].IsActive" type="hidden" value="true">
|
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
||||||
</div>
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="row g-0">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="p-3 text-center">
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Links[i].ProductImage))
|
||||||
|
{
|
||||||
|
<img src="@Model.Links[i].ProductImage"
|
||||||
|
class="img-fluid rounded"
|
||||||
|
style="max-height: 80px; max-width: 100%;"
|
||||||
|
onerror="this.style.display='none'; this.parentNode.innerHTML='<i class=\'fas fa-image text-muted\'></i><br><small class=\'text-muted\'>Sem imagem</small>';" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="fas fa-image text-muted fa-2x"></i>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">Sem imagem</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-success">@Model.Links[i].Title</h6>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Links[i].ProductPrice))
|
||||||
|
{
|
||||||
|
<p class="card-text"><strong class="text-success">@Model.Links[i].ProductPrice</strong></p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Links[i].ProductDescription))
|
||||||
|
{
|
||||||
|
<p class="card-text small text-muted">@Model.Links[i].ProductDescription</p>
|
||||||
|
}
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>
|
||||||
|
@(Model.Links[i].Url.Length > 50 ? $"{Model.Links[i].Url.Substring(0, 50)}..." : Model.Links[i].Url)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden fields for form submission -->
|
||||||
|
<input type="hidden" name="Links[@i].Id" value="@Model.Links[i].Id">
|
||||||
|
<input type="hidden" name="Links[@i].Title" value="@Model.Links[i].Title">
|
||||||
|
<input type="hidden" name="Links[@i].Url" value="@Model.Links[i].Url">
|
||||||
|
<input type="hidden" name="Links[@i].Description" value="@Model.Links[i].Description">
|
||||||
|
<input type="hidden" name="Links[@i].Type" value="Product">
|
||||||
|
<input type="hidden" name="Links[@i].ProductTitle" value="@Model.Links[i].Title">
|
||||||
|
<input type="hidden" name="Links[@i].ProductDescription" value="@Model.Links[i].ProductDescription">
|
||||||
|
<input type="hidden" name="Links[@i].ProductPrice" value="@Model.Links[i].ProductPrice">
|
||||||
|
<input type="hidden" name="Links[@i].ProductImage" value="@Model.Links[i].ProductImage">
|
||||||
|
<input type="hidden" name="Links[@i].Icon" value="fas fa-shopping-bag">
|
||||||
|
<input type="hidden" name="Links[@i].Order" value="@i">
|
||||||
|
<input type="hidden" name="Links[@i].IsActive" value="true">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@ -355,41 +442,126 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="addLinkForm">
|
<form id="addLinkForm">
|
||||||
|
<!-- Tipo de Link -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="linkTitle" class="form-label">Título do Link</label>
|
<label class="form-label">Tipo de Link</label>
|
||||||
<input type="text" class="form-control" id="linkTitle" placeholder="Ex: Meu Site, Portfólio, Instagram..." required>
|
<div class="d-flex gap-2">
|
||||||
<div class="form-text">Nome que aparecerá no botão</div>
|
<div class="form-check flex-fill">
|
||||||
|
<input class="form-check-input" type="radio" name="linkType" id="linkTypeNormal" value="Normal" checked>
|
||||||
|
<label class="form-check-label w-100 p-2 border rounded" for="linkTypeNormal">
|
||||||
|
<i class="fas fa-link me-2"></i>
|
||||||
|
<strong>Link Normal</strong>
|
||||||
|
<div class="small text-muted">Link simples para sites, redes sociais, etc.</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check flex-fill">
|
||||||
|
<input class="form-check-input" type="radio" name="linkType" id="linkTypeProduct" value="Product">
|
||||||
|
<label class="form-check-label w-100 p-2 border rounded" for="linkTypeProduct">
|
||||||
|
<i class="fas fa-shopping-bag me-2"></i>
|
||||||
|
<strong>Link de Produto</strong>
|
||||||
|
<div class="small text-muted">Para produtos de e-commerce com preview</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<!-- Seção para Link Normal -->
|
||||||
<label for="linkUrl" class="form-label">URL</label>
|
<div id="normalLinkSection">
|
||||||
<input type="url" class="form-control" id="linkUrl" placeholder="https://exemplo.com" required>
|
<div class="mb-3">
|
||||||
<div class="form-text">Link completo incluindo https://</div>
|
<label for="linkTitle" class="form-label">Título do Link</label>
|
||||||
|
<input type="text" class="form-control" id="linkTitle" placeholder="Ex: Meu Site, Portfólio, Instagram..." required>
|
||||||
|
<div class="form-text">Nome que aparecerá no botão</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="linkUrl" class="form-label">URL</label>
|
||||||
|
<input type="url" class="form-control" id="linkUrl" placeholder="https://exemplo.com" required>
|
||||||
|
<div class="form-text">Link completo incluindo https://</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="linkDescription" class="form-label">Descrição (opcional)</label>
|
||||||
|
<input type="text" class="form-control" id="linkDescription" placeholder="Breve descrição do link">
|
||||||
|
<div class="form-text">Texto adicional que aparece abaixo do título</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="linkIcon" class="form-label">Ícone (opcional)</label>
|
||||||
|
<select class="form-select" id="linkIcon">
|
||||||
|
<option value="">Sem ícone</option>
|
||||||
|
<option value="fas fa-globe">🌐 Site</option>
|
||||||
|
<option value="fas fa-shopping-cart">🛒 Loja</option>
|
||||||
|
<option value="fas fa-briefcase">💼 Portfólio</option>
|
||||||
|
<option value="fas fa-envelope">✉️ Email</option>
|
||||||
|
<option value="fas fa-phone">📞 Telefone</option>
|
||||||
|
<option value="fas fa-map-marker-alt">📍 Localização</option>
|
||||||
|
<option value="fab fa-youtube">📺 YouTube</option>
|
||||||
|
<option value="fab fa-linkedin">💼 LinkedIn</option>
|
||||||
|
<option value="fab fa-github">💻 GitHub</option>
|
||||||
|
<option value="fas fa-download">⬇️ Download</option>
|
||||||
|
<option value="fas fa-calendar">📅 Agenda</option>
|
||||||
|
<option value="fas fa-heart">❤️ Favorito</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<!-- Seção para Link de Produto -->
|
||||||
<label for="linkDescription" class="form-label">Descrição (opcional)</label>
|
<div id="productLinkSection" style="display: none;">
|
||||||
<input type="text" class="form-control" id="linkDescription" placeholder="Breve descrição do link">
|
<div class="mb-3">
|
||||||
<div class="form-text">Texto adicional que aparece abaixo do título</div>
|
<label for="productUrl" class="form-label">URL do Produto</label>
|
||||||
</div>
|
<div class="input-group">
|
||||||
|
<input type="url" class="form-control" id="productUrl" placeholder="https://mercadolivre.com.br/produto...">
|
||||||
|
<button type="button" class="btn btn-outline-primary" id="extractProductBtn">
|
||||||
|
<i class="fas fa-magic"></i> Extrair Dados
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
<small>
|
||||||
|
<strong>Suportamos:</strong> Mercado Livre, Amazon, Magazine Luiza, Americanas, Shopee, e outros e-commerces conhecidos.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div id="extractLoading" style="display: none;" class="text-center my-3">
|
||||||
<label for="linkIcon" class="form-label">Ícone (opcional)</label>
|
<div class="spinner-border text-primary" role="status">
|
||||||
<select class="form-select" id="linkIcon">
|
<span class="visually-hidden">Carregando...</span>
|
||||||
<option value="">Sem ícone</option>
|
</div>
|
||||||
<option value="fas fa-globe">🌐 Site</option>
|
<p class="mt-2 text-muted">Extraindo informações do produto...</p>
|
||||||
<option value="fas fa-shopping-cart">🛒 Loja</option>
|
</div>
|
||||||
<option value="fas fa-briefcase">💼 Portfólio</option>
|
|
||||||
<option value="fas fa-envelope">✉️ Email</option>
|
<div class="row">
|
||||||
<option value="fas fa-phone">📞 Telefone</option>
|
<div class="col-md-8">
|
||||||
<option value="fas fa-map-marker-alt">📍 Localização</option>
|
<div class="mb-3">
|
||||||
<option value="fab fa-youtube">📺 YouTube</option>
|
<label for="productTitle" class="form-label">Título do Produto</label>
|
||||||
<option value="fab fa-linkedin">💼 LinkedIn</option>
|
<input type="text" class="form-control" id="productTitle" maxlength="100" placeholder="Nome do produto">
|
||||||
<option value="fab fa-github">💻 GitHub</option>
|
</div>
|
||||||
<option value="fas fa-download">⬇️ Download</option>
|
<div class="mb-3">
|
||||||
<option value="fas fa-calendar">📅 Agenda</option>
|
<label for="productDescription" class="form-label">Descrição (Opcional)</label>
|
||||||
<option value="fas fa-heart">❤️ Favorito</option>
|
<textarea class="form-control" id="productDescription" rows="2" maxlength="200" placeholder="Breve descrição do produto"></textarea>
|
||||||
</select>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="productPrice" class="form-label">Preço (Opcional)</label>
|
||||||
|
<input type="text" class="form-control" id="productPrice" placeholder="R$ 99,90">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Imagem do Produto</label>
|
||||||
|
<div class="border rounded p-3 text-center">
|
||||||
|
<img id="productImagePreview" class="img-fluid rounded" style="display: none; max-height: 120px;">
|
||||||
|
<div id="productImagePlaceholder" class="text-muted">
|
||||||
|
<i class="fas fa-image fa-2x mb-2"></i>
|
||||||
|
<p class="small mb-0">A imagem será extraída automaticamente</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="productImage">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info small">
|
||||||
|
<i class="fas fa-info-circle me-1"></i>
|
||||||
|
<strong>Dica:</strong> Os dados serão extraídos automaticamente da página do produto.
|
||||||
|
Você pode editar manualmente se necessário.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -501,6 +673,32 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Product Link Preview Styles */
|
||||||
|
.product-link-preview {
|
||||||
|
background: rgba(25, 135, 84, 0.05);
|
||||||
|
border-color: rgba(25, 135, 84, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-preview .card {
|
||||||
|
box-shadow: none;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-preview .card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-preview .card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-preview img {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
@ -528,46 +726,51 @@
|
|||||||
|
|
||||||
// Add link functionality via modal
|
// Add link functionality via modal
|
||||||
$('#addLinkBtn').on('click', function() {
|
$('#addLinkBtn').on('click', function() {
|
||||||
if (linkCount >= @Model.MaxLinksAllowed) {
|
const maxlinks = @Model.MaxLinksAllowed;
|
||||||
|
if (linkCount >= maxlinks+4) {
|
||||||
alert('Você atingiu o limite de links para seu plano atual.');
|
alert('Você atingiu o limite de links para seu plano atual.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save link from modal
|
// Toggle between link types
|
||||||
$(document).on('click', '#saveLinkBtn', function() {
|
$('input[name="linkType"]').on('change', function() {
|
||||||
console.log('Save button clicked');
|
const linkType = $(this).val();
|
||||||
|
if (linkType === 'Product') {
|
||||||
|
$('#normalLinkSection').hide();
|
||||||
|
$('#productLinkSection').show();
|
||||||
|
} else {
|
||||||
|
$('#normalLinkSection').show();
|
||||||
|
$('#productLinkSection').hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const title = $('#linkTitle').val().trim();
|
// Extract product data
|
||||||
const url = $('#linkUrl').val().trim();
|
$('#extractProductBtn').on('click', function() {
|
||||||
const description = $('#linkDescription').val().trim();
|
const url = $('#productUrl').val().trim();
|
||||||
const icon = $('#linkIcon').val();
|
|
||||||
|
|
||||||
console.log('Values:', { title, url, description, icon });
|
if (!url) {
|
||||||
|
alert('Por favor, insira a URL do produto.');
|
||||||
if (!title || !url) {
|
|
||||||
alert('Por favor, preencha pelo menos o título e a URL do link.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic URL validation
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
|
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
addLinkInput(title, url, description, icon);
|
extractProductData(url);
|
||||||
|
});
|
||||||
|
|
||||||
// Clear modal form
|
// Save link from modal
|
||||||
$('#addLinkForm')[0].reset();
|
$(document).on('click', '#saveLinkBtn', function() {
|
||||||
|
const linkType = $('input[name="linkType"]:checked').val();
|
||||||
|
|
||||||
// Close modal using Bootstrap 5 syntax
|
if (linkType === 'Product') {
|
||||||
var modal = bootstrap.Modal.getInstance(document.getElementById('addLinkModal'));
|
saveProductLink();
|
||||||
if (modal) {
|
} else {
|
||||||
modal.hide();
|
saveNormalLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
markStepComplete(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove link functionality
|
// Remove link functionality
|
||||||
@ -658,12 +861,12 @@
|
|||||||
.done(function(data) {
|
.done(function(data) {
|
||||||
$('#Slug').val(data.slug);
|
$('#Slug').val(data.slug);
|
||||||
$('#slugPreview').val(data.slug);
|
$('#slugPreview').val(data.slug);
|
||||||
$('#categorySlug').text(category);
|
$('#categorySlug').text(data.category);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLinkInput(title = '', url = '', description = '', icon = '') {
|
function addLinkInput(title = '', url = '', description = '', icon = '', linkType = 'Normal', id='new') {
|
||||||
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
|
const iconHtml = icon ? `<i class="${icon} me-2"></i>` : '';
|
||||||
|
|
||||||
const linkHtml = `
|
const linkHtml = `
|
||||||
@ -695,7 +898,8 @@
|
|||||||
<label class="form-label">Descrição (opcional)</label>
|
<label class="form-label">Descrição (opcional)</label>
|
||||||
<input type="text" name="Links[${linkCount}].Description" class="form-control link-description" value="${description}" placeholder="Breve descrição do link" readonly>
|
<input type="text" name="Links[${linkCount}].Description" class="form-control link-description" value="${description}" placeholder="Breve descrição do link" readonly>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="Links[${linkCount}].Id" value="">
|
<input type="hidden" name="Links[${linkCount}].Id" value="${id}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Type" value="${linkType}">
|
||||||
<input type="hidden" name="Links[${linkCount}].Icon" value="${icon}">
|
<input type="hidden" name="Links[${linkCount}].Icon" value="${icon}">
|
||||||
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
|
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
|
||||||
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
|
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
|
||||||
@ -712,5 +916,209 @@
|
|||||||
$(this).attr('data-link', index);
|
$(this).attr('data-link', index);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveNormalLink() {
|
||||||
|
const title = $('#linkTitle').val().trim();
|
||||||
|
const url = $('#linkUrl').val().trim();
|
||||||
|
const description = $('#linkDescription').val().trim();
|
||||||
|
const icon = $('#linkIcon').val();
|
||||||
|
|
||||||
|
if (!title || !url) {
|
||||||
|
alert('Por favor, preencha pelo menos o título e a URL do link.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLinkInput(title, url, description, icon, 'Normal');
|
||||||
|
closeModalAndReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProductLink() {
|
||||||
|
const url = $('#productUrl').val().trim();
|
||||||
|
const title = $('#productTitle').val().trim();
|
||||||
|
const description = $('#productDescription').val().trim();
|
||||||
|
const price = $('#productPrice').val().trim();
|
||||||
|
const image = $('#productImage').val();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
alert('Por favor, insira a URL do produto.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
alert('Por favor, preencha o título do produto.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
alert('Por favor, insira uma URL válida que comece com http:// ou https://');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addProductLinkInput(title, url, description, price, image);
|
||||||
|
closeModalAndReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProductData(url) {
|
||||||
|
$('#extractProductBtn').prop('disabled', true);
|
||||||
|
$('#extractLoading').show();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/api/Product/extract',
|
||||||
|
type: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify({ url: url }),
|
||||||
|
success: function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
$('#productTitle').val(response.title || '');
|
||||||
|
$('#productDescription').val(response.description || '');
|
||||||
|
$('#productPrice').val(response.price || '');
|
||||||
|
|
||||||
|
if (response.image) {
|
||||||
|
$('#productImage').val(response.image);
|
||||||
|
$('#productImagePreview').attr('src', response.image).show();
|
||||||
|
$('#productImagePlaceholder').hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Dados extraídos com sucesso!', 'success');
|
||||||
|
} else {
|
||||||
|
alert('Erro: ' + (response.message || 'Não foi possível extrair os dados do produto.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
let errorMessage = 'Erro ao extrair dados do produto.';
|
||||||
|
|
||||||
|
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||||
|
errorMessage = xhr.responseJSON.message;
|
||||||
|
} else if (xhr.status === 429) {
|
||||||
|
errorMessage = 'Aguarde 1 minuto antes de extrair dados de outro produto.';
|
||||||
|
} else if (xhr.status === 401) {
|
||||||
|
errorMessage = 'Você precisa estar logado para usar esta funcionalidade.';
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(errorMessage);
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
$('#extractProductBtn').prop('disabled', false);
|
||||||
|
$('#extractLoading').hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProductLinkInput(title, url, description, price, image, id='new') {
|
||||||
|
const linkHtml = `
|
||||||
|
<div class="link-input-group product-link-preview" data-link="${linkCount}">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-shopping-bag me-2 text-success"></i>Link de Produto ${linkCount + 1}
|
||||||
|
</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger remove-link-btn">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="row g-0">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="p-3 text-center">
|
||||||
|
${image ? `<img src="${image}" class="img-fluid rounded" style="max-height: 80px; max-width: 100%;" onerror="this.style.display='none'; this.parentNode.innerHTML='<i class=\\"fas fa-image text-muted\\"></i><br><small class=\\"text-muted\\">Sem imagem</small>';">` : '<i class="fas fa-image text-muted fa-2x"></i><br><small class="text-muted">Sem imagem</small>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-success">${title}</h6>
|
||||||
|
${price ? `<p class="card-text"><strong class="text-success">${price}</strong></p>` : ''}
|
||||||
|
${description ? `<p class="card-text small text-muted">${description}</p>` : ''}
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>
|
||||||
|
${url.length > 50 ? url.substring(0, 50) + '...' : url}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden fields for form submission -->
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Id" value="${id}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Title" value="${title}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Url" value="${url}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Description" value="${description}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Type" value="Product">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].ProductTitle" value="${title}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].ProductDescription" value="${description}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].ProductPrice" value="${price}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].ProductImage" value="${image}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Icon" value="fas fa-shopping-bag">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].Order" value="${linkCount}">
|
||||||
|
<input type="hidden" name="Links[${linkCount}].IsActive" value="true">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
$('#linksContainer').append(linkHtml);
|
||||||
|
linkCount++;
|
||||||
|
markStepComplete(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModalAndReset() {
|
||||||
|
// Clear modal form
|
||||||
|
$('#addLinkForm')[0].reset();
|
||||||
|
$('#productImagePreview').hide();
|
||||||
|
$('#productImagePlaceholder').show();
|
||||||
|
$('#productImage').val('');
|
||||||
|
$('#normalLinkSection').show();
|
||||||
|
$('#productLinkSection').hide();
|
||||||
|
$('#linkTypeNormal').prop('checked', true);
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
var modal = bootstrap.Modal.getInstance(document.getElementById('addLinkModal'));
|
||||||
|
if (modal) {
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
// Simple toast notification
|
||||||
|
const toastHtml = `
|
||||||
|
<div class="toast align-items-center text-white bg-${type === 'success' ? 'success' : 'primary'} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">${message}</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!$('#toastContainer').length) {
|
||||||
|
$('body').append('<div id="toastContainer" class="toast-container position-fixed top-0 end-0 p-3"></div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const $toast = $(toastHtml);
|
||||||
|
$('#toastContainer').append($toast);
|
||||||
|
|
||||||
|
const toast = new bootstrap.Toast($toast[0]);
|
||||||
|
toast.show();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
$toast.remove();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (TempData["Error"] != null)
|
||||||
|
{
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||||
|
<div class="toast show" role="alert">
|
||||||
|
<div class="toast-header">
|
||||||
|
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
||||||
|
<strong class="me-auto">Atenção</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
@TempData["Error"]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
@{
|
@{
|
||||||
var isPreview = ViewBag.IsPreview as bool? ?? false;
|
//var isPreview = ViewBag.IsPreview as bool? ?? false;
|
||||||
ViewData["Title"] = "BCards - Crie seu LinkTree Profissional";
|
ViewData["Title"] = "BCards - Crie seu LinkTree Profissional";
|
||||||
var categories = ViewBag.Categories as List<BCards.Web.Models.Category> ?? new List<BCards.Web.Models.Category>();
|
var categories = ViewBag.Categories as List<BCards.Web.Models.Category> ?? new List<BCards.Web.Models.Category>();
|
||||||
var recentPages = ViewBag.RecentPages as List<BCards.Web.Models.UserPage> ?? new List<BCards.Web.Models.UserPage>();
|
var recentPages = ViewBag.RecentPages as List<BCards.Web.Models.UserPage> ?? new List<BCards.Web.Models.UserPage>();
|
||||||
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
//Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
||||||
|
Layout = "_Layout";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="hero-section bg-primary bg-gradient text-white py-5 mb-5">
|
<div class="hero-section bg-primary bg-gradient text-white py-5 mb-5">
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Planos e Preços - BCards";
|
ViewData["Title"] = "Planos e Preços - BCards";
|
||||||
var isPreview = ViewBag.IsPreview as bool? ?? false;
|
//var isPreview = ViewBag.IsPreview as bool? ?? false;
|
||||||
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
//Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
||||||
|
Layout = "_Layout";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="container py-5">
|
<div class="container py-5">
|
||||||
@ -100,7 +101,7 @@
|
|||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
||||||
<input type="hidden" name="planType" value="basic" />
|
<input type="hidden" name="planType" value="Basic" />
|
||||||
<button type="submit" class="btn btn-outline-primary w-100">Escolher Básico</button>
|
<button type="submit" class="btn btn-outline-primary w-100">Escolher Básico</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@ -154,7 +155,7 @@
|
|||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
||||||
<input type="hidden" name="planType" value="professional" />
|
<input type="hidden" name="planType" value="Professional" />
|
||||||
<button type="submit" class="btn btn-warning w-100">Escolher Profissional</button>
|
<button type="submit" class="btn btn-warning w-100">Escolher Profissional</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@ -212,7 +213,7 @@
|
|||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
<form asp-controller="Payment" asp-action="CreateCheckoutSession" method="post">
|
||||||
<input type="hidden" name="planType" value="premium" />
|
<input type="hidden" name="planType" value="Premium" />
|
||||||
<button type="submit" class="btn btn-primary w-100 fw-bold">Escolher Premium</button>
|
<button type="submit" class="btn btn-primary w-100 fw-bold">Escolher Premium</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@ -343,3 +344,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (TempData["Success"] != null)
|
||||||
|
{
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||||
|
<div class="toast show" role="alert">
|
||||||
|
<div class="toast-header">
|
||||||
|
<i class="fas fa-check-circle text-success me-2"></i>
|
||||||
|
<strong class="me-auto">Sucesso</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
@TempData["Success"]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (TempData["Error"] != null)
|
||||||
|
{
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||||
|
<div class="toast show" role="alert">
|
||||||
|
<div class="toast-header">
|
||||||
|
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
||||||
|
<strong class="me-auto">Atenção</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
@TempData["Error"]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (TempData["Info"] != null)
|
||||||
|
{
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||||
|
<div class="toast show" role="alert">
|
||||||
|
<div class="toast-header">
|
||||||
|
<i class="fas fa-exclamation-triangle text-primary me-2"></i>
|
||||||
|
<strong class="me-auto">Atenção</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
@TempData["Info"]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
60
src/BCards.Web/Views/Moderation/Dashboard.cshtml
Normal file
60
src/BCards.Web/Views/Moderation/Dashboard.cshtml
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
@using BCards.Web.ViewModels
|
||||||
|
@model ModerationDashboardViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Dashboard de Moderação";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>Dashboard de Moderação</h1>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h4>Sistema de Moderação</h4>
|
||||||
|
<p>Páginas pendentes: <strong>@Model.PendingPages.Count</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.PendingPages.Any())
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Páginas Pendentes</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Categoria</th>
|
||||||
|
<th>Criada em</th>
|
||||||
|
<th>Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var pageItem in Model.PendingPages)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@pageItem.DisplayName</td>
|
||||||
|
<td>@pageItem.Category</td>
|
||||||
|
<td>@pageItem.CreatedAt.ToString("dd/MM/yyyy")</td>
|
||||||
|
<td>
|
||||||
|
<a href="/moderation/review/@pageItem.Id" class="btn btn-sm btn-primary">
|
||||||
|
Moderar
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h4>✅ Nenhuma página pendente!</h4>
|
||||||
|
<p>Todas as páginas foram processadas.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
83
src/BCards.Web/Views/Moderation/History.cshtml
Normal file
83
src/BCards.Web/Views/Moderation/History.cshtml
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
@using BCards.Web.ViewModels
|
||||||
|
@model ModerationHistoryViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Histórico de Moderação";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>Histórico de Moderação</h1>
|
||||||
|
<a href="/moderation/dashboard" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Voltar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h4>Histórico</h4>
|
||||||
|
<p>Páginas processadas: <strong>@Model.Pages.Count</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Pages.Any())
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Páginas Processadas</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Categoria</th>
|
||||||
|
<th>Processada em</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var pageItem in Model.Pages)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
@if (pageItem.Status == "Active")
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Aprovada</span>
|
||||||
|
}
|
||||||
|
else if (pageItem.Status == "Rejected")
|
||||||
|
{
|
||||||
|
<span class="badge bg-danger">Rejeitada</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">@pageItem.Status</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>@pageItem.DisplayName</td>
|
||||||
|
<td>@pageItem.Category</td>
|
||||||
|
<td>
|
||||||
|
@if (pageItem.ApprovedAt.HasValue)
|
||||||
|
{
|
||||||
|
@pageItem.ApprovedAt.Value.ToString("dd/MM/yyyy")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">Pendente</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h4>📋 Nenhum histórico ainda</h4>
|
||||||
|
<p>Ainda não há páginas processadas.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
267
src/BCards.Web/Views/Moderation/Review.cshtml
Normal file
267
src/BCards.Web/Views/Moderation/Review.cshtml
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
@using BCards.Web.ViewModels
|
||||||
|
@model ModerationReviewViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Revisar Página";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>Moderar Página</h1>
|
||||||
|
<a href="/moderation/dashboard" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Voltar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Informações da Página -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Informações da Página</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Nome:</strong> @Model.Page.DisplayName
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Categoria:</strong>
|
||||||
|
<span class="badge bg-light text-dark">@Model.Page.Category</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Slug:</strong> @Model.Page.Slug
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Tipo:</strong> @Model.Page.BusinessType
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Plano:</strong>
|
||||||
|
<span class="badge bg-info">@Model.Page.PlanLimitations.PlanType</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Criado em:</strong> @Model.Page.CreatedAt.ToString("dd/MM/yyyy HH:mm")
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Tentativas:</strong> @Model.Page.ModerationAttempts
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Total de Links:</strong> @Model.Page.Links.Count
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informações do Usuário -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Informações do Usuário</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Nome:</strong> @Model.User.Name
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Email:</strong> @Model.User.Email
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Score:</strong> @Model.Page.UserScore
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Membro desde:</strong> @Model.User.CreatedAt.ToString("dd/MM/yyyy")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview da Página -->
|
||||||
|
@if (!string.IsNullOrEmpty(Model.PreviewUrl))
|
||||||
|
{
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Preview da Página</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<a href="@Model.PreviewUrl" target="_blank" class="btn btn-info btn-block">
|
||||||
|
<i class="fas fa-external-link-alt"></i> Abrir Preview
|
||||||
|
</a>
|
||||||
|
<small class="text-muted mt-2 d-block">
|
||||||
|
Visualizações: @Model.Page.PreviewViewCount/50
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conteúdo da Página -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Conteúdo da Página</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Page.Bio))
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Biografia:</strong>
|
||||||
|
<p>@Model.Page.Bio</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Links (@Model.Page.Links.Count):</strong>
|
||||||
|
<div class="mt-2">
|
||||||
|
@foreach (var link in Model.Page.Links.OrderBy(l => l.Order))
|
||||||
|
{
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<strong>@link.Title</strong>
|
||||||
|
@if (link.Type == LinkType.Product)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success ms-2">Produto</span>
|
||||||
|
}
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">@link.Url</small>
|
||||||
|
@if (!string.IsNullOrEmpty(link.Description))
|
||||||
|
{
|
||||||
|
<br>
|
||||||
|
<small>@link.Description</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="@link.Url" target="_blank" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Critérios de Moderação -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Critérios de Moderação</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="moderationForm">
|
||||||
|
@foreach (var criterion in Model.ModerationCriteria)
|
||||||
|
{
|
||||||
|
<div class="mb-4">
|
||||||
|
<h6 class="text-danger">🚫 @criterion.Category</h6>
|
||||||
|
@foreach (var item in criterion.Items)
|
||||||
|
{
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="issues" value="@item" id="issue_@(item.GetHashCode())">
|
||||||
|
<label class="form-check-label" for="issue_@(item.GetHashCode())">
|
||||||
|
@item
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Histórico de Moderação -->
|
||||||
|
@if (Model.Page.ModerationHistory.Any())
|
||||||
|
{
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Histórico de Moderação</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@foreach (var history in Model.Page.ModerationHistory.OrderByDescending(h => h.Date))
|
||||||
|
{
|
||||||
|
<div class="mb-3 pb-3 border-bottom">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<strong>Tentativa @history.Attempt</strong>
|
||||||
|
<small class="text-muted">@history.Date.ToString("dd/MM/yyyy HH:mm")</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-@(history.Status == "approved" ? "success" : "danger")">
|
||||||
|
@(history.Status == "approved" ? "Aprovada" : "Rejeitada")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(history.Reason))
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>Motivo:</strong> @history.Reason
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (history.Issues.Any())
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>Problemas:</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var issue in history.Issues)
|
||||||
|
{
|
||||||
|
<li>@issue</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Ações de Moderação -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Ações de Moderação</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<form asp-action="Approve" asp-route-id="@Model.Page.Id" method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="notes" class="form-label">Notas (opcional)</label>
|
||||||
|
<textarea class="form-control" id="notes" name="notes" rows="3"
|
||||||
|
placeholder="Observações sobre a aprovação..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success btn-block">
|
||||||
|
<i class="fas fa-check"></i> Aprovar Página
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<form asp-action="Reject" asp-route-id="@Model.Page.Id" method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="reason" class="form-label">Motivo da Rejeição *</label>
|
||||||
|
<textarea class="form-control" id="reason" name="reason" rows="3"
|
||||||
|
placeholder="Explique o motivo da rejeição..." required></textarea>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="selectedIssues" name="issues" value="">
|
||||||
|
<button type="submit" class="btn btn-danger btn-block">
|
||||||
|
<i class="fas fa-times"></i> Rejeitar Página
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
document.querySelector('form[asp-action="Reject"]').addEventListener('submit', function(e) {
|
||||||
|
const checkedIssues = Array.from(document.querySelectorAll('input[name="issues"]:checked'))
|
||||||
|
.map(cb => cb.value);
|
||||||
|
document.getElementById('selectedIssues').value = JSON.stringify(checkedIssues);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
291
src/BCards.Web/Views/Payment/ManageSubscription.cshtml
Normal file
291
src/BCards.Web/Views/Payment/ManageSubscription.cshtml
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
@model BCards.Web.ViewModels.ManageSubscriptionViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Gerenciar Assinatura";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2 class="mb-0">
|
||||||
|
<i class="fas fa-credit-card me-2"></i>
|
||||||
|
Gerenciar Assinatura
|
||||||
|
</h2>
|
||||||
|
<a href="@Url.Action("Dashboard", "Admin")" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i>
|
||||||
|
Voltar ao Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||||
|
@Model.ErrorMessage
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="fas fa-check-circle me-2"></i>
|
||||||
|
@Model.SuccessMessage
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(TempData["Error"]?.ToString()))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||||
|
@TempData["Error"]
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(TempData["Success"]?.ToString()))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="fas fa-check-circle me-2"></i>
|
||||||
|
@TempData["Success"]
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Current Subscription Card -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-star me-2"></i>
|
||||||
|
Assinatura Atual
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (Model.HasActiveSubscription)
|
||||||
|
{
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h4 class="text-primary">Plano @Model.PlanDisplayName</h4>
|
||||||
|
<p class="text-muted mb-2">
|
||||||
|
Status: <span class="badge bg-success">@Model.StatusDisplayName</span>
|
||||||
|
</p>
|
||||||
|
@if (Model.MonthlyAmount.HasValue)
|
||||||
|
{
|
||||||
|
<p class="mb-2">
|
||||||
|
<strong>R$ @Model.MonthlyAmount.Value.ToString("F2")</strong> / mês
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
@if (Model.NextBillingDate.HasValue)
|
||||||
|
{
|
||||||
|
<p class="mb-2">
|
||||||
|
<i class="fas fa-calendar me-2"></i>
|
||||||
|
Próxima cobrança: <strong>@Model.NextBillingDate.Value.ToString("dd/MM/yyyy")</strong>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (Model.WillCancelAtPeriodEnd)
|
||||||
|
{
|
||||||
|
<p class="text-warning mb-2">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||||
|
Assinatura será cancelada em @Model.CurrentPeriodEnd?.ToString("dd/MM/yyyy")
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
@if (!Model.WillCancelAtPeriodEnd)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#cancelModal">
|
||||||
|
<i class="fas fa-times me-1"></i>
|
||||||
|
Cancelar Assinatura
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post" action="@Url.Action("OpenStripePortal")" style="display: inline;">
|
||||||
|
<button type="submit" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>
|
||||||
|
Portal de Pagamento
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="fas fa-info-circle text-muted" style="font-size: 3rem;"></i>
|
||||||
|
<h5 class="mt-3">Nenhuma assinatura ativa</h5>
|
||||||
|
<p class="text-muted">Você está usando o plano gratuito. Faça upgrade para desbloquear mais recursos!</p>
|
||||||
|
<a href="@Url.Action("Pricing", "Home")" class="btn btn-primary">
|
||||||
|
<i class="fas fa-upgrade me-1"></i>
|
||||||
|
Ver Planos
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available Plans -->
|
||||||
|
@if (Model.HasActiveSubscription && (Model.CanUpgrade || Model.CanDowngrade))
|
||||||
|
{
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-exchange-alt me-2"></i>
|
||||||
|
Alterar Plano
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
@foreach (var plan in Model.AvailablePlans.Where(p => !p.IsCurrentPlan))
|
||||||
|
{
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100 @(plan.IsUpgrade ? "border-success" : plan.IsDowngrade ? "border-warning" : "")">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title">@plan.DisplayName</h5>
|
||||||
|
<h4 class="text-primary mb-3">R$ @plan.Price.ToString("F2")</h4>
|
||||||
|
<ul class="list-unstyled text-start mb-3">
|
||||||
|
@foreach (var feature in plan.Features)
|
||||||
|
{
|
||||||
|
<li class="mb-1">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
@feature
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="post" action="@Url.Action("ChangePlan")">
|
||||||
|
<input type="hidden" name="newPlanType" value="@plan.PlanType" />
|
||||||
|
<button type="submit" class="btn @(plan.IsUpgrade ? "btn-success" : "btn-warning") w-100">
|
||||||
|
<i class="fas @(plan.IsUpgrade ? "fa-arrow-up" : "fa-arrow-down") me-1"></i>
|
||||||
|
@(plan.IsUpgrade ? "Fazer Upgrade" : "Fazer Downgrade")
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Payment History -->
|
||||||
|
@if (Model.PaymentHistory.Any())
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-history me-2"></i>
|
||||||
|
Histórico de Pagamentos
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Descrição</th>
|
||||||
|
<th>Valor</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Recibo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var invoice in Model.PaymentHistory.Take(10))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@invoice.Created.ToString("dd/MM/yyyy")</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(invoice.Description))
|
||||||
|
{
|
||||||
|
@invoice.Description
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Assinatura @Model.PlanDisplayName</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>R$ @((invoice.AmountPaid / 100m).ToString("F2"))</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-success">Pago</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(invoice.HostedInvoiceUrl))
|
||||||
|
{
|
||||||
|
<a href="@invoice.HostedInvoiceUrl" target="_blank" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-download me-1"></i>
|
||||||
|
Ver
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cancel Subscription Modal -->
|
||||||
|
<div class="modal fade" id="cancelModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
||||||
|
Cancelar Assinatura
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Tem certeza que deseja cancelar sua assinatura?</p>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
Sua assinatura permanecerá ativa até o final do período atual
|
||||||
|
(@Model.CurrentPeriodEnd?.ToString("dd/MM/yyyy")).
|
||||||
|
Após essa data, você retornará ao plano gratuito.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||||
|
Manter Assinatura
|
||||||
|
</button>
|
||||||
|
@if (Model.StripeSubscription != null)
|
||||||
|
{
|
||||||
|
<form method="post" action="@Url.Action("CancelSubscription")" style="display: inline;">
|
||||||
|
<input type="hidden" name="subscriptionId" value="@Model.StripeSubscription.Id" />
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="fas fa-times me-1"></i>
|
||||||
|
Confirmar Cancelamento
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
// Auto-dismiss alerts after 5 seconds
|
||||||
|
setTimeout(function() {
|
||||||
|
$('.alert:not(.alert-dismissible)').fadeOut();
|
||||||
|
}, 5000);
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
@model bool
|
||||||
|
|
||||||
|
@if (Model)
|
||||||
|
{
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle @(ViewBag.IsHomePage == true ? "text-warning" : "text-warning") fw-bold"
|
||||||
|
href="#" id="moderationDropdown" role="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false"
|
||||||
|
style="@(ViewBag.IsHomePage == true ? "color: #fbbf24 !important;" : "")">
|
||||||
|
<i class="fas fa-shield-alt me-1"></i>Moderação
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="moderationDropdown">
|
||||||
|
<li>
|
||||||
|
<h6 class="dropdown-header">
|
||||||
|
<i class="fas fa-shield-alt me-2"></i>Área de Moderação
|
||||||
|
</h6>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="/moderation/dashboard">
|
||||||
|
<i class="fas fa-tachometer-alt me-2"></i>Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="/moderation/history">
|
||||||
|
<i class="fas fa-history me-2"></i>Histórico
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item text-muted small" href="#" onclick="return false;">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>Você é um moderador
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
@ -31,6 +31,9 @@
|
|||||||
<meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital" />
|
<meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@await RenderSectionAsync("Head", required: false)
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
||||||
@ -39,38 +42,65 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
|
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light fixed-top @(ViewBag.IsHomePage == true ? "bg-home-blue" : "bg-dashboard")" id="mainNavbar">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand fw-bold text-primary" asp-area="" asp-controller="Home" asp-action="Index">
|
<a class="navbar-brand fw-bold @(ViewBag.IsHomePage == true ? "text-white" : "text-primary")"
|
||||||
|
asp-area="" asp-controller="Home" asp-action="Index">
|
||||||
BCards
|
BCards
|
||||||
</a>
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
|
|
||||||
aria-expanded="false" aria-label="Toggle navigation">
|
<button class="navbar-toggler @(ViewBag.IsHomePage == true ? "navbar-dark" : "")"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target=".navbar-collapse"
|
||||||
|
aria-controls="navbarSupportedContent"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
|
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
|
||||||
<ul class="navbar-nav flex-grow-1">
|
<ul class="navbar-nav flex-grow-1">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Início</a>
|
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
|
||||||
|
asp-area="" asp-controller="Home" asp-action="Index">
|
||||||
|
Início
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Pricing">Planos</a>
|
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
|
||||||
|
asp-area="" asp-controller="Home" asp-action="Pricing">
|
||||||
|
Planos
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
@* Menu de Moderação via ViewComponent *@
|
||||||
|
@await Component.InvokeAsync("ModerationMenu")
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Dashboard">Dashboard</a>
|
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
|
||||||
|
asp-area="" asp-controller="Admin" asp-action="Dashboard">
|
||||||
|
<i class="fas fa-user me-1"></i>Dashboard
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Logout">Sair</a>
|
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
|
||||||
|
asp-area="" asp-controller="Auth" asp-action="Logout">
|
||||||
|
<i class="fas fa-sign-out-alt me-1"></i>Sair
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Auth" asp-action="Login">Entrar</a>
|
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
|
||||||
|
asp-area="" asp-controller="Auth" asp-action="Login">
|
||||||
|
<i class="fas fa-sign-in-alt me-1"></i>Entrar
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
@model BCards.Web.Models.PageTheme
|
@model BCards.Web.Models.PageTheme
|
||||||
|
|
||||||
@{
|
@{
|
||||||
var theme = Model ?? new BCards.Web.Models.PageTheme
|
var theme = Model ?? new BCards.Web.Models.PageTheme
|
||||||
{
|
{
|
||||||
PrimaryColor = "#2563eb",
|
Name = "Padrão",
|
||||||
SecondaryColor = "#1d4ed8",
|
PrimaryColor = "#2563eb",
|
||||||
BackgroundColor = "#ffffff",
|
SecondaryColor = "#1d4ed8",
|
||||||
TextColor = "#1f2937"
|
BackgroundColor = "#ffffff",
|
||||||
};
|
TextColor = "#1f2937"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@ -14,28 +16,27 @@
|
|||||||
--secondary-color: @theme.SecondaryColor;
|
--secondary-color: @theme.SecondaryColor;
|
||||||
--background-color: @theme.BackgroundColor;
|
--background-color: @theme.BackgroundColor;
|
||||||
--text-color: @theme.TextColor;
|
--text-color: @theme.TextColor;
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.95);
|
||||||
|
--border-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-page {
|
.user-page {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@if (!string.IsNullOrEmpty(theme.BackgroundImage))
|
min-height: 100vh;
|
||||||
{
|
padding: 2rem 0;
|
||||||
@:background-image: url('@theme.BackgroundImage');
|
|
||||||
@:background-size: cover;
|
|
||||||
@:background-position: center;
|
|
||||||
@:background-attachment: fixed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card {
|
.profile-card {
|
||||||
background-color: rgba(255, 255, 255, 0.95);
|
background-color: var(--card-bg);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid var(--border-color);
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-image {
|
.profile-image {
|
||||||
@ -52,15 +53,20 @@
|
|||||||
height: 120px;
|
height: 120px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 4px solid var(--primary-color);
|
border: 4px solid var(--primary-color);
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: var(--card-bg);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 3rem;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-name {
|
.profile-name {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.5rem;
|
margin: 1rem 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-bio {
|
.profile-bio {
|
||||||
@ -70,74 +76,331 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== LINKS CONTAINER ========== */
|
||||||
.links-container {
|
.links-container {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-button {
|
/* ========== UNIVERSAL LINK STYLE (TODOS OS LINKS IGUAIS) ========== */
|
||||||
|
.universal-link {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.universal-link:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.universal-link-header {
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
color: white !important;
|
color: white !important;
|
||||||
border: none;
|
padding: 0.75rem 1rem;
|
||||||
padding: 1rem 2rem;
|
display: flex;
|
||||||
border-radius: 50px;
|
align-items: center;
|
||||||
text-decoration: none;
|
justify-content: space-between;
|
||||||
display: block;
|
cursor: pointer;
|
||||||
margin-bottom: 1rem;
|
text-decoration: none !important;
|
||||||
text-align: center;
|
transition: background-color 0.3s ease;
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-button:hover {
|
.universal-link-header:hover {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
|
||||||
color: white !important;
|
color: white !important;
|
||||||
text-decoration: none;
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-button:active {
|
.universal-link-content {
|
||||||
transform: translateY(0);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnail para produtos */
|
||||||
|
.link-thumbnail {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ícone para links normais */
|
||||||
|
.link-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-text-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-title {
|
.link-title {
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
/* Truncate long titles */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-description {
|
.link-subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
/* Truncate long subtitles */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seta de expansão */
|
||||||
|
.expand-arrow {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow i {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
opacity: 0.9;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-icon {
|
.expand-arrow.expanded i {
|
||||||
font-size: 1.2rem;
|
transform: rotate(180deg);
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-footer {
|
/* Conteúdo expandido */
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
.universal-link-details {
|
||||||
padding-top: 1rem;
|
padding: 0;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-out, padding 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-footer a {
|
.universal-link-details.show {
|
||||||
|
max-height: 400px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Imagem expandida para produtos */
|
||||||
|
.expanded-image {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 200px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-description {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
|
||||||
|
max-height: 150px; /* Ajuste conforme necessário */
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem; /* Espaço para a scrollbar */
|
||||||
|
|
||||||
|
/* Styling da scrollbar (opcional) */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--primary-color) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-description::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-description::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-description::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-price {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-action {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== FOOTER ========== */
|
||||||
|
.profile-footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-promo {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-promo:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-promo-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-promo-header i {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-promo-header.expanded i {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-promo-content {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-out, margin-top 0.3s ease;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-promo-content.show {
|
||||||
|
max-height: 200px;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-promo-button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white !important;
|
||||||
|
border: none;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-promo-button:hover {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: white !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-credits {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-credits a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-footer a:hover {
|
.footer-credits a:hover {
|
||||||
color: var(--secondary-color);
|
color: var(--secondary-color);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* ========== ANIMATIONS ========== */
|
||||||
|
@@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
max-height: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
max-height: 400px;
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== RESPONSIVE DESIGN ========== */
|
||||||
@@media (max-width: 768px) {
|
@@media (max-width: 768px) {
|
||||||
|
.user-page {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-card {
|
.profile-card {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin: 1rem;
|
margin: 0 1rem;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,28 +414,39 @@
|
|||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-bio {
|
.universal-link-header {
|
||||||
font-size: 1rem;
|
padding: 0.65rem 0.8rem;
|
||||||
}
|
|
||||||
|
|
||||||
.link-button {
|
|
||||||
padding: 0.875rem 1.5rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-title {
|
.link-title {
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-description {
|
.link-subtitle {
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-thumbnail,
|
||||||
|
.link-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow i {
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@media (max-width: 480px) {
|
@@media (max-width: 480px) {
|
||||||
.profile-card {
|
.profile-card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 0.5rem;
|
margin: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-image,
|
.profile-image,
|
||||||
@ -185,31 +459,57 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-button {
|
.universal-link-header {
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.6rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-subtitle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-thumbnail,
|
||||||
|
.link-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-image {
|
||||||
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme adjustments */
|
/* ========== DARK THEME COMPATIBILITY ========== */
|
||||||
@@media (prefers-color-scheme: dark) {
|
.user-page[data-theme="dark"] .profile-card,
|
||||||
.user-page[data-theme="dark"] .profile-card {
|
.user-page[data-theme="dark"] .universal-link,
|
||||||
background-color: rgba(17, 24, 39, 0.95);
|
.user-page[data-theme="dark"] .footer-promo {
|
||||||
color: #f9fafb;
|
background-color: rgba(31, 41, 55, 0.95);
|
||||||
}
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animation for link buttons */
|
.user-page[data-theme="dark"] .universal-link-details,
|
||||||
.link-button::before {
|
.user-page[data-theme="dark"] .footer-promo-content {
|
||||||
content: '';
|
background-color: rgba(31, 41, 55, 0.95);
|
||||||
position: absolute;
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
|
||||||
transition: left 0.5s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-button:hover::before {
|
/* Accessibility */
|
||||||
left: 100%;
|
.universal-link-header:focus,
|
||||||
|
.expand-arrow:focus {
|
||||||
|
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth scroll for mobile */
|
||||||
|
.universal-link-details {
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
@ -41,8 +41,12 @@
|
|||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@await RenderSectionAsync("Head", required: false)
|
||||||
|
|
||||||
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" />
|
||||||
|
|
||||||
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
||||||
|
|
||||||
@await RenderSectionAsync("Styles", required: false)
|
@await RenderSectionAsync("Styles", required: false)
|
||||||
|
|||||||
@ -1,20 +1,45 @@
|
|||||||
@model BCards.Web.Models.UserPage
|
@model BCards.Web.Models.IPageDisplay
|
||||||
@{
|
@{
|
||||||
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
|
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
|
||||||
var category = ViewBag.Category as BCards.Web.Models.Category;
|
var category = ViewBag.Category as BCards.Web.Models.Category;
|
||||||
var isPreview = ViewBag.IsPreview as bool? ?? false;
|
var isPreview = ViewBag.IsPreview as bool? ?? false;
|
||||||
|
var isLivePage = ViewBag.IsLivePage as bool? ?? false;
|
||||||
|
|
||||||
ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}";
|
ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}";
|
||||||
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!isPreview)
|
@section Head {
|
||||||
{
|
@if (isPreview)
|
||||||
@section Styles {
|
{
|
||||||
<style>
|
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
|
||||||
@Html.Raw(await Html.PartialAsync("_ThemeStyles", Model.Theme))
|
|
||||||
</style>
|
|
||||||
}
|
}
|
||||||
|
else if (isLivePage)
|
||||||
|
{
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
@if (!string.IsNullOrEmpty(ViewBag.PageUrl as string))
|
||||||
|
{
|
||||||
|
<link rel="canonical" href="@ViewBag.PageUrl">
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
@{
|
||||||
|
var partialOutput = await Html.PartialAsync("_ThemeStyles", Model.Theme);
|
||||||
|
using (var writer = new System.IO.StringWriter())
|
||||||
|
{
|
||||||
|
partialOutput.WriteTo(writer, HtmlEncoder);
|
||||||
|
@Html.Raw(writer.ToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="user-page min-vh-100 d-flex align-items-center py-4">
|
<div class="user-page min-vh-100 d-flex align-items-center py-4">
|
||||||
@ -29,8 +54,8 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="profile-image-placeholder mb-3 mx-auto d-flex align-items-center justify-content-center">
|
<div class="profile-image-placeholder mb-3 mx-auto">
|
||||||
<i class="fs-1">👤</i>
|
👤
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,7 +67,7 @@
|
|||||||
<p class="profile-bio">@Model.Bio</p>
|
<p class="profile-bio">@Model.Bio</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Links -->
|
<!-- Links Container -->
|
||||||
<div class="links-container">
|
<div class="links-container">
|
||||||
@if (Model.Links?.Any(l => l.IsActive) == true)
|
@if (Model.Links?.Any(l => l.IsActive) == true)
|
||||||
{
|
{
|
||||||
@ -51,24 +76,122 @@
|
|||||||
var link = Model.Links[i];
|
var link = Model.Links[i];
|
||||||
if (link.IsActive)
|
if (link.IsActive)
|
||||||
{
|
{
|
||||||
<a href="@link.Url"
|
var hasExpandableContent = (!string.IsNullOrEmpty(link.Description) ||
|
||||||
target="_blank"
|
(link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductDescription)));
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="link-button"
|
<!-- Universal Link Style (TODOS OS LINKS IGUAIS) -->
|
||||||
data-link-index="@i"
|
<div class="universal-link" data-link-id="@i">
|
||||||
onclick="recordClick('@Model.Id', @i)">
|
<!-- Header clicável (vai para o link) -->
|
||||||
@if (!string.IsNullOrEmpty(link.Icon))
|
<a href="@link.Url"
|
||||||
{
|
class="universal-link-header"
|
||||||
<span class="link-icon me-2">@link.Icon</span>
|
onclick="recordClick('@Model.Id', @i)"
|
||||||
}
|
target="_blank"
|
||||||
<div>
|
rel="noopener noreferrer">
|
||||||
<div class="link-title">@link.Title</div>
|
|
||||||
@if (!string.IsNullOrEmpty(link.Description))
|
<div class="universal-link-content">
|
||||||
|
@if (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductImage))
|
||||||
|
{
|
||||||
|
<!-- Thumbnail para produtos -->
|
||||||
|
<img src="@link.ProductImage"
|
||||||
|
alt="@(link.ProductTitle ?? link.Title)"
|
||||||
|
class="link-thumbnail"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(link.Icon))
|
||||||
|
{
|
||||||
|
<!-- Ícone para links normais -->
|
||||||
|
<div class="link-icon">
|
||||||
|
<i class="@link.Icon"></i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<!-- Ícone padrão se não tiver -->
|
||||||
|
<div class="link-icon">
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="link-text-container">
|
||||||
|
<div class="link-title">
|
||||||
|
@if (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductTitle))
|
||||||
|
{
|
||||||
|
@link.ProductTitle
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@link.Title
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductPrice))
|
||||||
|
{
|
||||||
|
<div class="link-subtitle">@link.ProductPrice</div>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(link.Description) && link.Description.Length > 50)
|
||||||
|
{
|
||||||
|
<div class="link-subtitle">@(link.Description.Substring(0, 50))...</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (hasExpandableContent)
|
||||||
{
|
{
|
||||||
<div class="link-description">@link.Description</div>
|
<!-- Seta de expansão (só aparece se tem conteúdo expandível) -->
|
||||||
|
<button class="expand-arrow"
|
||||||
|
type="button"
|
||||||
|
onclick="event.preventDefault(); event.stopPropagation(); toggleLinkDetails(@i)">
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</a>
|
||||||
</a>
|
|
||||||
|
@if (hasExpandableContent)
|
||||||
|
{
|
||||||
|
<!-- Conteúdo expandível -->
|
||||||
|
<div class="universal-link-details" id="details-@i">
|
||||||
|
@if (link.Type == BCards.Web.Models.LinkType.Product)
|
||||||
|
{
|
||||||
|
<!-- Conteúdo expandido para produtos -->
|
||||||
|
@if (!string.IsNullOrEmpty(link.ProductImage))
|
||||||
|
{
|
||||||
|
<img src="@link.ProductImage"
|
||||||
|
alt="@(link.ProductTitle ?? link.Title)"
|
||||||
|
class="expanded-image"
|
||||||
|
loading="lazy">
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(link.ProductPrice))
|
||||||
|
{
|
||||||
|
<div class="expanded-price">@link.ProductPrice</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(link.ProductDescription))
|
||||||
|
{
|
||||||
|
<div class="expanded-description">
|
||||||
|
@link.ProductDescription
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<!-- Conteúdo expandido para links normais -->
|
||||||
|
@if (!string.IsNullOrEmpty(link.Description))
|
||||||
|
{
|
||||||
|
<div class="expanded-description">
|
||||||
|
@link.Description
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="expanded-action">
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
Clique no título acima para abrir
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,10 +204,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="profile-footer mt-4 pt-3 border-top">
|
<div class="profile-footer">
|
||||||
<small class="text-muted">
|
|
||||||
Criado com <a href="@Url.Action("Index", "Home")" class="text-decoration-none">BCards</a>
|
<!-- Promoção BCards -->
|
||||||
</small>
|
<div class="footer-promo" onclick="togglePromo(this)">
|
||||||
|
<div class="footer-promo-header">
|
||||||
|
<span>💡 Gostou desta página?</span>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<div class="footer-promo-content">
|
||||||
|
Crie a sua própria página personalizada com <strong>BCards</strong>!
|
||||||
|
É rápido, fácil e profissional. Compartilhe todos os seus links em um só lugar.
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="@Url.Action("Index", "Home")" class="footer-promo-button">
|
||||||
|
<i class="fas fa-rocket"></i>
|
||||||
|
Criar Minha Página
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-credits">
|
||||||
|
Criado com <a href="@Url.Action("Index", "Home")">BCards</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -101,8 +243,8 @@
|
|||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script>
|
<script>
|
||||||
|
// Função original de rastreamento de cliques
|
||||||
function recordClick(pageId, linkIndex) {
|
function recordClick(pageId, linkIndex) {
|
||||||
// Record click asynchronously
|
|
||||||
fetch('/click/' + pageId, {
|
fetch('/click/' + pageId, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -113,5 +255,82 @@
|
|||||||
console.log('Error recording click:', error);
|
console.log('Error recording click:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle link details (função universal para todos os links)
|
||||||
|
function toggleLinkDetails(linkIndex) {
|
||||||
|
const currentDetails = document.getElementById('details-' + linkIndex);
|
||||||
|
const currentArrow = document.querySelector('[data-link-id="' + linkIndex + '"] .expand-arrow');
|
||||||
|
|
||||||
|
if (!currentDetails || !currentArrow) return;
|
||||||
|
|
||||||
|
const isCurrentlyExpanded = currentDetails.classList.contains('show');
|
||||||
|
|
||||||
|
// Fechar todos os outros links primeiro (auto-close)
|
||||||
|
const allDetails = document.querySelectorAll('.universal-link-details');
|
||||||
|
const allArrows = document.querySelectorAll('.expand-arrow');
|
||||||
|
|
||||||
|
allDetails.forEach(details => {
|
||||||
|
details.classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
allArrows.forEach(arrow => {
|
||||||
|
arrow.classList.remove('expanded');
|
||||||
|
const icon = arrow.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
icon.style.transform = 'rotate(0deg)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se não estava expandido, expandir este
|
||||||
|
if (!isCurrentlyExpanded) {
|
||||||
|
currentDetails.classList.add('show');
|
||||||
|
currentArrow.classList.add('expanded');
|
||||||
|
const icon = currentArrow.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
icon.style.transform = 'rotate(180deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle footer promo
|
||||||
|
function togglePromo(element) {
|
||||||
|
const content = element.querySelector('.footer-promo-content');
|
||||||
|
const arrow = element.querySelector('.footer-promo-header i');
|
||||||
|
|
||||||
|
if (content.classList.contains('show')) {
|
||||||
|
content.classList.remove('show');
|
||||||
|
arrow.style.transform = 'rotate(0deg)';
|
||||||
|
element.querySelector('.footer-promo-header').classList.remove('expanded');
|
||||||
|
} else {
|
||||||
|
content.classList.add('show');
|
||||||
|
arrow.style.transform = 'rotate(180deg)';
|
||||||
|
element.querySelector('.footer-promo-header').classList.add('expanded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Garantir que todos os accordions comecem fechados
|
||||||
|
const allDetails = document.querySelectorAll('.universal-link-details, .footer-promo-content');
|
||||||
|
allDetails.forEach(detail => {
|
||||||
|
detail.classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
const allArrows = document.querySelectorAll('.expand-arrow i, .footer-promo-header i');
|
||||||
|
allArrows.forEach(arrow => {
|
||||||
|
arrow.style.transform = 'rotate(0deg)';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adicionar eventos de teclado para acessibilidade
|
||||||
|
const expandButtons = document.querySelectorAll('.expand-arrow');
|
||||||
|
expandButtons.forEach(button => {
|
||||||
|
button.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
button.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
30
src/BCards.Web/Views/UserPage/PageRejected.cshtml
Normal file
30
src/BCards.Web/Views/UserPage/PageRejected.cshtml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Página Rejeitada";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-times-circle text-danger fa-4x"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="h4 mb-3">Página Rejeitada</h2>
|
||||||
|
<p class="lead mb-4">
|
||||||
|
Esta página foi rejeitada na moderação e não está disponível publicamente.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
O conteúdo não atende aos nossos termos de uso ou padrões de qualidade.
|
||||||
|
<br>
|
||||||
|
<strong>Proprietário:</strong> Verifique seu painel para mais detalhes
|
||||||
|
</p>
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<i class="fas fa-home"></i> Voltar ao Início
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
34
src/BCards.Web/Views/UserPage/PendingModeration.cshtml
Normal file
34
src/BCards.Web/Views/UserPage/PendingModeration.cshtml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Página em Análise";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-hourglass-half text-warning fa-4x"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="h4 mb-3">Página em Análise</h2>
|
||||||
|
<p class="lead mb-4">
|
||||||
|
Esta página está sendo analisada por nossa equipe de moderação.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Estamos verificando se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.
|
||||||
|
<br>
|
||||||
|
<strong>Tempo estimado:</strong> 3-7 dias úteis
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<strong>Proprietário da página:</strong> Verifique seu email para o link de preview
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<i class="fas fa-home"></i> Voltar ao Início
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
38
src/BCards.Web/Views/UserPage/PreviewExpired.cshtml
Normal file
38
src/BCards.Web/Views/UserPage/PreviewExpired.cshtml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Preview Expirado";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-clock text-warning fa-4x"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="h4 mb-3">Preview Expirado</h2>
|
||||||
|
<p class="lead mb-4">
|
||||||
|
O link de preview que você acessou não é mais válido.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Isso pode acontecer se:
|
||||||
|
<br>
|
||||||
|
• O link expirou (30 dias)
|
||||||
|
<br>
|
||||||
|
• Excedeu o limite de visualizações (50)
|
||||||
|
<br>
|
||||||
|
• A página já foi processada
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<strong>Proprietário:</strong> Acesse seu painel para ver o status atual
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<i class="fas fa-home"></i> Voltar ao Início
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
13
src/BCards.Web/appsettings.Development.json
Normal file
13
src/BCards.Web/appsettings.Development.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DetailedErrors": true,
|
||||||
|
"MongoDb": {
|
||||||
|
"ConnectionString": "mongodb://localhost:27017",
|
||||||
|
"DatabaseName": "BCardsDB_Dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/BCards.Web/appsettings.Release.json
Normal file
141
src/BCards.Web/appsettings.Release.json
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning",
|
||||||
|
"BCards": "Information"
|
||||||
|
},
|
||||||
|
"Console": {
|
||||||
|
"IncludeScopes": false,
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"File": {
|
||||||
|
"Path": "/app/logs/bcards-{Date}.log",
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"MongoDb": {
|
||||||
|
"ConnectionString": "mongodb://192.168.0.100:27017/BCardsDB",
|
||||||
|
"DatabaseName": "BCardsDB",
|
||||||
|
"MaxConnectionPoolSize": 100,
|
||||||
|
"ConnectTimeout": "30s",
|
||||||
|
"ServerSelectionTimeout": "30s",
|
||||||
|
"SocketTimeout": "30s"
|
||||||
|
},
|
||||||
|
"Stripe": {
|
||||||
|
"PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS",
|
||||||
|
"SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO",
|
||||||
|
"WebhookSecret": "whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543",
|
||||||
|
"ApiVersion": "2023-10-16"
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Google": {
|
||||||
|
"ClientId": "472850008574-nmeepbdt4hunsk5c8krpbdmd3olc4jv6.apps.googleusercontent.com",
|
||||||
|
"ClientSecret": "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"
|
||||||
|
},
|
||||||
|
"Microsoft": {
|
||||||
|
"ClientId": "b411606a-e574-4f59-b7cd-10dd941b9fa3",
|
||||||
|
"ClientSecret": "T0.8Q~an.51iW1H0DVjL2i1bmSK_qTgVQOuEmapK"
|
||||||
|
},
|
||||||
|
"CookieSettings": {
|
||||||
|
"SecurePolicy": "Always",
|
||||||
|
"SameSiteMode": "Lax",
|
||||||
|
"HttpOnly": true,
|
||||||
|
"ExpireTimeSpan": "7.00:00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Plans": {
|
||||||
|
"Basic": {
|
||||||
|
"PriceId": "price_1RjUskBMIadsOxJVgLwlVo1y",
|
||||||
|
"Price": 9.90,
|
||||||
|
"MaxLinks": 5,
|
||||||
|
"Features": [ "basic_themes", "simple_analytics" ]
|
||||||
|
},
|
||||||
|
"Professional": {
|
||||||
|
"PriceId": "price_1RjUv9BMIadsOxJVORqlM4E9",
|
||||||
|
"Price": 24.90,
|
||||||
|
"MaxLinks": 15,
|
||||||
|
"Features": [ "all_themes", "advanced_analytics", "custom_domain" ]
|
||||||
|
},
|
||||||
|
"Premium": {
|
||||||
|
"PriceId": "price_1RjUw0BMIadsOxJVmdouNV1g",
|
||||||
|
"Price": 29.90,
|
||||||
|
"MaxLinks": -1,
|
||||||
|
"Features": [ "custom_themes", "full_analytics", "multiple_domains", "priority_support" ]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "ricardo.carneiro@jobmaker.com.br",
|
||||||
|
"ModeratorEmails": [
|
||||||
|
"rrcgoncalves@gmail.com",
|
||||||
|
"rirocarneiro@gmail.com"
|
||||||
|
],
|
||||||
|
"PreviewTokenExpirationHours": 4,
|
||||||
|
"AutoRejectDays": 30
|
||||||
|
},
|
||||||
|
"SendGrid": {
|
||||||
|
"ApiKey": "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",
|
||||||
|
"FromEmail": "ricardo.carneiro@jobmaker.com.br",
|
||||||
|
"FromName": "BCards - Staging",
|
||||||
|
"ReplyToEmail": "ricardo.carneiro@jobmaker.com.br"
|
||||||
|
},
|
||||||
|
"BaseUrl": "http://192.168.0.100:8090",
|
||||||
|
"Environment": {
|
||||||
|
"Name": "Release",
|
||||||
|
"IsStagingEnvironment": true,
|
||||||
|
"AllowTestData": true,
|
||||||
|
"EnableDetailedErrors": false
|
||||||
|
},
|
||||||
|
"Performance": {
|
||||||
|
"EnableCaching": true,
|
||||||
|
"CacheExpirationMinutes": 30,
|
||||||
|
"EnableCompression": true,
|
||||||
|
"EnableResponseCaching": true
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"EnableHttpsRedirection": false,
|
||||||
|
"EnableHsts": false,
|
||||||
|
"RequireHttpsMetadata": false,
|
||||||
|
"CorsOrigins": [
|
||||||
|
"http://192.168.0.100:8090",
|
||||||
|
"http://localhost:8090"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HealthChecks": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Endpoints": {
|
||||||
|
"Health": "/health",
|
||||||
|
"Ready": "/ready",
|
||||||
|
"Live": "/live"
|
||||||
|
},
|
||||||
|
"MongoDb": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Timeout": "10s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Monitoring": {
|
||||||
|
"EnableMetrics": true,
|
||||||
|
"MetricsEndpoint": "/metrics",
|
||||||
|
"EnableTracing": false
|
||||||
|
},
|
||||||
|
"Features": {
|
||||||
|
"EnablePreviewMode": true,
|
||||||
|
"EnableModerationWorkflow": true,
|
||||||
|
"EnableAnalytics": true,
|
||||||
|
"EnableFileUploads": true,
|
||||||
|
"MaxFileUploadSize": "5MB"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,14 +11,14 @@
|
|||||||
"DatabaseName": "BCardsDB"
|
"DatabaseName": "BCardsDB"
|
||||||
},
|
},
|
||||||
"Stripe": {
|
"Stripe": {
|
||||||
"PublishableKey": "pk_test_your_publishable_key_here",
|
"PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS",
|
||||||
"SecretKey": "sk_test_your_secret_key_here",
|
"SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO",
|
||||||
"WebhookSecret": "whsec_your_webhook_secret_here"
|
"WebhookSecret": "whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543"
|
||||||
},
|
},
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Google": {
|
"Google": {
|
||||||
"ClientId": "your_google_client_id",
|
"ClientId": "472850008574-nmeepbdt4hunsk5c8krpbdmd3olc4jv6.apps.googleusercontent.com",
|
||||||
"ClientSecret": "your_google_client_secret"
|
"ClientSecret": "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"
|
||||||
},
|
},
|
||||||
"Microsoft": {
|
"Microsoft": {
|
||||||
"ClientId": "b411606a-e574-4f59-b7cd-10dd941b9fa3",
|
"ClientId": "b411606a-e574-4f59-b7cd-10dd941b9fa3",
|
||||||
@ -27,22 +27,42 @@
|
|||||||
},
|
},
|
||||||
"Plans": {
|
"Plans": {
|
||||||
"Basic": {
|
"Basic": {
|
||||||
"PriceId": "price_basic_monthly",
|
"PriceId": "price_1RjUskBMIadsOxJVgLwlVo1y",
|
||||||
"Price": 9.90,
|
"Price": 9.90,
|
||||||
"MaxLinks": 5,
|
"MaxLinks": 5,
|
||||||
"Features": ["basic_themes", "simple_analytics"]
|
"Features": [ "basic_themes", "simple_analytics" ]
|
||||||
},
|
},
|
||||||
"Professional": {
|
"Professional": {
|
||||||
"PriceId": "price_professional_monthly",
|
"PriceId": "price_1RjUv9BMIadsOxJVORqlM4E9",
|
||||||
"Price": 24.90,
|
"Price": 24.90,
|
||||||
"MaxLinks": 15,
|
"MaxLinks": 15,
|
||||||
"Features": ["all_themes", "advanced_analytics", "custom_domain"]
|
"Features": [ "all_themes", "advanced_analytics", "custom_domain" ]
|
||||||
},
|
},
|
||||||
"Premium": {
|
"Premium": {
|
||||||
"PriceId": "price_premium_monthly",
|
"PriceId": "price_1RjUw0BMIadsOxJVmdouNV1g",
|
||||||
"Price": 29.90,
|
"Price": 29.90,
|
||||||
"MaxLinks": -1,
|
"MaxLinks": -1,
|
||||||
"Features": ["custom_themes", "full_analytics", "multiple_domains", "priority_support"]
|
"Features": [ "custom_themes", "full_analytics", "multiple_domains", "priority_support" ]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"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": "ricardo.carneiro@jobmaker.com.br",
|
||||||
|
"ModeratorEmails": [
|
||||||
|
"rrcgoncalves@gmail.com",
|
||||||
|
"rirocarneiro@gmail.com"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SendGrid": {
|
||||||
|
"ApiKey": "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",
|
||||||
|
"FromEmail": "ricardo.carneiro@jobmaker.com.br",
|
||||||
|
"FromName": "Ricardo Carneiro"
|
||||||
|
},
|
||||||
|
"BaseUrl": "https://bcards.site"
|
||||||
}
|
}
|
||||||
@ -19,6 +19,7 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin-bottom: 60px;
|
margin-bottom: 60px;
|
||||||
|
padding-top: 70px; /* Altura da navbar fixa */
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
@ -217,3 +218,56 @@ body {
|
|||||||
border: 2px solid;
|
border: 2px solid;
|
||||||
border-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%) 1;
|
border-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%) 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Menu Home (mesmo gradiente do fundo principal) */
|
||||||
|
.bg-home-blue {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
||||||
|
/* Removida linha divisória na home */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Dashboard (mais neutro) */
|
||||||
|
.bg-dashboard {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
body {
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-home-blue .navbar-nav {
|
||||||
|
background: rgba(102, 126, 234, 0.95);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger menu para home */
|
||||||
|
.navbar-dark .navbar-toggler-icon {
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.85%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effects para home menu */
|
||||||
|
.bg-home-blue .nav-link:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effects para dashboard menu */
|
||||||
|
.bg-dashboard .nav-link:hover {
|
||||||
|
background-color: rgba(37, 99, 235, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Garantir contraste adequado no menu home */
|
||||||
|
.bg-home-blue .navbar-brand,
|
||||||
|
.bg-home-blue .nav-link {
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
@ -21,10 +21,23 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Moq" Version="4.20.70" />
|
<PackageReference Include="Moq" Version="4.20.70" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
|
||||||
|
<PackageReference Include="PuppeteerSharp" Version="15.0.1" />
|
||||||
|
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
|
||||||
|
<PackageReference Include="Stripe.net" Version="43.22.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\BCards.Web\BCards.Web.csproj" />
|
<ProjectReference Include="..\..\src\BCards.Web\BCards.Web.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.Testing.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
95
tests/BCards.Tests/Fixtures/BCardsWebApplicationFactory.cs
Normal file
95
tests/BCards.Tests/Fixtures/BCardsWebApplicationFactory.cs
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using BCards.Web.Configuration;
|
||||||
|
|
||||||
|
namespace BCards.Tests.Fixtures;
|
||||||
|
|
||||||
|
public class BCardsWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
|
||||||
|
{
|
||||||
|
public IMongoDatabase TestDatabase { get; private set; } = null!;
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureAppConfiguration((context, config) =>
|
||||||
|
{
|
||||||
|
// Remove existing configuration and add test configuration
|
||||||
|
config.Sources.Clear();
|
||||||
|
config.AddJsonFile("appsettings.Testing.json");
|
||||||
|
config.AddEnvironmentVariables();
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Remove the existing MongoDB registration
|
||||||
|
var mongoDescriptor = services.SingleOrDefault(
|
||||||
|
d => d.ServiceType == typeof(IMongoDatabase));
|
||||||
|
if (mongoDescriptor != null)
|
||||||
|
{
|
||||||
|
services.Remove(mongoDescriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mongoClientDescriptor = services.SingleOrDefault(
|
||||||
|
d => d.ServiceType == typeof(IMongoClient));
|
||||||
|
if (mongoClientDescriptor != null)
|
||||||
|
{
|
||||||
|
services.Remove(mongoClientDescriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add test MongoDB client and database
|
||||||
|
services.AddSingleton<IMongoClient>(serviceProvider =>
|
||||||
|
{
|
||||||
|
var testConnectionString = "mongodb://localhost:27017";
|
||||||
|
return new MongoClient(testConnectionString);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddScoped(serviceProvider =>
|
||||||
|
{
|
||||||
|
var client = serviceProvider.GetRequiredService<IMongoClient>();
|
||||||
|
var databaseName = $"bcards_test_{Guid.NewGuid():N}";
|
||||||
|
TestDatabase = client.GetDatabase(databaseName);
|
||||||
|
return TestDatabase;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure test Stripe settings
|
||||||
|
services.Configure<StripeSettings>(options =>
|
||||||
|
{
|
||||||
|
options.PublishableKey = "pk_test_fake_key_for_testing";
|
||||||
|
options.SecretKey = "sk_test_fake_key_for_testing";
|
||||||
|
options.WebhookSecret = "whsec_fake_webhook_secret_for_testing";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.UseEnvironment("Testing");
|
||||||
|
|
||||||
|
// Suppress logs during testing to reduce noise
|
||||||
|
builder.ConfigureLogging(logging =>
|
||||||
|
{
|
||||||
|
logging.ClearProviders();
|
||||||
|
logging.AddConsole();
|
||||||
|
logging.SetMinimumLevel(LogLevel.Warning);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing && TestDatabase != null)
|
||||||
|
{
|
||||||
|
// Clean up test database
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = TestDatabase.Client;
|
||||||
|
client.DropDatabase(TestDatabase.DatabaseNamespace.DatabaseName);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
tests/BCards.Tests/Fixtures/DatabaseFixture.cs
Normal file
142
tests/BCards.Tests/Fixtures/DatabaseFixture.cs
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
|
|
||||||
|
namespace BCards.Tests.Fixtures;
|
||||||
|
|
||||||
|
public class DatabaseFixture : IDisposable
|
||||||
|
{
|
||||||
|
public IMongoDatabase Database { get; }
|
||||||
|
public IUserRepository UserRepository { get; }
|
||||||
|
public IUserPageRepository UserPageRepository { get; }
|
||||||
|
public ICategoryRepository CategoryRepository { get; }
|
||||||
|
|
||||||
|
public DatabaseFixture(IMongoDatabase database)
|
||||||
|
{
|
||||||
|
Database = database;
|
||||||
|
UserRepository = new UserRepository(database);
|
||||||
|
UserPageRepository = new UserPageRepository(database);
|
||||||
|
CategoryRepository = new CategoryRepository(database);
|
||||||
|
|
||||||
|
InitializeTestData().Wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeTestData()
|
||||||
|
{
|
||||||
|
// Clear any existing data
|
||||||
|
await Database.DropCollectionAsync("users");
|
||||||
|
await Database.DropCollectionAsync("userpages");
|
||||||
|
await Database.DropCollectionAsync("categories");
|
||||||
|
|
||||||
|
// Initialize test categories
|
||||||
|
var categories = new List<Category>
|
||||||
|
{
|
||||||
|
new Category { Id = "tech", Name = "Tecnologia", Description = "Tecnologia e inovação" },
|
||||||
|
new Category { Id = "business", Name = "Negócios", Description = "Empresas e negócios" },
|
||||||
|
new Category { Id = "personal", Name = "Pessoal", Description = "Páginas pessoais" }
|
||||||
|
};
|
||||||
|
|
||||||
|
await CategoryRepository.CreateManyAsync(categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User> CreateTestUser(PlanType planType = PlanType.Trial, string? email = null)
|
||||||
|
{
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
Email = email ?? $"test-{Guid.NewGuid():N}@example.com",
|
||||||
|
Name = "Test User",
|
||||||
|
CurrentPlan = planType.ToString(),
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await UserRepository.CreateAsync(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserPage> CreateTestUserPage(string userId, string category = "tech", int linkCount = 1, int productLinkCount = 0)
|
||||||
|
{
|
||||||
|
var userPage = new UserPage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
UserId = userId,
|
||||||
|
DisplayName = "Test Page",
|
||||||
|
Category = category,
|
||||||
|
Slug = $"test-page-{Guid.NewGuid():N}",
|
||||||
|
Bio = "Test page description",
|
||||||
|
Status = BCards.Web.ViewModels.PageStatus.Active,
|
||||||
|
Links = new List<LinkItem>(),
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add normal links
|
||||||
|
for (int i = 0; i < linkCount; i++)
|
||||||
|
{
|
||||||
|
userPage.Links.Add(new LinkItem
|
||||||
|
{
|
||||||
|
Title = $"Test Link {i + 1}",
|
||||||
|
Url = $"https://example.com/link{i + 1}",
|
||||||
|
Description = $"Test link {i + 1} description",
|
||||||
|
Icon = "fas fa-link",
|
||||||
|
IsActive = true,
|
||||||
|
Order = i,
|
||||||
|
Type = LinkType.Normal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add product links
|
||||||
|
for (int i = 0; i < productLinkCount; i++)
|
||||||
|
{
|
||||||
|
userPage.Links.Add(new LinkItem
|
||||||
|
{
|
||||||
|
Title = $"Test Product {i + 1}",
|
||||||
|
Url = $"https://example.com/product{i + 1}",
|
||||||
|
Description = $"Test product {i + 1} description",
|
||||||
|
Icon = "fas fa-shopping-cart",
|
||||||
|
IsActive = true,
|
||||||
|
Order = linkCount + i,
|
||||||
|
Type = LinkType.Product,
|
||||||
|
ProductTitle = $"Product {i + 1}",
|
||||||
|
ProductPrice = "R$ 99,90",
|
||||||
|
ProductDescription = $"Amazing product {i + 1}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await UserPageRepository.CreateAsync(userPage);
|
||||||
|
return userPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CleanDatabase()
|
||||||
|
{
|
||||||
|
var collections = new[] { "users", "userpages", "categories", "livepages" };
|
||||||
|
|
||||||
|
foreach (var collection in collections)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Database.DropCollectionAsync(collection);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Ignore errors when collection doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await InitializeTestData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Database.Client.DropDatabase(Database.DatabaseNamespace.DatabaseName);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
tests/BCards.Tests/appsettings.Testing.json
Normal file
39
tests/BCards.Tests/appsettings.Testing.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "mongodb://localhost:27017/bcards_test"
|
||||||
|
},
|
||||||
|
"MongoDb": {
|
||||||
|
"ConnectionString": "mongodb://localhost:27017",
|
||||||
|
"DatabaseName": "bcards_test"
|
||||||
|
},
|
||||||
|
"Stripe": {
|
||||||
|
"PublishableKey": "pk_test_fake_key_for_testing",
|
||||||
|
"SecretKey": "sk_test_fake_key_for_testing",
|
||||||
|
"WebhookSecret": "whsec_fake_webhook_secret_for_testing"
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Google": {
|
||||||
|
"ClientId": "fake-google-client-id",
|
||||||
|
"ClientSecret": "fake-google-client-secret"
|
||||||
|
},
|
||||||
|
"Microsoft": {
|
||||||
|
"ClientId": "fake-microsoft-client-id",
|
||||||
|
"ClientSecret": "fake-microsoft-client-secret"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SendGrid": {
|
||||||
|
"ApiKey": "fake-sendgrid-api-key"
|
||||||
|
},
|
||||||
|
"Moderation": {
|
||||||
|
"RequireApproval": false,
|
||||||
|
"AuthKey": "test-moderation-key",
|
||||||
|
"MaxPendingPages": 1000
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"BCards": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user