pricing
This commit is contained in:
246
PRICING_CONFIGURATION.md
Normal file
246
PRICING_CONFIGURATION.md
Normal 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
170
init_pricing_plans.py
Normal 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()
|
||||
62
migrations/versions/add_pricing_plans_table.py
Normal file
62
migrations/versions/add_pricing_plans_table.py
Normal 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')
|
||||
55
migrations/versions/add_quota_fields_to_pricing_plans.py
Normal file
55
migrations/versions/add_quota_fields_to_pricing_plans.py
Normal 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')
|
||||
88
models.py
88
models.py
@@ -587,3 +587,91 @@ class HelpArticle(db.Model):
|
||||
grouped[article.category] = []
|
||||
grouped[article.category].append(article)
|
||||
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
|
||||
Binary file not shown.
Binary file not shown.
237
routes/admin.py
237
routes/admin.py
@@ -3,6 +3,7 @@ from flask_login import login_required, current_user
|
||||
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle
|
||||
import os
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
admin = Blueprint('admin', __name__)
|
||||
|
||||
@@ -439,3 +440,239 @@ def get_help_article(article_id):
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1122,7 +1122,7 @@ def init_routes(main_bp):
|
||||
|
||||
active_tab = request.args.get('tab', 'colors')
|
||||
# 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:
|
||||
active_tab = 'colors'
|
||||
|
||||
@@ -1180,6 +1180,12 @@ def init_routes(main_bp):
|
||||
current_page = mails.page
|
||||
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':
|
||||
company_form.company_name.data = site_settings.company_name
|
||||
company_form.company_website.data = site_settings.company_website
|
||||
@@ -1210,6 +1216,7 @@ def init_routes(main_bp):
|
||||
nginx_settings=nginx_settings,
|
||||
git_settings=git_settings,
|
||||
cloudflare_settings=cloudflare_settings,
|
||||
pricing_plans=pricing_plans,
|
||||
csrf_token=generate_csrf())
|
||||
|
||||
@main_bp.route('/settings/update-smtp', methods=['POST'])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request
|
||||
from models import SiteSettings, HelpArticle
|
||||
from models import SiteSettings, HelpArticle, PricingPlan
|
||||
import os
|
||||
|
||||
def init_public_routes(public_bp):
|
||||
@@ -8,6 +8,11 @@ def init_public_routes(public_bp):
|
||||
site_settings = SiteSettings.query.first()
|
||||
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')
|
||||
def features():
|
||||
"""Features page"""
|
||||
|
||||
310
static/js/settings/pricing.js
Normal file
310
static/js/settings/pricing.js
Normal 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);
|
||||
}
|
||||
@@ -5,6 +5,72 @@
|
||||
<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>
|
||||
</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="col-md-3">
|
||||
<div class="card pricing-card h-100 d-flex flex-column">
|
||||
@@ -107,12 +173,15 @@
|
||||
</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 %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const billingToggle = document.getElementById('annualBilling');
|
||||
if (!billingToggle) return;
|
||||
|
||||
const monthlyPrices = document.querySelectorAll('.monthly-price');
|
||||
const annualPrices = document.querySelectorAll('.annual-price');
|
||||
|
||||
@@ -186,3 +255,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
@@ -9,6 +9,7 @@
|
||||
{% from "settings/tabs/mails.html" import mails_tab %}
|
||||
{% from "settings/tabs/smtp_settings.html" import smtp_settings_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 %}
|
||||
|
||||
{% block title %}Settings - DocuPulse{% endblock %}
|
||||
@@ -82,6 +83,11 @@
|
||||
<i class="fas fa-plug me-2"></i>Connections
|
||||
</button>
|
||||
</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 %}
|
||||
</ul>
|
||||
</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">
|
||||
{{ connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings) }}
|
||||
</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 %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,4 +161,7 @@
|
||||
{% 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/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 %}
|
||||
427
templates/settings/tabs/pricing.html
Normal file
427
templates/settings/tabs/pricing.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user