This commit is contained in:
2025-06-24 11:25:10 +02:00
parent 6412d9f01a
commit 996adb4bce
15 changed files with 1695 additions and 4 deletions

246
PRICING_CONFIGURATION.md Normal file
View File

@@ -0,0 +1,246 @@
# Pricing Configuration Feature
This document describes the new configurable pricing feature that allows MASTER instances to manage pricing plans through the admin interface.
## Overview
The pricing configuration feature allows administrators on MASTER instances to:
- Create, edit, and delete pricing plans
- Configure plan features, prices, and settings
- Set resource quotas for rooms, conversations, storage, and users
- Mark plans as "Most Popular" or "Custom"
- Control plan visibility and ordering
- Update pricing without code changes
## Features
### Pricing Plan Management
- **Plan Name**: Display name for the pricing plan
- **Description**: Optional description shown below the plan name
- **Pricing**: Monthly and annual prices (annual prices are typically 20% lower)
- **Features**: Dynamic list of features with checkmarks
- **Button Configuration**: Customizable button text and URL
- **Plan Types**: Regular plans with prices or custom plans
- **Popular Plans**: Mark one plan as "Most Popular" with special styling
- **Active/Inactive**: Toggle plan visibility
- **Ordering**: Control the display order of plans
### Resource Quotas
- **Room Quota**: Maximum number of rooms allowed (0 = unlimited)
- **Conversation Quota**: Maximum number of conversations allowed (0 = unlimited)
- **Storage Quota**: Maximum storage in gigabytes (0 = unlimited)
- **Manager Quota**: Maximum number of manager users allowed (0 = unlimited)
- **Admin Quota**: Maximum number of admin users allowed (0 = unlimited)
### Admin Interface
- **Pricing Tab**: New tab in admin settings (MASTER instances only)
- **Add/Edit Modals**: User-friendly forms for plan management
- **Real-time Updates**: Changes are reflected immediately
- **Feature Management**: Add/remove features dynamically
- **Quota Configuration**: Set resource limits for each plan
- **Status Toggles**: Quick switches for plan properties
## Setup Instructions
### 1. Database Migration
Run the migrations to create the pricing_plans table and add quota fields:
```bash
# Apply the migrations
alembic upgrade head
```
### 2. Initialize Default Plans (Optional)
Run the initialization script to create default pricing plans:
```bash
# Set MASTER environment variable
export MASTER=true
# Run the initialization script
python init_pricing_plans.py
```
This will create four default plans with quotas:
- **Starter**: 5 rooms, 10 conversations, 10GB storage, 10 managers, 1 admin
- **Professional**: 25 rooms, 50 conversations, 100GB storage, 50 managers, 3 admins
- **Enterprise**: 100 rooms, 200 conversations, 500GB storage, 200 managers, 10 admins
- **Custom**: Unlimited everything
### 3. Access Admin Interface
1. Log in as an admin user on a MASTER instance
2. Go to Settings
3. Click on the "Pricing" tab
4. Configure your pricing plans
## Usage
### Creating a New Plan
1. Click "Add New Plan" in the pricing tab
2. Fill in the plan details:
- **Name**: Plan display name
- **Description**: Optional description
- **Monthly Price**: Price per month
- **Annual Price**: Price per month when billed annually
- **Quotas**: Set resource limits (0 = unlimited)
- **Features**: Add features using the "Add Feature" button
- **Button Text**: Text for the call-to-action button
- **Button URL**: URL the button should link to
- **Options**: Check "Most Popular", "Custom Plan", or "Active" as needed
3. Click "Create Plan"
### Editing a Plan
1. Click the "Edit" button on any plan card
2. Modify the plan details in the modal
3. Click "Update Plan"
### Managing Plan Status
- **Active/Inactive**: Use the toggle switch in the plan header
- **Most Popular**: Check the "Most Popular" checkbox (only one plan can be popular)
- **Custom Plan**: Check "Custom Plan" for plans without fixed pricing
### Deleting a Plan
1. Click the "Delete" button on a plan card
2. Confirm the deletion in the modal
## Technical Details
### Database Schema
The `pricing_plans` table includes:
- `id`: Primary key
- `name`: Plan name (required)
- `description`: Optional description
- `monthly_price`: Monthly price (float)
- `annual_price`: Annual price (float)
- `features`: JSON array of feature strings
- `button_text`: Button display text
- `button_url`: Button link URL
- `is_popular`: Boolean for "Most Popular" styling
- `is_custom`: Boolean for custom plans
- `is_active`: Boolean for plan visibility
- `order_index`: Integer for display ordering
- `room_quota`: Maximum rooms (0 = unlimited)
- `conversation_quota`: Maximum conversations (0 = unlimited)
- `storage_quota_gb`: Maximum storage in GB (0 = unlimited)
- `manager_quota`: Maximum managers (0 = unlimited)
- `admin_quota`: Maximum admins (0 = unlimited)
- `created_by`: Foreign key to user who created the plan
- `created_at`/`updated_at`: Timestamps
### API Endpoints
- `POST /api/admin/pricing-plans` - Create new plan
- `GET /api/admin/pricing-plans/<id>` - Get plan details
- `PUT /api/admin/pricing-plans/<id>` - Update plan
- `DELETE /api/admin/pricing-plans/<id>` - Delete plan
- `PATCH /api/admin/pricing-plans/<id>/status` - Update plan status
### Template Integration
The pricing page automatically uses configured plans:
- Falls back to hardcoded plans if no plans are configured
- Supports dynamic feature lists
- Handles custom plans without pricing
- Shows/hides billing toggle based on plan types
- Displays quota information in plan cards
### Quota Enforcement
The PricingPlan model includes utility methods for quota checking:
- `check_quota(quota_type, current_count)`: Returns True if quota allows the operation
- `get_quota_remaining(quota_type, current_count)`: Returns remaining quota
- `format_quota_display(quota_type)`: Formats quota for display
- `get_storage_quota_bytes()`: Converts GB to bytes for storage calculations
Example usage in your application:
```python
# Check if user can create a new room
plan = PricingPlan.query.get(user_plan_id)
current_rooms = Room.query.filter_by(created_by=user.id).count()
if plan.check_quota('room_quota', current_rooms):
# Allow room creation
pass
else:
# Show upgrade message
pass
```
## Security
- Only admin users can access pricing configuration
- Only MASTER instances can configure pricing
- All API endpoints require authentication and admin privileges
- CSRF protection is enabled for all forms
## Customization
### Styling
The pricing plans use the existing CSS variables:
- `--primary-color`: Main brand color
- `--secondary-color`: Secondary brand color
- `--shadow-color`: Card shadows
### Button URLs
Configure button URLs to point to:
- Contact forms
- Payment processors
- Sales pages
- Custom landing pages
### Features
Features can include:
- Storage limits
- User limits
- Feature availability
- Support levels
- Integration options
### Quota Integration
To integrate quotas into your application:
1. **User Plan Assignment**: Associate users with pricing plans
2. **Quota Checking**: Use the `check_quota()` method before operations
3. **Upgrade Prompts**: Show upgrade messages when quotas are exceeded
4. **Usage Tracking**: Track current usage for quota calculations
## Troubleshooting
### Common Issues
1. **Pricing tab not visible**
- Ensure you're on a MASTER instance (`MASTER=true`)
- Ensure you're logged in as an admin user
2. **Plans not showing on pricing page**
- Check that plans are marked as "Active"
- Verify the database migration was applied
- Check for JavaScript errors in browser console
3. **Features not saving**
- Ensure at least one feature is provided
- Check that feature text is not empty
4. **Quota fields not working**
- Verify the quota migration was applied
- Check that quota values are integers
- Ensure quota fields are included in form submissions
5. **API errors**
- Verify CSRF token is included in requests
- Check that all required fields are provided
- Ensure proper JSON formatting for features
### Debugging
- Check browser console for JavaScript errors
- Review server logs for API errors
- Verify database connectivity
- Test with default plans first
## Future Enhancements
Potential future improvements:
- Plan categories/tiers
- Regional pricing
- Currency support
- Promotional pricing
- Plan comparison features
- Analytics and usage tracking
- Automatic quota enforcement middleware
- Usage dashboard for quota monitoring

Binary file not shown.

170
init_pricing_plans.py Normal file
View File

@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
Script to initialize default pricing plans in the database.
This should be run on a MASTER instance only.
"""
import os
import sys
from app import app, db
from models import PricingPlan, User
def init_pricing_plans():
"""Initialize default pricing plans"""
# Check if this is a MASTER instance
if os.environ.get('MASTER', 'false').lower() != 'true':
print("Error: This script should only be run on a MASTER instance.")
print("Set MASTER=true environment variable to run this script.")
sys.exit(1)
with app.app_context():
# Check if pricing plans already exist
existing_plans = PricingPlan.query.count()
if existing_plans > 0:
print(f"Found {existing_plans} existing pricing plans. Skipping initialization.")
return
# Get the first admin user
admin_user = User.query.filter_by(is_admin=True).first()
if not admin_user:
print("Error: No admin user found. Please create an admin user first.")
sys.exit(1)
# Default pricing plans
default_plans = [
{
'name': 'Starter',
'description': 'Perfect for small teams getting started',
'monthly_price': 29.0,
'annual_price': 23.0,
'features': [
'Up to 5 rooms',
'Up to 10 conversations',
'10GB storage',
'Up to 10 managers',
'Email support'
],
'button_text': 'Get Started',
'button_url': '#',
'is_popular': False,
'is_custom': False,
'is_active': True,
'order_index': 1,
'room_quota': 5,
'conversation_quota': 10,
'storage_quota_gb': 10,
'manager_quota': 10,
'admin_quota': 1
},
{
'name': 'Professional',
'description': 'Ideal for growing businesses',
'monthly_price': 99.0,
'annual_price': 79.0,
'features': [
'Up to 25 rooms',
'Up to 50 conversations',
'100GB storage',
'Up to 50 managers',
'Priority support'
],
'button_text': 'Get Started',
'button_url': '#',
'is_popular': True,
'is_custom': False,
'is_active': True,
'order_index': 2,
'room_quota': 25,
'conversation_quota': 50,
'storage_quota_gb': 100,
'manager_quota': 50,
'admin_quota': 3
},
{
'name': 'Enterprise',
'description': 'For large organizations with advanced needs',
'monthly_price': 299.0,
'annual_price': 239.0,
'features': [
'Up to 100 rooms',
'Up to 200 conversations',
'500GB storage',
'Up to 200 managers',
'24/7 dedicated support'
],
'button_text': 'Get Started',
'button_url': '#',
'is_popular': False,
'is_custom': False,
'is_active': True,
'order_index': 3,
'room_quota': 100,
'conversation_quota': 200,
'storage_quota_gb': 500,
'manager_quota': 200,
'admin_quota': 10
},
{
'name': 'Custom',
'description': 'Tailored solutions for enterprise customers',
'monthly_price': 0.0,
'annual_price': 0.0,
'features': [
'Unlimited rooms',
'Unlimited conversations',
'Unlimited storage',
'Unlimited users',
'Custom integrations',
'Dedicated account manager'
],
'button_text': 'Contact Sales',
'button_url': '#',
'is_popular': False,
'is_custom': True,
'is_active': True,
'order_index': 4,
'room_quota': 0,
'conversation_quota': 0,
'storage_quota_gb': 0,
'manager_quota': 0,
'admin_quota': 0
}
]
# Create pricing plans
for plan_data in default_plans:
plan = PricingPlan(
name=plan_data['name'],
description=plan_data['description'],
monthly_price=plan_data['monthly_price'],
annual_price=plan_data['annual_price'],
features=plan_data['features'],
button_text=plan_data['button_text'],
button_url=plan_data['button_url'],
is_popular=plan_data['is_popular'],
is_custom=plan_data['is_custom'],
is_active=plan_data['is_active'],
order_index=plan_data['order_index'],
room_quota=plan_data['room_quota'],
conversation_quota=plan_data['conversation_quota'],
storage_quota_gb=plan_data['storage_quota_gb'],
manager_quota=plan_data['manager_quota'],
admin_quota=plan_data['admin_quota'],
created_by=admin_user.id
)
db.session.add(plan)
try:
db.session.commit()
print("Successfully created default pricing plans:")
for plan_data in default_plans:
print(f" - {plan_data['name']}: €{plan_data['monthly_price']}/month")
print("\nYou can now configure these plans in the admin settings.")
except Exception as e:
db.session.rollback()
print(f"Error creating pricing plans: {e}")
sys.exit(1)
if __name__ == '__main__':
init_pricing_plans()

View File

@@ -0,0 +1,62 @@
"""add pricing plans table
Revision ID: add_pricing_plans_table
Revises: add_help_articles_table
Create Date: 2024-12-19 11:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'add_pricing_plans_table'
down_revision = 'add_help_articles_table'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
result = conn.execute(text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'pricing_plans'
);
"""))
exists = result.scalar()
if not exists:
op.create_table('pricing_plans',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('monthly_price', sa.Float(), nullable=False),
sa.Column('annual_price', sa.Float(), nullable=False),
sa.Column('features', sa.JSON(), nullable=False),
sa.Column('is_popular', sa.Boolean(), nullable=True, server_default='false'),
sa.Column('is_custom', sa.Boolean(), nullable=True, server_default='false'),
sa.Column('button_text', sa.String(length=50), nullable=True, server_default="'Get Started'"),
sa.Column('button_url', sa.String(length=200), nullable=True, server_default="'#'"),
sa.Column('order_index', sa.Integer(), nullable=True, server_default='0'),
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for better performance
op.create_index('idx_pricing_plans_active', 'pricing_plans', ['is_active'])
op.create_index('idx_pricing_plans_order', 'pricing_plans', ['order_index'])
op.create_index('idx_pricing_plans_popular', 'pricing_plans', ['is_popular'])
op.create_index('idx_pricing_plans_created_at', 'pricing_plans', ['created_at'])
def downgrade():
op.drop_index('idx_pricing_plans_active', table_name='pricing_plans')
op.drop_index('idx_pricing_plans_order', table_name='pricing_plans')
op.drop_index('idx_pricing_plans_popular', table_name='pricing_plans')
op.drop_index('idx_pricing_plans_created_at', table_name='pricing_plans')
op.drop_table('pricing_plans')

View File

@@ -0,0 +1,55 @@
"""add quota fields to pricing plans
Revision ID: add_quota_fields
Revises: add_pricing_plans_table
Create Date: 2024-12-19 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'add_quota_fields'
down_revision = 'add_pricing_plans_table'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
# Check if columns already exist
result = conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'pricing_plans'
AND column_name IN ('room_quota', 'conversation_quota', 'storage_quota_gb', 'manager_quota', 'admin_quota')
"""))
existing_columns = [row[0] for row in result.fetchall()]
# Add quota columns if they don't exist
if 'room_quota' not in existing_columns:
op.add_column('pricing_plans', sa.Column('room_quota', sa.Integer(), nullable=True, server_default='0'))
if 'conversation_quota' not in existing_columns:
op.add_column('pricing_plans', sa.Column('conversation_quota', sa.Integer(), nullable=True, server_default='0'))
if 'storage_quota_gb' not in existing_columns:
op.add_column('pricing_plans', sa.Column('storage_quota_gb', sa.Integer(), nullable=True, server_default='0'))
if 'manager_quota' not in existing_columns:
op.add_column('pricing_plans', sa.Column('manager_quota', sa.Integer(), nullable=True, server_default='0'))
if 'admin_quota' not in existing_columns:
op.add_column('pricing_plans', sa.Column('admin_quota', sa.Integer(), nullable=True, server_default='0'))
def downgrade():
# Remove quota columns
op.drop_column('pricing_plans', 'admin_quota')
op.drop_column('pricing_plans', 'manager_quota')
op.drop_column('pricing_plans', 'storage_quota_gb')
op.drop_column('pricing_plans', 'conversation_quota')
op.drop_column('pricing_plans', 'room_quota')

View File

@@ -587,3 +587,91 @@ class HelpArticle(db.Model):
grouped[article.category] = [] grouped[article.category] = []
grouped[article.category].append(article) grouped[article.category].append(article)
return grouped return grouped
class PricingPlan(db.Model):
__tablename__ = 'pricing_plans'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
monthly_price = db.Column(db.Float, nullable=False)
annual_price = db.Column(db.Float, nullable=False)
features = db.Column(db.JSON, nullable=False) # List of feature strings
is_popular = db.Column(db.Boolean, default=False)
is_custom = db.Column(db.Boolean, default=False)
button_text = db.Column(db.String(50), default='Get Started')
button_url = db.Column(db.String(200), default='#')
order_index = db.Column(db.Integer, default=0)
is_active = db.Column(db.Boolean, default=True)
# Quota fields
room_quota = db.Column(db.Integer, default=0) # 0 = unlimited
conversation_quota = db.Column(db.Integer, default=0) # 0 = unlimited
storage_quota_gb = db.Column(db.Integer, default=0) # 0 = unlimited, stored in GB
manager_quota = db.Column(db.Integer, default=0) # 0 = unlimited
admin_quota = db.Column(db.Integer, default=0) # 0 = unlimited
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
# Relationships
creator = db.relationship('User', backref=db.backref('created_pricing_plans', cascade='all, delete-orphan'), foreign_keys=[created_by])
def __repr__(self):
return f'<PricingPlan {self.name}>'
@classmethod
def get_active_plans(cls):
"""Get all active pricing plans ordered by order_index"""
return cls.query.filter_by(is_active=True).order_by(cls.order_index).all()
@classmethod
def get_popular_plan(cls):
"""Get the plan marked as most popular"""
return cls.query.filter_by(is_active=True, is_popular=True).first()
def get_storage_quota_bytes(self):
"""Get storage quota in bytes"""
if self.storage_quota_gb == 0:
return 0 # Unlimited
return self.storage_quota_gb * 1024 * 1024 * 1024 # Convert GB to bytes
def format_quota_display(self, quota_type):
"""Format quota for display"""
if quota_type == 'room_quota':
return 'Unlimited' if self.room_quota == 0 else f'{self.room_quota} rooms'
elif quota_type == 'conversation_quota':
return 'Unlimited' if self.conversation_quota == 0 else f'{self.conversation_quota} conversations'
elif quota_type == 'storage_quota_gb':
return 'Unlimited' if self.storage_quota_gb == 0 else f'{self.storage_quota_gb}GB'
elif quota_type == 'manager_quota':
return 'Unlimited' if self.manager_quota == 0 else f'{self.manager_quota} managers'
elif quota_type == 'admin_quota':
return 'Unlimited' if self.admin_quota == 0 else f'{self.admin_quota} admins'
return 'Unknown'
def check_quota(self, quota_type, current_count):
"""Check if a quota would be exceeded"""
if quota_type == 'room_quota':
return self.room_quota == 0 or current_count < self.room_quota
elif quota_type == 'conversation_quota':
return self.conversation_quota == 0 or current_count < self.conversation_quota
elif quota_type == 'storage_quota_gb':
return self.storage_quota_gb == 0 or current_count < self.storage_quota_gb
elif quota_type == 'manager_quota':
return self.manager_quota == 0 or current_count < self.manager_quota
elif quota_type == 'admin_quota':
return self.admin_quota == 0 or current_count < self.admin_quota
return True
def get_quota_remaining(self, quota_type, current_count):
"""Get remaining quota"""
if quota_type == 'room_quota':
return float('inf') if self.room_quota == 0 else max(0, self.room_quota - current_count)
elif quota_type == 'conversation_quota':
return float('inf') if self.conversation_quota == 0 else max(0, self.conversation_quota - current_count)
elif quota_type == 'storage_quota_gb':
return float('inf') if self.storage_quota_gb == 0 else max(0, self.storage_quota_gb - current_count)
elif quota_type == 'manager_quota':
return float('inf') if self.manager_quota == 0 else max(0, self.manager_quota - current_count)
elif quota_type == 'admin_quota':
return float('inf') if self.admin_quota == 0 else max(0, self.admin_quota - current_count)
return 0

View File

@@ -3,6 +3,7 @@ from flask_login import login_required, current_user
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle
import os import os
from datetime import datetime from datetime import datetime
import json
admin = Blueprint('admin', __name__) admin = Blueprint('admin', __name__)
@@ -439,3 +440,239 @@ def get_help_article(article_id):
} }
return jsonify({'article': article_data}) return jsonify({'article': article_data})
@admin.route('/api/admin/pricing-plans', methods=['POST'])
@login_required
def create_pricing_plan():
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403
try:
from models import PricingPlan
# Get form data
name = request.form.get('name')
description = request.form.get('description')
monthly_price = float(request.form.get('monthly_price'))
annual_price = float(request.form.get('annual_price'))
features = json.loads(request.form.get('features', '[]'))
button_text = request.form.get('button_text', 'Get Started')
button_url = request.form.get('button_url', '#')
is_popular = request.form.get('is_popular') == 'true'
is_custom = request.form.get('is_custom') == 'true'
is_active = request.form.get('is_active') == 'true'
# Get quota fields
room_quota = int(request.form.get('room_quota', 0))
conversation_quota = int(request.form.get('conversation_quota', 0))
storage_quota_gb = int(request.form.get('storage_quota_gb', 0))
manager_quota = int(request.form.get('manager_quota', 0))
admin_quota = int(request.form.get('admin_quota', 0))
# Validate required fields
if not name or not features:
return jsonify({'error': 'Name and features are required'}), 400
# Get the highest order index
max_order = db.session.query(db.func.max(PricingPlan.order_index)).scalar() or 0
# Create new pricing plan
plan = PricingPlan(
name=name,
description=description,
monthly_price=monthly_price,
annual_price=annual_price,
features=features,
button_text=button_text,
button_url=button_url,
is_popular=is_popular,
is_custom=is_custom,
is_active=is_active,
room_quota=room_quota,
conversation_quota=conversation_quota,
storage_quota_gb=storage_quota_gb,
manager_quota=manager_quota,
admin_quota=admin_quota,
order_index=max_order + 1,
created_by=current_user.id
)
db.session.add(plan)
db.session.commit()
return jsonify({'success': True, 'message': 'Pricing plan created successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/pricing-plans/<int:plan_id>', methods=['GET'])
@login_required
def get_pricing_plan(plan_id):
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
from models import PricingPlan
plan = PricingPlan.query.get(plan_id)
if not plan:
return jsonify({'error': 'Pricing plan not found'}), 404
return jsonify({
'success': True,
'plan': {
'id': plan.id,
'name': plan.name,
'description': plan.description,
'monthly_price': plan.monthly_price,
'annual_price': plan.annual_price,
'features': plan.features,
'button_text': plan.button_text,
'button_url': plan.button_url,
'is_popular': plan.is_popular,
'is_custom': plan.is_custom,
'is_active': plan.is_active,
'order_index': plan.order_index,
'room_quota': plan.room_quota,
'conversation_quota': plan.conversation_quota,
'storage_quota_gb': plan.storage_quota_gb,
'manager_quota': plan.manager_quota,
'admin_quota': plan.admin_quota
}
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/pricing-plans/<int:plan_id>', methods=['PUT'])
@login_required
def update_pricing_plan(plan_id):
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403
try:
from models import PricingPlan
plan = PricingPlan.query.get(plan_id)
if not plan:
return jsonify({'error': 'Pricing plan not found'}), 404
# Get form data
name = request.form.get('name')
description = request.form.get('description')
monthly_price = float(request.form.get('monthly_price'))
annual_price = float(request.form.get('annual_price'))
features = json.loads(request.form.get('features', '[]'))
button_text = request.form.get('button_text', 'Get Started')
button_url = request.form.get('button_url', '#')
is_popular = request.form.get('is_popular') == 'true'
is_custom = request.form.get('is_custom') == 'true'
is_active = request.form.get('is_active') == 'true'
# Get quota fields
room_quota = int(request.form.get('room_quota', 0))
conversation_quota = int(request.form.get('conversation_quota', 0))
storage_quota_gb = int(request.form.get('storage_quota_gb', 0))
manager_quota = int(request.form.get('manager_quota', 0))
admin_quota = int(request.form.get('admin_quota', 0))
# Validate required fields
if not name or not features:
return jsonify({'error': 'Name and features are required'}), 400
# Update plan
plan.name = name
plan.description = description
plan.monthly_price = monthly_price
plan.annual_price = annual_price
plan.features = features
plan.button_text = button_text
plan.button_url = button_url
plan.is_popular = is_popular
plan.is_custom = is_custom
plan.is_active = is_active
plan.room_quota = room_quota
plan.conversation_quota = conversation_quota
plan.storage_quota_gb = storage_quota_gb
plan.manager_quota = manager_quota
plan.admin_quota = admin_quota
db.session.commit()
return jsonify({'success': True, 'message': 'Pricing plan updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/pricing-plans/<int:plan_id>', methods=['DELETE'])
@login_required
def delete_pricing_plan(plan_id):
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403
try:
from models import PricingPlan
plan = PricingPlan.query.get(plan_id)
if not plan:
return jsonify({'error': 'Pricing plan not found'}), 404
db.session.delete(plan)
db.session.commit()
return jsonify({'success': True, 'message': 'Pricing plan deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/pricing-plans/<int:plan_id>/status', methods=['PATCH'])
@login_required
def update_pricing_plan_status(plan_id):
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
# Check if this is a MASTER instance
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
if not is_master:
return jsonify({'error': 'Pricing configuration is only available on MASTER instances'}), 403
try:
from models import PricingPlan
plan = PricingPlan.query.get(plan_id)
if not plan:
return jsonify({'error': 'Pricing plan not found'}), 404
data = request.get_json()
field = data.get('field')
value = data.get('value')
if field not in ['is_active', 'is_popular', 'is_custom']:
return jsonify({'error': 'Invalid field'}), 400
setattr(plan, field, value)
db.session.commit()
return jsonify({'success': True, 'message': 'Plan status updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500

View File

@@ -1122,7 +1122,7 @@ def init_routes(main_bp):
active_tab = request.args.get('tab', 'colors') active_tab = request.args.get('tab', 'colors')
# Validate tab parameter # Validate tab parameter
valid_tabs = ['colors', 'general', 'email_templates', 'mails', 'security', 'events', 'debugging', 'smtp', 'connections'] valid_tabs = ['colors', 'general', 'email_templates', 'mails', 'security', 'events', 'debugging', 'smtp', 'connections', 'pricing']
if active_tab not in valid_tabs: if active_tab not in valid_tabs:
active_tab = 'colors' active_tab = 'colors'
@@ -1180,6 +1180,12 @@ def init_routes(main_bp):
current_page = mails.page current_page = mails.page
users = User.query.order_by(User.username).all() users = User.query.order_by(User.username).all()
# Get pricing plans for the pricing tab (only for MASTER instances)
pricing_plans = []
if active_tab == 'pricing':
from models import PricingPlan
pricing_plans = PricingPlan.query.order_by(PricingPlan.order_index).all()
if request.method == 'GET': if request.method == 'GET':
company_form.company_name.data = site_settings.company_name company_form.company_name.data = site_settings.company_name
company_form.company_website.data = site_settings.company_website company_form.company_website.data = site_settings.company_website
@@ -1210,6 +1216,7 @@ def init_routes(main_bp):
nginx_settings=nginx_settings, nginx_settings=nginx_settings,
git_settings=git_settings, git_settings=git_settings,
cloudflare_settings=cloudflare_settings, cloudflare_settings=cloudflare_settings,
pricing_plans=pricing_plans,
csrf_token=generate_csrf()) csrf_token=generate_csrf())
@main_bp.route('/settings/update-smtp', methods=['POST']) @main_bp.route('/settings/update-smtp', methods=['POST'])

View File

@@ -1,5 +1,5 @@
from flask import Blueprint, render_template, redirect, url_for, request from flask import Blueprint, render_template, redirect, url_for, request
from models import SiteSettings, HelpArticle from models import SiteSettings, HelpArticle, PricingPlan
import os import os
def init_public_routes(public_bp): def init_public_routes(public_bp):
@@ -8,6 +8,11 @@ def init_public_routes(public_bp):
site_settings = SiteSettings.query.first() site_settings = SiteSettings.query.first()
return dict(site_settings=site_settings) return dict(site_settings=site_settings)
@public_bp.context_processor
def inject_pricing_plans():
"""Make PricingPlan model available in templates"""
return dict(PricingPlan=PricingPlan)
@public_bp.route('/features') @public_bp.route('/features')
def features(): def features():
"""Features page""" """Features page"""

View File

@@ -0,0 +1,310 @@
document.addEventListener('DOMContentLoaded', function() {
// Feature management for add form
const addFeatureBtn = document.getElementById('addFeatureBtn');
const featuresContainer = document.getElementById('featuresContainer');
if (addFeatureBtn) {
addFeatureBtn.addEventListener('click', function() {
addFeatureField(featuresContainer);
});
}
// Feature management for edit form
const editAddFeatureBtn = document.getElementById('editAddFeatureBtn');
const editFeaturesContainer = document.getElementById('editFeaturesContainer');
if (editAddFeatureBtn) {
editAddFeatureBtn.addEventListener('click', function() {
addFeatureField(editFeaturesContainer);
});
}
// Remove feature buttons
document.addEventListener('click', function(e) {
if (e.target.classList.contains('remove-feature-btn')) {
e.target.closest('.input-group').remove();
}
});
// Add Pricing Plan Form
const addPricingPlanForm = document.getElementById('addPricingPlanForm');
if (addPricingPlanForm) {
addPricingPlanForm.addEventListener('submit', function(e) {
e.preventDefault();
submitPricingPlanForm(this, 'POST', '/api/admin/pricing-plans');
});
}
// Edit Pricing Plan Form
const editPricingPlanForm = document.getElementById('editPricingPlanForm');
if (editPricingPlanForm) {
editPricingPlanForm.addEventListener('submit', function(e) {
e.preventDefault();
const planId = document.getElementById('editPlanId').value;
submitPricingPlanForm(this, 'PUT', `/api/admin/pricing-plans/${planId}`);
});
}
// Edit Plan Buttons
document.addEventListener('click', function(e) {
if (e.target.classList.contains('edit-plan-btn')) {
const planId = e.target.getAttribute('data-plan-id');
loadPlanForEdit(planId);
}
});
// Delete Plan Buttons
document.addEventListener('click', function(e) {
if (e.target.classList.contains('delete-plan-btn')) {
const planId = e.target.getAttribute('data-plan-id');
const planName = e.target.closest('.card').querySelector('.card-header h5').textContent;
showDeleteConfirmation(planId, planName);
}
});
// Confirm Delete Button
const confirmDeleteBtn = document.getElementById('confirmDeletePlan');
if (confirmDeleteBtn) {
confirmDeleteBtn.addEventListener('click', function() {
const planId = this.getAttribute('data-plan-id');
deletePricingPlan(planId);
});
}
// Toggle switches
document.addEventListener('change', function(e) {
if (e.target.classList.contains('plan-status-toggle')) {
const planId = e.target.getAttribute('data-plan-id');
const isActive = e.target.checked;
updatePlanStatus(planId, 'is_active', isActive);
}
if (e.target.classList.contains('plan-popular-toggle')) {
const planId = e.target.getAttribute('data-plan-id');
const isPopular = e.target.checked;
updatePlanStatus(planId, 'is_popular', isPopular);
}
if (e.target.classList.contains('plan-custom-toggle')) {
const planId = e.target.getAttribute('data-plan-id');
const isCustom = e.target.checked;
updatePlanStatus(planId, 'is_custom', isCustom);
}
});
});
function addFeatureField(container) {
const featureGroup = document.createElement('div');
featureGroup.className = 'input-group mb-2';
featureGroup.innerHTML = `
<input type="text" class="form-control feature-input" name="features[]" required>
<button type="button" class="btn btn-outline-danger remove-feature-btn">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(featureGroup);
}
function submitPricingPlanForm(form, method, url) {
const formData = new FormData(form);
// Convert features array
const features = [];
form.querySelectorAll('input[name="features[]"]').forEach(input => {
if (input.value.trim()) {
features.push(input.value.trim());
}
});
// Remove features from formData and add as JSON
formData.delete('features[]');
formData.append('features', JSON.stringify(features));
// Convert checkboxes to boolean values
const checkboxes = ['is_popular', 'is_custom', 'is_active'];
checkboxes.forEach(field => {
formData.set(field, formData.get(field) === 'on');
});
fetch(url, {
method: method,
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Pricing plan saved successfully!', 'success');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
showNotification(data.error || 'Error saving pricing plan', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error saving pricing plan', 'error');
});
}
function loadPlanForEdit(planId) {
fetch(`/api/admin/pricing-plans/${planId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
const plan = data.plan;
// Populate form fields
document.getElementById('editPlanId').value = plan.id;
document.getElementById('editPlanName').value = plan.name;
document.getElementById('editPlanDescription').value = plan.description || '';
document.getElementById('editMonthlyPrice').value = plan.monthly_price;
document.getElementById('editAnnualPrice').value = plan.annual_price;
document.getElementById('editButtonText').value = plan.button_text;
document.getElementById('editButtonUrl').value = plan.button_url;
document.getElementById('editIsPopular').checked = plan.is_popular;
document.getElementById('editIsCustom').checked = plan.is_custom;
document.getElementById('editIsActive').checked = plan.is_active;
// Populate quota fields
document.getElementById('editRoomQuota').value = plan.room_quota || 0;
document.getElementById('editConversationQuota').value = plan.conversation_quota || 0;
document.getElementById('editStorageQuota').value = plan.storage_quota_gb || 0;
document.getElementById('editManagerQuota').value = plan.manager_quota || 0;
document.getElementById('editAdminQuota').value = plan.admin_quota || 0;
// Populate features
const editFeaturesContainer = document.getElementById('editFeaturesContainer');
editFeaturesContainer.innerHTML = '';
plan.features.forEach(feature => {
const featureGroup = document.createElement('div');
featureGroup.className = 'input-group mb-2';
featureGroup.innerHTML = `
<input type="text" class="form-control feature-input" name="features[]" value="${feature}" required>
<button type="button" class="btn btn-outline-danger remove-feature-btn">
<i class="fas fa-times"></i>
</button>
`;
editFeaturesContainer.appendChild(featureGroup);
});
// Add one empty feature field if no features
if (plan.features.length === 0) {
addFeatureField(editFeaturesContainer);
}
} else {
showNotification(data.error || 'Error loading plan', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error loading plan', 'error');
});
}
function showDeleteConfirmation(planId, planName) {
document.getElementById('deletePlanName').textContent = planName;
document.getElementById('confirmDeletePlan').setAttribute('data-plan-id', planId);
const deleteModal = new bootstrap.Modal(document.getElementById('deletePricingPlanModal'));
deleteModal.show();
}
function deletePricingPlan(planId) {
fetch(`/api/admin/pricing-plans/${planId}`, {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Pricing plan deleted successfully!', 'success');
// Close modal
const deleteModal = bootstrap.Modal.getInstance(document.getElementById('deletePricingPlanModal'));
deleteModal.hide();
// Remove from DOM
const planElement = document.querySelector(`[data-plan-id="${planId}"]`);
if (planElement) {
planElement.remove();
}
// Check if no plans left
const remainingPlans = document.querySelectorAll('[data-plan-id]');
if (remainingPlans.length === 0) {
window.location.reload();
}
} else {
showNotification(data.error || 'Error deleting plan', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error deleting plan', 'error');
});
}
function updatePlanStatus(planId, field, value) {
fetch(`/api/admin/pricing-plans/${planId}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value
},
body: JSON.stringify({
field: field,
value: value
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Plan status updated successfully!', 'success');
} else {
showNotification(data.error || 'Error updating plan status', 'error');
// Revert the toggle
const toggle = document.querySelector(`[data-plan-id="${planId}"].${field.replace('is_', 'plan-')}-toggle`);
if (toggle) {
toggle.checked = !value;
}
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error updating plan status', 'error');
// Revert the toggle
const toggle = document.querySelector(`[data-plan-id="${planId}"].${field.replace('is_', 'plan-')}-toggle`);
if (toggle) {
toggle.checked = !value;
}
});
}
function showNotification(message, type) {
// Create notification element
const notification = document.createElement('div');
notification.className = `alert alert-${type === 'success' ? 'success' : 'danger'} alert-dismissible fade show position-fixed`;
notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 5 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 5000);
}

View File

@@ -5,6 +5,72 @@
<h2 class="display-5 fw-bold mb-3">Simple, Transparent Pricing</h2> <h2 class="display-5 fw-bold mb-3">Simple, Transparent Pricing</h2>
<p class="lead text-muted">Choose the plan that fits your organization's needs</p> <p class="lead text-muted">Choose the plan that fits your organization's needs</p>
</div> </div>
{% set pricing_plans = PricingPlan.get_active_plans() %}
{% if pricing_plans %}
<div class="row g-4 justify-content-center">
{% for plan in pricing_plans %}
<div class="col-md-3">
<div class="card pricing-card h-100 d-flex flex-column {% if plan.is_popular %}border-primary position-relative{% endif %}"
{% if plan.is_popular %}style="border: 3px solid var(--primary-color) !important;"{% endif %}>
{% if plan.is_popular %}
<div class="position-absolute top-0 start-0" style="z-index: 10;">
<span class="badge px-3 py-2" style="background: var(--primary-color); color: white; font-size: 0.8rem; font-weight: 600; border-radius: 0 0 15px 0; margin-top: 0; border-top-left-radius: 10px;">
Most Popular
</span>
</div>
{% endif %}
<div class="card-body text-center p-5 d-flex flex-column">
<div class="flex-grow-1">
<h3 class="card-title">{{ plan.name }}</h3>
{% if plan.is_custom %}
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">Custom</div>
{% else %}
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
<span class="monthly-price">€{{ "%.0f"|format(plan.monthly_price) }}</span>
<span class="annual-price" style="display: none;">€{{ "%.0f"|format(plan.annual_price) }}</span>
<span class="fs-6 text-muted">/month</span>
</div>
{% endif %}
{% if plan.description %}
<p class="text-muted mb-3">{{ plan.description }}</p>
{% endif %}
<ul class="list-unstyled mb-4">
{% for feature in plan.features %}
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>{{ feature }}</li>
{% endfor %}
</ul>
</div>
<a href="{{ plan.button_url }}" class="btn {% if plan.is_popular %}btn-primary{% else %}btn-outline-primary{% endif %} btn-lg w-100 mt-auto px-4 py-3">
{{ plan.button_text }}
</a>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Billing Toggle - Only show if there are non-custom plans -->
{% set has_non_custom_plans = pricing_plans | selectattr('is_custom', 'equalto', false) | list | length > 0 %}
{% if has_non_custom_plans %}
<div class="d-flex justify-content-center align-items-center mt-4 mb-3">
<span class="me-3">Monthly</span>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="annualBilling" style="width: 3rem; height: 1.5rem; background-color: var(--border-color); border-color: var(--border-color);">
<label class="form-check-label" for="annualBilling"></label>
</div>
<span class="ms-3">Annual <span class="badge text-white px-2 py-1 ms-1" style="background: var(--primary-color); font-size: 0.75rem;">Save 20%</span></span>
</div>
{% endif %}
{% else %}
<!-- Fallback to default pricing if no plans are configured -->
<div class="row g-4 justify-content-center"> <div class="row g-4 justify-content-center">
<div class="col-md-3"> <div class="col-md-3">
<div class="card pricing-card h-100 d-flex flex-column"> <div class="card pricing-card h-100 d-flex flex-column">
@@ -107,12 +173,15 @@
</div> </div>
<span class="ms-3">Annual <span class="badge text-white px-2 py-1 ms-1" style="background: var(--primary-color); font-size: 0.75rem;">Save 20%</span></span> <span class="ms-3">Annual <span class="badge text-white px-2 py-1 ms-1" style="background: var(--primary-color); font-size: 0.75rem;">Save 20%</span></span>
</div> </div>
{% endif %}
</div> </div>
</section> </section>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const billingToggle = document.getElementById('annualBilling'); const billingToggle = document.getElementById('annualBilling');
if (!billingToggle) return;
const monthlyPrices = document.querySelectorAll('.monthly-price'); const monthlyPrices = document.querySelectorAll('.monthly-price');
const annualPrices = document.querySelectorAll('.annual-price'); const annualPrices = document.querySelectorAll('.annual-price');
@@ -186,3 +255,4 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
</script> </script>
</script>

View File

@@ -9,6 +9,7 @@
{% from "settings/tabs/mails.html" import mails_tab %} {% from "settings/tabs/mails.html" import mails_tab %}
{% from "settings/tabs/smtp_settings.html" import smtp_settings_tab %} {% from "settings/tabs/smtp_settings.html" import smtp_settings_tab %}
{% from "settings/tabs/connections.html" import connections_tab %} {% from "settings/tabs/connections.html" import connections_tab %}
{% from "settings/tabs/pricing.html" import pricing_tab %}
{% from "settings/components/reset_colors_modal.html" import reset_colors_modal %} {% from "settings/components/reset_colors_modal.html" import reset_colors_modal %}
{% block title %}Settings - DocuPulse{% endblock %} {% block title %}Settings - DocuPulse{% endblock %}
@@ -82,6 +83,11 @@
<i class="fas fa-plug me-2"></i>Connections <i class="fas fa-plug me-2"></i>Connections
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if active_tab == 'pricing' %}active{% endif %}" id="pricing-tab" data-bs-toggle="tab" data-bs-target="#pricing" type="button" role="tab" aria-controls="pricing" aria-selected="{{ 'true' if active_tab == 'pricing' else 'false' }}">
<i class="fas fa-tags me-2"></i>Pricing
</button>
</li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@@ -136,6 +142,11 @@
<div class="tab-pane fade {% if active_tab == 'connections' %}show active{% endif %}" id="connections" role="tabpanel" aria-labelledby="connections-tab"> <div class="tab-pane fade {% if active_tab == 'connections' %}show active{% endif %}" id="connections" role="tabpanel" aria-labelledby="connections-tab">
{{ connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings) }} {{ connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings) }}
</div> </div>
<!-- Pricing Tab -->
<div class="tab-pane fade {% if active_tab == 'pricing' %}show active{% endif %}" id="pricing" role="tabpanel" aria-labelledby="pricing-tab">
{{ pricing_tab(pricing_plans, csrf_token) }}
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -150,4 +161,7 @@
{% block extra_js %} {% block extra_js %}
<script src="{{ url_for('static', filename='js/settings.js') }}?v={{ 'js/settings.js'|asset_version }}"></script> <script src="{{ url_for('static', filename='js/settings.js') }}?v={{ 'js/settings.js'|asset_version }}"></script>
<script src="{{ url_for('static', filename='js/events.js') }}?v={{ 'js/events.js'|asset_version }}"></script> <script src="{{ url_for('static', filename='js/events.js') }}?v={{ 'js/events.js'|asset_version }}"></script>
{% if is_master and active_tab == 'pricing' %}
<script src="{{ url_for('static', filename='js/settings/pricing.js') }}?v={{ 'js/settings/pricing.js'|asset_version }}"></script>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,427 @@
{% macro pricing_tab(pricing_plans, csrf_token) %}
<div class="pricing-configuration">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Pricing Plans Configuration</h4>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addPricingPlanModal">
<i class="fas fa-plus me-2"></i>Add New Plan
</button>
</div>
<!-- Pricing Plans List -->
<div class="row" id="pricingPlansContainer">
{% for plan in pricing_plans %}
<div class="col-md-6 col-lg-4 mb-4" data-plan-id="{{ plan.id }}">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ plan.name }}</h5>
<div class="form-check form-switch">
<input class="form-check-input plan-status-toggle" type="checkbox"
data-plan-id="{{ plan.id }}"
{% if plan.is_active %}checked{% endif %}>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<strong>Monthly Price:</strong> €{{ "%.2f"|format(plan.monthly_price) }}<br>
<strong>Annual Price:</strong> €{{ "%.2f"|format(plan.annual_price) }}
</div>
{% if plan.description %}
<div class="mb-3">
<strong>Description:</strong><br>
<small class="text-muted">{{ plan.description }}</small>
</div>
{% endif %}
<!-- Quotas Section -->
<div class="mb-3">
<strong>Quotas:</strong>
<div class="row mt-2">
<div class="col-6">
<small class="text-muted">
<i class="fas fa-door-open me-1"></i>Rooms: {{ plan.format_quota_display('room_quota') }}<br>
<i class="fas fa-comments me-1"></i>Conversations: {{ plan.format_quota_display('conversation_quota') }}<br>
<i class="fas fa-hdd me-1"></i>Storage: {{ plan.format_quota_display('storage_quota_gb') }}
</small>
</div>
<div class="col-6">
<small class="text-muted">
<i class="fas fa-user-tie me-1"></i>Managers: {{ plan.format_quota_display('manager_quota') }}<br>
<i class="fas fa-user-shield me-1"></i>Admins: {{ plan.format_quota_display('admin_quota') }}
</small>
</div>
</div>
</div>
<div class="mb-3">
<strong>Features:</strong>
<ul class="list-unstyled mt-2">
{% for feature in plan.features %}
<li><i class="fas fa-check text-success me-2"></i>{{ feature }}</li>
{% endfor %}
</ul>
</div>
<div class="mb-3">
<strong>Button:</strong> {{ plan.button_text }} → {{ plan.button_url }}
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input plan-popular-toggle" type="checkbox"
data-plan-id="{{ plan.id }}"
{% if plan.is_popular %}checked{% endif %}>
<label class="form-check-label">Most Popular</label>
</div>
<div class="form-check">
<input class="form-check-input plan-custom-toggle" type="checkbox"
data-plan-id="{{ plan.id }}"
{% if plan.is_custom %}checked{% endif %}>
<label class="form-check-label">Custom Plan</label>
</div>
</div>
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-outline-primary edit-plan-btn"
data-plan-id="{{ plan.id }}"
data-bs-toggle="modal" data-bs-target="#editPricingPlanModal">
<i class="fas fa-edit me-1"></i>Edit
</button>
<button type="button" class="btn btn-sm btn-outline-danger delete-plan-btn"
data-plan-id="{{ plan.id }}">
<i class="fas fa-trash me-1"></i>Delete
</button>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="text-center py-5">
<i class="fas fa-tags fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No pricing plans configured</h5>
<p class="text-muted">Click "Add New Plan" to create your first pricing plan.</p>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Add Pricing Plan Modal -->
<div class="modal fade" id="addPricingPlanModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add New Pricing Plan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addPricingPlanForm">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="planName" class="form-label">Plan Name *</label>
<input type="text" class="form-control" id="planName" name="name" required>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="planDescription" class="form-label">Description</label>
<textarea class="form-control" id="planDescription" name="description" rows="2"></textarea>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="monthlyPrice" class="form-label">Monthly Price (€) *</label>
<input type="number" class="form-control" id="monthlyPrice" name="monthly_price"
step="0.01" min="0" required>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="annualPrice" class="form-label">Annual Price (€) *</label>
<input type="number" class="form-control" id="annualPrice" name="annual_price"
step="0.01" min="0" required>
</div>
</div>
</div>
<!-- Quotas Section -->
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="fas fa-chart-pie me-2"></i>Resource Quotas</h6>
<small class="text-muted">Set to 0 for unlimited</small>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="roomQuota" class="form-label">Room Quota</label>
<input type="number" class="form-control" id="roomQuota" name="room_quota"
min="0" value="0">
<small class="text-muted">Number of rooms allowed</small>
</div>
<div class="mb-3">
<label for="conversationQuota" class="form-label">Conversation Quota</label>
<input type="number" class="form-control" id="conversationQuota" name="conversation_quota"
min="0" value="0">
<small class="text-muted">Number of conversations allowed</small>
</div>
<div class="mb-3">
<label for="storageQuota" class="form-label">Storage Quota (GB)</label>
<input type="number" class="form-control" id="storageQuota" name="storage_quota_gb"
min="0" value="0">
<small class="text-muted">Storage limit in gigabytes</small>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="managerQuota" class="form-label">Manager Quota</label>
<input type="number" class="form-control" id="managerQuota" name="manager_quota"
min="0" value="0">
<small class="text-muted">Number of manager users allowed</small>
</div>
<div class="mb-3">
<label for="adminQuota" class="form-label">Admin Quota</label>
<input type="number" class="form-control" id="adminQuota" name="admin_quota"
min="0" value="0">
<small class="text-muted">Number of admin users allowed</small>
</div>
</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="planFeatures" class="form-label">Features *</label>
<div id="featuresContainer">
<div class="input-group mb-2">
<input type="text" class="form-control feature-input" name="features[]" required>
<button type="button" class="btn btn-outline-danger remove-feature-btn">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="addFeatureBtn">
<i class="fas fa-plus me-1"></i>Add Feature
</button>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="buttonText" class="form-label">Button Text</label>
<input type="text" class="form-control" id="buttonText" name="button_text"
value="Get Started">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="buttonUrl" class="form-label">Button URL</label>
<input type="text" class="form-control" id="buttonUrl" name="button_url"
value="#">
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isPopular" name="is_popular">
<label class="form-check-label" for="isPopular">Most Popular</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isCustom" name="is_custom">
<label class="form-check-label" for="isCustom">Custom Plan</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isActive" name="is_active" checked>
<label class="form-check-label" for="isActive">Active</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create Plan</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Pricing Plan Modal -->
<div class="modal fade" id="editPricingPlanModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Pricing Plan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="editPricingPlanForm">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" id="editPlanId" name="plan_id">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="editPlanName" class="form-label">Plan Name *</label>
<input type="text" class="form-control" id="editPlanName" name="name" required>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="editPlanDescription" class="form-label">Description</label>
<textarea class="form-control" id="editPlanDescription" name="description" rows="2"></textarea>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="editMonthlyPrice" class="form-label">Monthly Price (€) *</label>
<input type="number" class="form-control" id="editMonthlyPrice" name="monthly_price"
step="0.01" min="0" required>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="editAnnualPrice" class="form-label">Annual Price (€) *</label>
<input type="number" class="form-control" id="editAnnualPrice" name="annual_price"
step="0.01" min="0" required>
</div>
</div>
</div>
<!-- Quotas Section -->
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="fas fa-chart-pie me-2"></i>Resource Quotas</h6>
<small class="text-muted">Set to 0 for unlimited</small>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="editRoomQuota" class="form-label">Room Quota</label>
<input type="number" class="form-control" id="editRoomQuota" name="room_quota"
min="0" value="0">
<small class="text-muted">Number of rooms allowed</small>
</div>
<div class="mb-3">
<label for="editConversationQuota" class="form-label">Conversation Quota</label>
<input type="number" class="form-control" id="editConversationQuota" name="conversation_quota"
min="0" value="0">
<small class="text-muted">Number of conversations allowed</small>
</div>
<div class="mb-3">
<label for="editStorageQuota" class="form-label">Storage Quota (GB)</label>
<input type="number" class="form-control" id="editStorageQuota" name="storage_quota_gb"
min="0" value="0">
<small class="text-muted">Storage limit in gigabytes</small>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="editManagerQuota" class="form-label">Manager Quota</label>
<input type="number" class="form-control" id="editManagerQuota" name="manager_quota"
min="0" value="0">
<small class="text-muted">Number of manager users allowed</small>
</div>
<div class="mb-3">
<label for="editAdminQuota" class="form-label">Admin Quota</label>
<input type="number" class="form-control" id="editAdminQuota" name="admin_quota"
min="0" value="0">
<small class="text-muted">Number of admin users allowed</small>
</div>
</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="editPlanFeatures" class="form-label">Features *</label>
<div id="editFeaturesContainer">
<!-- Features will be populated dynamically -->
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="editAddFeatureBtn">
<i class="fas fa-plus me-1"></i>Add Feature
</button>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="editButtonText" class="form-label">Button Text</label>
<input type="text" class="form-control" id="editButtonText" name="button_text">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="editButtonUrl" class="form-label">Button URL</label>
<input type="text" class="form-control" id="editButtonUrl" name="button_url">
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editIsPopular" name="is_popular">
<label class="form-check-label" for="editIsPopular">Most Popular</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editIsCustom" name="is_custom">
<label class="form-check-label" for="editIsCustom">Custom Plan</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editIsActive" name="is_active">
<label class="form-check-label" for="editIsActive">Active</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Update Plan</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deletePricingPlanModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Pricing Plan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this pricing plan? This action cannot be undone.</p>
<p class="text-danger"><strong>Plan: <span id="deletePlanName"></span></strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeletePlan">Delete Plan</button>
</div>
</div>
</div>
</div>
{% endmacro %}