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] = []
|
||||||
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
|
||||||
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
|
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
|
||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
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>
|
<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>
|
||||||
@@ -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 %}
|
||||||
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