Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0d436d116 | |||
| 36da0717a2 | |||
| 8a622334d0 | |||
| b1da4977d3 | |||
| 9b85f3bb8d | |||
| 3a0659b63b | |||
| 5b598f2966 | |||
| 77032062a1 | |||
| 81675af837 | |||
| 0a2cddf122 | |||
| 56d94a06ce | |||
| de3880e880 | |||
| 0466b11c71 | |||
| e519dc3a8b | |||
| ac9f002365 | |||
| 8de74827f2 | |||
| 81552bc5ec | |||
| 490bc05a9e | |||
| cc699506d3 | |||
| 84da2eb489 | |||
| a9a61c98f5 | |||
| 4678022c7b | |||
| ca2d2e6587 | |||
| 912f97490c | |||
| d7f5809771 | |||
| 782be6bd38 | |||
| 996adb4bce | |||
| 6412d9f01a | |||
| 875e20304b | |||
| fed00ff2a0 | |||
| 10560a01fb | |||
| 56e7f1be53 | |||
| f5168c27bf | |||
| 4cf9cca116 | |||
| af375a2b5c | |||
| 23a55e025c | |||
| 40b1a63cf5 | |||
| 033f82eb2b | |||
| 1370bef1f1 | |||
| 1a6741ec10 | |||
| 0b9005b481 | |||
| 7ec3027410 | |||
| 405cc83ba1 | |||
| 0bbdf0eaab |
@@ -1,274 +0,0 @@
|
|||||||
{
|
|
||||||
"openapi": "3.1.0",
|
|
||||||
"info": {
|
|
||||||
"title": "Nginx Proxy Manager API",
|
|
||||||
"version": "2.x.x"
|
|
||||||
},
|
|
||||||
"servers": [
|
|
||||||
{
|
|
||||||
"url": "http://127.0.0.1:81/api"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"components": {
|
|
||||||
"securitySchemes": {
|
|
||||||
"bearerAuth": {
|
|
||||||
"type": "http",
|
|
||||||
"scheme": "bearer",
|
|
||||||
"bearerFormat": "JWT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"paths": {
|
|
||||||
"/": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/audit-log": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/audit-log/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/access-lists": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/access-lists/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/access-lists/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/access-lists/{listID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/access-lists/listID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/nginx/access-lists/listID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/access-lists/listID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/certificates/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/certificates/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/validate": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/certificates/validate/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/test-http": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/certificates/test-http/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/{certID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/certificates/certID/get.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/certificates/certID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/{certID}/download": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/certificates/certID/download/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/{certID}/renew": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/certificates/certID/renew/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/certificates/{certID}/upload": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/certificates/certID/upload/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/proxy-hosts": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/proxy-hosts/{hostID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/hostID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/hostID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/hostID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/proxy-hosts/{hostID}/enable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/hostID/enable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/proxy-hosts/{hostID}/disable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/proxy-hosts/hostID/disable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/redirection-hosts": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/redirection-hosts/{hostID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/hostID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/hostID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/hostID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/redirection-hosts/{hostID}/enable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/hostID/enable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/redirection-hosts/{hostID}/disable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/redirection-hosts/hostID/disable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/dead-hosts": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/dead-hosts/{hostID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/hostID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/hostID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/hostID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/dead-hosts/{hostID}/enable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/hostID/enable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/dead-hosts/{hostID}/disable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/dead-hosts/hostID/disable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/streams": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/streams/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/streams/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/streams/{streamID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/nginx/streams/streamID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/nginx/streams/streamID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/nginx/streams/streamID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/streams/{streamID}/enable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/streams/streamID/enable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/nginx/streams/{streamID}/disable": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/nginx/streams/streamID/disable/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/reports/hosts": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/reports/hosts/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/schema": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/schema/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/settings": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/settings/get.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/settings/{settingID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/settings/settingID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/settings/settingID/put.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/tokens": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/tokens/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/tokens/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/users": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/users/get.json"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/users/post.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/users/{userID}": {
|
|
||||||
"get": {
|
|
||||||
"$ref": "./paths/users/userID/get.json"
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/users/userID/put.json"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"$ref": "./paths/users/userID/delete.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/users/{userID}/auth": {
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/users/userID/auth/put.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/users/{userID}/permissions": {
|
|
||||||
"put": {
|
|
||||||
"$ref": "./paths/users/userID/permissions/put.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/users/{userID}/login": {
|
|
||||||
"post": {
|
|
||||||
"$ref": "./paths/users/userID/login/post.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
53
README.md
53
README.md
@@ -10,8 +10,9 @@ DocuPulse is a powerful document management system designed to streamline docume
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js (version 18 or higher)
|
- Python 3.11 or higher
|
||||||
- npm or yarn
|
- PostgreSQL 13 or higher
|
||||||
|
- Docker and Docker Compose (for containerized deployment)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -23,18 +24,50 @@ cd docupulse
|
|||||||
|
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
npm install
|
pip install -r requirements.txt
|
||||||
# or
|
|
||||||
yarn install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start the development server:
|
3. Set up environment variables:
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
# Copy example environment file
|
||||||
# or
|
cp .env.example .env
|
||||||
yarn dev
|
|
||||||
|
# Set version information for local development
|
||||||
|
python set_version.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
4. Initialize the database:
|
||||||
|
```bash
|
||||||
|
flask db upgrade
|
||||||
|
flask create-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start the development server:
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Tracking
|
||||||
|
|
||||||
|
DocuPulse uses a database-only approach for version tracking:
|
||||||
|
|
||||||
|
- **Environment Variables**: Version information is passed via environment variables (`APP_VERSION`, `GIT_COMMIT`, `GIT_BRANCH`, `DEPLOYED_AT`)
|
||||||
|
- **Database Storage**: Instance version information is stored in the `instances` table
|
||||||
|
- **API Endpoint**: Version information is available via `/api/version`
|
||||||
|
|
||||||
|
### Setting Version Information
|
||||||
|
|
||||||
|
For local development:
|
||||||
|
```bash
|
||||||
|
python set_version.py
|
||||||
|
```
|
||||||
|
|
||||||
|
For production deployments, set the following environment variables:
|
||||||
|
- `APP_VERSION`: Application version/tag
|
||||||
|
- `GIT_COMMIT`: Git commit hash
|
||||||
|
- `GIT_BRANCH`: Git branch name
|
||||||
|
- `DEPLOYED_AT`: Deployment timestamp
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Document upload and management
|
- Document upload and management
|
||||||
@@ -42,6 +75,8 @@ yarn dev
|
|||||||
- Secure document storage
|
- Secure document storage
|
||||||
- User authentication and authorization
|
- User authentication and authorization
|
||||||
- Document version control
|
- Document version control
|
||||||
|
- Multi-tenant instance management
|
||||||
|
- RESTful API
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
4
app.py
4
app.py
@@ -36,6 +36,10 @@ def create_app():
|
|||||||
app.config['CSS_VERSION'] = os.getenv('CSS_VERSION', '1.0.3') # Add CSS version for cache busting
|
app.config['CSS_VERSION'] = os.getenv('CSS_VERSION', '1.0.3') # Add CSS version for cache busting
|
||||||
app.config['SERVER_NAME'] = os.getenv('SERVER_NAME', '127.0.0.1:5000')
|
app.config['SERVER_NAME'] = os.getenv('SERVER_NAME', '127.0.0.1:5000')
|
||||||
app.config['PREFERRED_URL_SCHEME'] = os.getenv('PREFERRED_URL_SCHEME', 'http')
|
app.config['PREFERRED_URL_SCHEME'] = os.getenv('PREFERRED_URL_SCHEME', 'http')
|
||||||
|
|
||||||
|
# Configure request timeouts for long-running operations
|
||||||
|
app.config['REQUEST_TIMEOUT'] = int(os.getenv('REQUEST_TIMEOUT', '300')) # 5 minutes default
|
||||||
|
app.config['STACK_DEPLOYMENT_TIMEOUT'] = int(os.getenv('STACK_DEPLOYMENT_TIMEOUT', '300')) # 5 minutes for stack deployment
|
||||||
|
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import os
|
|
||||||
import shutil
|
|
||||||
from app import create_app
|
|
||||||
from models import db, RoomFile, Room, RoomMemberPermission
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
def clear_all_data():
|
|
||||||
with app.app_context():
|
|
||||||
# Delete records in the correct order to handle foreign key constraints
|
|
||||||
# 1. Delete all RoomFile records from the database
|
|
||||||
RoomFile.query.delete()
|
|
||||||
print("All RoomFile records deleted.")
|
|
||||||
|
|
||||||
# 2. Delete all RoomMemberPermission records
|
|
||||||
RoomMemberPermission.query.delete()
|
|
||||||
print("All RoomMemberPermission records deleted.")
|
|
||||||
|
|
||||||
# 3. Delete all room_members associations
|
|
||||||
db.session.execute(text('DELETE FROM room_members'))
|
|
||||||
print("All room_members associations deleted.")
|
|
||||||
|
|
||||||
# 4. Delete all Room records
|
|
||||||
Room.query.delete()
|
|
||||||
print("All Room records deleted.")
|
|
||||||
|
|
||||||
# Commit the database changes
|
|
||||||
db.session.commit()
|
|
||||||
print("Database cleanup completed.")
|
|
||||||
|
|
||||||
def clear_filesystem():
|
|
||||||
# 1. Clear the data/rooms directory
|
|
||||||
data_root = os.path.join(os.path.dirname(__file__), 'data', 'rooms')
|
|
||||||
if os.path.exists(data_root):
|
|
||||||
for item in os.listdir(data_root):
|
|
||||||
item_path = os.path.join(data_root, item)
|
|
||||||
if os.path.isfile(item_path):
|
|
||||||
os.remove(item_path)
|
|
||||||
elif os.path.isdir(item_path):
|
|
||||||
shutil.rmtree(item_path)
|
|
||||||
print("Cleared data/rooms directory")
|
|
||||||
|
|
||||||
# 2. Clear the uploads directory except for profile_pics
|
|
||||||
uploads_dir = os.path.join(os.path.dirname(__file__), 'uploads')
|
|
||||||
if os.path.exists(uploads_dir):
|
|
||||||
for item in os.listdir(uploads_dir):
|
|
||||||
if item != 'profile_pics':
|
|
||||||
item_path = os.path.join(uploads_dir, item)
|
|
||||||
if os.path.isfile(item_path):
|
|
||||||
os.remove(item_path)
|
|
||||||
elif os.path.isdir(item_path):
|
|
||||||
shutil.rmtree(item_path)
|
|
||||||
print("Cleared uploads directory")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
clear_all_data()
|
|
||||||
clear_filesystem()
|
|
||||||
print("Cleanup completed successfully!")
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from app import create_app, db
|
|
||||||
from app.models import RoomFile, Room
|
|
||||||
import os
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
with app.app_context():
|
|
||||||
# Get the Test room
|
|
||||||
room = Room.query.filter_by(name='Test').first()
|
|
||||||
if not room:
|
|
||||||
print("Test room not found")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
# Delete from database
|
|
||||||
files = ['Screenshot_2025-03-19_100338.png', 'Screenshot_2025-03-19_100419.png']
|
|
||||||
deleted = RoomFile.query.filter_by(room_id=room.id, name__in=files).delete()
|
|
||||||
db.session.commit()
|
|
||||||
print(f"Deleted {deleted} records from database")
|
|
||||||
|
|
||||||
# Delete from filesystem
|
|
||||||
room_path = os.path.join('data', 'rooms', str(room.id))
|
|
||||||
for file in files:
|
|
||||||
file_path = os.path.join(room_path, file)
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
print(f"Deleted file: {file_path}")
|
|
||||||
else:
|
|
||||||
print(f"File not found: {file_path}")
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from app import app, db
|
|
||||||
from models import Notif
|
|
||||||
|
|
||||||
def create_notifs_table():
|
|
||||||
with app.app_context():
|
|
||||||
# Create the table
|
|
||||||
Notif.__table__.create(db.engine)
|
|
||||||
print("Notifications table created successfully!")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
create_notifs_table()
|
|
||||||
@@ -21,6 +21,17 @@ services:
|
|||||||
- POSTGRES_PASSWORD=docupulse_${PORT:-10335}
|
- POSTGRES_PASSWORD=docupulse_${PORT:-10335}
|
||||||
- POSTGRES_DB=docupulse_${PORT:-10335}
|
- POSTGRES_DB=docupulse_${PORT:-10335}
|
||||||
- MASTER=${ISMASTER:-false}
|
- MASTER=${ISMASTER:-false}
|
||||||
|
- APP_VERSION=${APP_VERSION:-unknown}
|
||||||
|
- GIT_COMMIT=${GIT_COMMIT:-unknown}
|
||||||
|
- GIT_BRANCH=${GIT_BRANCH:-unknown}
|
||||||
|
- DEPLOYED_AT=${DEPLOYED_AT:-unknown}
|
||||||
|
- PRICING_TIER_ID=${PRICING_TIER_ID:-0}
|
||||||
|
- PRICING_TIER_NAME=${PRICING_TIER_NAME:-Unknown}
|
||||||
|
- ROOM_QUOTA=${ROOM_QUOTA:-0}
|
||||||
|
- CONVERSATION_QUOTA=${CONVERSATION_QUOTA:-0}
|
||||||
|
- STORAGE_QUOTA_GB=${STORAGE_QUOTA_GB:-0}
|
||||||
|
- MANAGER_QUOTA=${MANAGER_QUOTA:-0}
|
||||||
|
- ADMIN_QUOTA=${ADMIN_QUOTA:-0}
|
||||||
volumes:
|
volumes:
|
||||||
- docupulse_uploads:/app/uploads
|
- docupulse_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -71,8 +71,25 @@ with app.app_context():
|
|||||||
# Create admin user if it doesn't exist
|
# Create admin user if it doesn't exist
|
||||||
print('Creating admin user...')
|
print('Creating admin user...')
|
||||||
try:
|
try:
|
||||||
admin = User.query.filter_by(email='administrator@docupulse.com').first()
|
# Check for admin user by both username and email to avoid constraint violations
|
||||||
if not admin:
|
admin_by_username = User.query.filter_by(username='administrator').first()
|
||||||
|
admin_by_email = User.query.filter_by(email='administrator@docupulse.com').first()
|
||||||
|
|
||||||
|
if admin_by_username and admin_by_email and admin_by_username.id == admin_by_email.id:
|
||||||
|
print('Admin user already exists (found by both username and email).')
|
||||||
|
print('Admin credentials:')
|
||||||
|
print('Email: administrator@docupulse.com')
|
||||||
|
print('Password: changeme')
|
||||||
|
elif admin_by_username or admin_by_email:
|
||||||
|
print('WARNING: Found partial admin user data:')
|
||||||
|
if admin_by_username:
|
||||||
|
print(f' - Found user with username "administrator" (ID: {admin_by_username.id})')
|
||||||
|
if admin_by_email:
|
||||||
|
print(f' - Found user with email "administrator@docupulse.com" (ID: {admin_by_email.id})')
|
||||||
|
print('Admin credentials:')
|
||||||
|
print('Email: administrator@docupulse.com')
|
||||||
|
print('Password: changeme')
|
||||||
|
else:
|
||||||
print('Admin user not found, creating new admin user...')
|
print('Admin user not found, creating new admin user...')
|
||||||
admin = User(
|
admin = User(
|
||||||
username='administrator',
|
username='administrator',
|
||||||
@@ -93,15 +110,26 @@ with app.app_context():
|
|||||||
print('Admin credentials:')
|
print('Admin credentials:')
|
||||||
print('Email: administrator@docupulse.com')
|
print('Email: administrator@docupulse.com')
|
||||||
print('Password: changeme')
|
print('Password: changeme')
|
||||||
except Exception as e:
|
except Exception as commit_error:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
log_error('Failed to commit admin user creation', e)
|
if 'duplicate key value violates unique constraint' in str(commit_error):
|
||||||
raise
|
print('WARNING: Admin user creation failed due to duplicate key constraint.')
|
||||||
else:
|
print('This might indicate a race condition or the user was created by another process.')
|
||||||
print('Admin user already exists.')
|
print('Checking for existing admin user again...')
|
||||||
print('Admin credentials:')
|
# Check again after the failed commit
|
||||||
print('Email: administrator@docupulse.com')
|
admin_by_username = User.query.filter_by(username='administrator').first()
|
||||||
print('Password: changeme')
|
admin_by_email = User.query.filter_by(email='administrator@docupulse.com').first()
|
||||||
|
if admin_by_username or admin_by_email:
|
||||||
|
print('Admin user now exists (likely created by another process).')
|
||||||
|
print('Admin credentials:')
|
||||||
|
print('Email: administrator@docupulse.com')
|
||||||
|
print('Password: changeme')
|
||||||
|
else:
|
||||||
|
log_error('Admin user creation failed and user still not found', commit_error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
log_error('Failed to commit admin user creation', commit_error)
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_error('Error during admin user creation/check', e)
|
log_error('Error during admin user creation/check', e)
|
||||||
raise
|
raise
|
||||||
|
|||||||
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()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""add_foreign_key_to_customer_subscription_plan_id
|
||||||
|
|
||||||
|
Revision ID: 3198363f8c4f
|
||||||
|
Revises: add_customer_table
|
||||||
|
Create Date: 2025-06-26 14:35:09.377247
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '3198363f8c4f'
|
||||||
|
down_revision = 'add_customer_table'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""replace_stripe_links_with_product_ids
|
||||||
|
|
||||||
|
Revision ID: 421f02ac5f59
|
||||||
|
Revises: add_stripe_payment_links
|
||||||
|
Create Date: 2025-06-26 13:49:45.124311
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '421f02ac5f59'
|
||||||
|
down_revision = 'add_stripe_payment_links'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Check if new columns already exist
|
||||||
|
result = conn.execute(text("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'pricing_plans'
|
||||||
|
AND column_name IN ('stripe_product_id', 'stripe_monthly_price_id', 'stripe_annual_price_id')
|
||||||
|
"""))
|
||||||
|
existing_columns = [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
# Add new Stripe product/price ID columns if they don't exist
|
||||||
|
if 'stripe_product_id' not in existing_columns:
|
||||||
|
op.add_column('pricing_plans', sa.Column('stripe_product_id', sa.String(length=100), nullable=True))
|
||||||
|
|
||||||
|
if 'stripe_monthly_price_id' not in existing_columns:
|
||||||
|
op.add_column('pricing_plans', sa.Column('stripe_monthly_price_id', sa.String(length=100), nullable=True))
|
||||||
|
|
||||||
|
if 'stripe_annual_price_id' not in existing_columns:
|
||||||
|
op.add_column('pricing_plans', sa.Column('stripe_annual_price_id', sa.String(length=100), nullable=True))
|
||||||
|
|
||||||
|
# Note: We'll keep the old payment link columns for now to allow for a gradual migration
|
||||||
|
# They can be removed in a future migration after the new system is fully implemented
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Remove the new Stripe product/price ID columns
|
||||||
|
op.drop_column('pricing_plans', 'stripe_annual_price_id')
|
||||||
|
op.drop_column('pricing_plans', 'stripe_monthly_price_id')
|
||||||
|
op.drop_column('pricing_plans', 'stripe_product_id')
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""add portainer stack fields to instances
|
||||||
|
|
||||||
|
Revision ID: 9206bf87bb8e
|
||||||
|
Revises: add_quota_fields
|
||||||
|
Create Date: 2025-06-24 14:02:17.375785
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '9206bf87bb8e'
|
||||||
|
down_revision = 'add_quota_fields'
|
||||||
|
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 = 'instances'
|
||||||
|
AND column_name IN ('portainer_stack_id', 'portainer_stack_name')
|
||||||
|
"""))
|
||||||
|
existing_columns = [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
# Add portainer stack columns if they don't exist
|
||||||
|
with op.batch_alter_table('instances', schema=None) as batch_op:
|
||||||
|
if 'portainer_stack_id' not in existing_columns:
|
||||||
|
batch_op.add_column(sa.Column('portainer_stack_id', sa.String(length=100), nullable=True))
|
||||||
|
if 'portainer_stack_name' not in existing_columns:
|
||||||
|
batch_op.add_column(sa.Column('portainer_stack_name', sa.String(length=100), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('instances', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('portainer_stack_name')
|
||||||
|
batch_op.drop_column('portainer_stack_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
57
migrations/versions/add_customer_table.py
Normal file
57
migrations/versions/add_customer_table.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""add customer table for Stripe customers
|
||||||
|
|
||||||
|
Revision ID: add_customer_table
|
||||||
|
Revises: 421f02ac5f59
|
||||||
|
Create Date: 2025-06-27 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'add_customer_table'
|
||||||
|
down_revision = '421f02ac5f59'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
if 'customer' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'customer',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||||
|
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
|
||||||
|
sa.Column('email', sa.String(150), nullable=False),
|
||||||
|
sa.Column('name', sa.String(150), nullable=True),
|
||||||
|
sa.Column('phone', sa.String(50), nullable=True),
|
||||||
|
sa.Column('billing_address_line1', sa.String(255), nullable=True),
|
||||||
|
sa.Column('billing_address_line2', sa.String(255), nullable=True),
|
||||||
|
sa.Column('billing_city', sa.String(100), nullable=True),
|
||||||
|
sa.Column('billing_state', sa.String(100), nullable=True),
|
||||||
|
sa.Column('billing_postal_code', sa.String(20), nullable=True),
|
||||||
|
sa.Column('billing_country', sa.String(100), nullable=True),
|
||||||
|
sa.Column('shipping_address_line1', sa.String(255), nullable=True),
|
||||||
|
sa.Column('shipping_address_line2', sa.String(255), nullable=True),
|
||||||
|
sa.Column('shipping_city', sa.String(100), nullable=True),
|
||||||
|
sa.Column('shipping_state', sa.String(100), nullable=True),
|
||||||
|
sa.Column('shipping_postal_code', sa.String(20), nullable=True),
|
||||||
|
sa.Column('shipping_country', sa.String(100), nullable=True),
|
||||||
|
sa.Column('tax_id_type', sa.String(50), nullable=True),
|
||||||
|
sa.Column('tax_id_value', sa.String(100), nullable=True),
|
||||||
|
sa.Column('stripe_customer_id', sa.String(255), nullable=True),
|
||||||
|
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
|
||||||
|
sa.Column('subscription_status', sa.String(50), nullable=True),
|
||||||
|
sa.Column('subscription_plan_id', sa.Integer, nullable=True),
|
||||||
|
sa.Column('subscription_billing_cycle', sa.String(20), nullable=True),
|
||||||
|
sa.Column('subscription_current_period_start', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('subscription_current_period_end', sa.DateTime, nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index('idx_customer_email', 'customer', ['email'])
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('idx_customer_email', table_name='customer')
|
||||||
|
op.drop_table('customer')
|
||||||
56
migrations/versions/add_help_articles_table.py
Normal file
56
migrations/versions/add_help_articles_table.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""add help articles table
|
||||||
|
|
||||||
|
Revision ID: add_help_articles_table
|
||||||
|
Revises: c94c2b2b9f2e
|
||||||
|
Create Date: 2024-12-19 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'add_help_articles_table'
|
||||||
|
down_revision = 'c94c2b2b9f2e'
|
||||||
|
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 = 'help_articles'
|
||||||
|
);
|
||||||
|
"""))
|
||||||
|
exists = result.scalar()
|
||||||
|
if not exists:
|
||||||
|
op.create_table('help_articles',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('category', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('body', sa.Text(), nullable=False),
|
||||||
|
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.Column('is_published', sa.Boolean(), nullable=True, server_default='true'),
|
||||||
|
sa.Column('order_index', sa.Integer(), nullable=True, server_default='0'),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for better performance
|
||||||
|
op.create_index('idx_help_articles_category', 'help_articles', ['category'])
|
||||||
|
op.create_index('idx_help_articles_published', 'help_articles', ['is_published'])
|
||||||
|
op.create_index('idx_help_articles_order', 'help_articles', ['order_index'])
|
||||||
|
op.create_index('idx_help_articles_created_at', 'help_articles', ['created_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('idx_help_articles_category', table_name='help_articles')
|
||||||
|
op.drop_index('idx_help_articles_published', table_name='help_articles')
|
||||||
|
op.drop_index('idx_help_articles_order', table_name='help_articles')
|
||||||
|
op.drop_index('idx_help_articles_created_at', table_name='help_articles')
|
||||||
|
op.drop_table('help_articles')
|
||||||
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')
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""add stripe payment links to pricing plans
|
||||||
|
|
||||||
|
Revision ID: add_stripe_payment_links
|
||||||
|
Revises: 9206bf87bb8e
|
||||||
|
Create Date: 2024-12-19 13:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'add_stripe_payment_links'
|
||||||
|
down_revision = '9206bf87bb8e'
|
||||||
|
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 ('monthly_stripe_link', 'annual_stripe_link')
|
||||||
|
"""))
|
||||||
|
existing_columns = [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
# Add Stripe payment link columns if they don't exist
|
||||||
|
if 'monthly_stripe_link' not in existing_columns:
|
||||||
|
op.add_column('pricing_plans', sa.Column('monthly_stripe_link', sa.String(length=500), nullable=True))
|
||||||
|
|
||||||
|
if 'annual_stripe_link' not in existing_columns:
|
||||||
|
op.add_column('pricing_plans', sa.Column('annual_stripe_link', sa.String(length=500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Remove Stripe payment link columns
|
||||||
|
op.drop_column('pricing_plans', 'annual_stripe_link')
|
||||||
|
op.drop_column('pricing_plans', 'monthly_stripe_link')
|
||||||
@@ -20,11 +20,21 @@ def upgrade():
|
|||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.drop_table('email_template')
|
op.drop_table('email_template')
|
||||||
op.drop_table('notification')
|
op.drop_table('notification')
|
||||||
|
|
||||||
|
# Check if columns already exist before adding them
|
||||||
|
connection = op.get_bind()
|
||||||
|
inspector = sa.inspect(connection)
|
||||||
|
existing_columns = [col['name'] for col in inspector.get_columns('instances')]
|
||||||
|
|
||||||
with op.batch_alter_table('instances', schema=None) as batch_op:
|
with op.batch_alter_table('instances', schema=None) as batch_op:
|
||||||
batch_op.add_column(sa.Column('deployed_version', sa.String(length=50), nullable=True))
|
if 'deployed_version' not in existing_columns:
|
||||||
batch_op.add_column(sa.Column('deployed_branch', sa.String(length=100), nullable=True))
|
batch_op.add_column(sa.Column('deployed_version', sa.String(length=50), nullable=True))
|
||||||
batch_op.add_column(sa.Column('latest_version', sa.String(length=50), nullable=True))
|
if 'deployed_branch' not in existing_columns:
|
||||||
batch_op.add_column(sa.Column('version_checked_at', sa.DateTime(), nullable=True))
|
batch_op.add_column(sa.Column('deployed_branch', sa.String(length=100), nullable=True))
|
||||||
|
if 'latest_version' not in existing_columns:
|
||||||
|
batch_op.add_column(sa.Column('latest_version', sa.String(length=50), nullable=True))
|
||||||
|
if 'version_checked_at' not in existing_columns:
|
||||||
|
batch_op.add_column(sa.Column('version_checked_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
||||||
batch_op.drop_constraint(batch_op.f('fk_room_file_deleted_by_user'), type_='foreignkey')
|
batch_op.drop_constraint(batch_op.f('fk_room_file_deleted_by_user'), type_='foreignkey')
|
||||||
@@ -37,11 +47,20 @@ def downgrade():
|
|||||||
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
||||||
batch_op.create_foreign_key(batch_op.f('fk_room_file_deleted_by_user'), 'user', ['deleted_by'], ['id'])
|
batch_op.create_foreign_key(batch_op.f('fk_room_file_deleted_by_user'), 'user', ['deleted_by'], ['id'])
|
||||||
|
|
||||||
|
# Check if columns exist before dropping them
|
||||||
|
connection = op.get_bind()
|
||||||
|
inspector = sa.inspect(connection)
|
||||||
|
existing_columns = [col['name'] for col in inspector.get_columns('instances')]
|
||||||
|
|
||||||
with op.batch_alter_table('instances', schema=None) as batch_op:
|
with op.batch_alter_table('instances', schema=None) as batch_op:
|
||||||
batch_op.drop_column('version_checked_at')
|
if 'version_checked_at' in existing_columns:
|
||||||
batch_op.drop_column('latest_version')
|
batch_op.drop_column('version_checked_at')
|
||||||
batch_op.drop_column('deployed_branch')
|
if 'latest_version' in existing_columns:
|
||||||
batch_op.drop_column('deployed_version')
|
batch_op.drop_column('latest_version')
|
||||||
|
if 'deployed_branch' in existing_columns:
|
||||||
|
batch_op.drop_column('deployed_branch')
|
||||||
|
if 'deployed_version' in existing_columns:
|
||||||
|
batch_op.drop_column('deployed_version')
|
||||||
|
|
||||||
op.create_table('notification',
|
op.create_table('notification',
|
||||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add_foreign_key_to_customer_subscription_plan_id
|
||||||
|
|
||||||
|
Revision ID: cc03b4419053
|
||||||
|
Revises: 3198363f8c4f
|
||||||
|
Create Date: 2025-06-26 14:35:15.661164
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'cc03b4419053'
|
||||||
|
down_revision = '3198363f8c4f'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add foreign key constraint if it doesn't exist
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_customer_subscription_plan_id',
|
||||||
|
'customer', 'pricing_plan',
|
||||||
|
['subscription_plan_id'], ['id'],
|
||||||
|
ondelete='SET NULL'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_constraint('fk_customer_subscription_plan_id', 'customer', type_='foreignkey')
|
||||||
184
models.py
184
models.py
@@ -528,6 +528,9 @@ class Instance(db.Model):
|
|||||||
status = db.Column(db.String(20), nullable=False, default='inactive')
|
status = db.Column(db.String(20), nullable=False, default='inactive')
|
||||||
status_details = db.Column(db.Text, nullable=True)
|
status_details = db.Column(db.Text, nullable=True)
|
||||||
connection_token = db.Column(db.String(64), unique=True, nullable=True)
|
connection_token = db.Column(db.String(64), unique=True, nullable=True)
|
||||||
|
# Portainer integration fields
|
||||||
|
portainer_stack_id = db.Column(db.String(100), nullable=True) # Portainer stack ID
|
||||||
|
portainer_stack_name = db.Column(db.String(100), nullable=True) # Portainer stack name
|
||||||
# Version tracking fields
|
# Version tracking fields
|
||||||
deployed_version = db.Column(db.String(50), nullable=True) # Current deployed version (commit hash or tag)
|
deployed_version = db.Column(db.String(50), nullable=True) # Current deployed version (commit hash or tag)
|
||||||
deployed_branch = db.Column(db.String(100), nullable=True) # Branch that was deployed
|
deployed_branch = db.Column(db.String(100), nullable=True) # Branch that was deployed
|
||||||
@@ -537,4 +540,183 @@ class Instance(db.Model):
|
|||||||
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP'), onupdate=db.text('CURRENT_TIMESTAMP'))
|
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP'), onupdate=db.text('CURRENT_TIMESTAMP'))
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Instance {self.name}>'
|
return f'<Instance {self.name}>'
|
||||||
|
|
||||||
|
class HelpArticle(db.Model):
|
||||||
|
__tablename__ = 'help_articles'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
category = db.Column(db.String(50), nullable=False) # getting-started, user-management, file-management, communication, security, administration
|
||||||
|
body = db.Column(db.Text, nullable=False) # Rich text content
|
||||||
|
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)
|
||||||
|
is_published = db.Column(db.Boolean, default=True)
|
||||||
|
order_index = db.Column(db.Integer, default=0) # For ordering articles within categories
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
creator = db.relationship('User', backref=db.backref('created_help_articles', cascade='all, delete-orphan'), foreign_keys=[created_by])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<HelpArticle {self.title} ({self.category})>'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_categories(cls):
|
||||||
|
"""Get all available categories with their display names"""
|
||||||
|
return {
|
||||||
|
'getting-started': 'Getting Started',
|
||||||
|
'user-management': 'User Management',
|
||||||
|
'file-management': 'File Management',
|
||||||
|
'communication': 'Communication',
|
||||||
|
'security': 'Security & Privacy',
|
||||||
|
'administration': 'Administration'
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_articles_by_category(cls, category, published_only=True):
|
||||||
|
"""Get articles for a specific category"""
|
||||||
|
query = cls.query.filter_by(category=category)
|
||||||
|
if published_only:
|
||||||
|
query = query.filter_by(is_published=True)
|
||||||
|
return query.order_by(cls.order_index.asc(), cls.created_at.desc()).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all_published(cls):
|
||||||
|
"""Get all published articles grouped by category"""
|
||||||
|
articles = cls.query.filter_by(is_published=True).order_by(cls.order_index.asc(), cls.created_at.desc()).all()
|
||||||
|
grouped = {}
|
||||||
|
for article in articles:
|
||||||
|
if article.category not in grouped:
|
||||||
|
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='#')
|
||||||
|
# Stripe integration fields
|
||||||
|
stripe_product_id = db.Column(db.String(100), nullable=True)
|
||||||
|
stripe_monthly_price_id = db.Column(db.String(100), nullable=True)
|
||||||
|
stripe_annual_price_id = db.Column(db.String(100), nullable=True)
|
||||||
|
# Deprecated: Stripe payment links (to be removed in a future migration)
|
||||||
|
monthly_stripe_link = db.Column(db.String(500), nullable=True) # DEPRECATED
|
||||||
|
annual_stripe_link = db.Column(db.String(500), nullable=True) # DEPRECATED
|
||||||
|
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
|
||||||
|
|
||||||
|
class Customer(db.Model):
|
||||||
|
__tablename__ = 'customer'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
email = db.Column(db.String(150), nullable=False, index=True)
|
||||||
|
name = db.Column(db.String(150))
|
||||||
|
phone = db.Column(db.String(50))
|
||||||
|
billing_address_line1 = db.Column(db.String(255))
|
||||||
|
billing_address_line2 = db.Column(db.String(255))
|
||||||
|
billing_city = db.Column(db.String(100))
|
||||||
|
billing_state = db.Column(db.String(100))
|
||||||
|
billing_postal_code = db.Column(db.String(20))
|
||||||
|
billing_country = db.Column(db.String(100))
|
||||||
|
shipping_address_line1 = db.Column(db.String(255))
|
||||||
|
shipping_address_line2 = db.Column(db.String(255))
|
||||||
|
shipping_city = db.Column(db.String(100))
|
||||||
|
shipping_state = db.Column(db.String(100))
|
||||||
|
shipping_postal_code = db.Column(db.String(20))
|
||||||
|
shipping_country = db.Column(db.String(100))
|
||||||
|
tax_id_type = db.Column(db.String(50))
|
||||||
|
tax_id_value = db.Column(db.String(100))
|
||||||
|
stripe_customer_id = db.Column(db.String(255))
|
||||||
|
stripe_subscription_id = db.Column(db.String(255))
|
||||||
|
subscription_status = db.Column(db.String(50))
|
||||||
|
subscription_plan_id = db.Column(db.Integer, db.ForeignKey('pricing_plans.id'))
|
||||||
|
subscription_billing_cycle = db.Column(db.String(20))
|
||||||
|
subscription_current_period_start = db.Column(db.DateTime)
|
||||||
|
subscription_current_period_end = db.Column(db.DateTime)
|
||||||
|
# Relationship to pricing plan
|
||||||
|
plan = db.relationship('PricingPlan', backref='customers')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Customer {self.email}>'
|
||||||
@@ -13,4 +13,5 @@ psycopg2-binary==2.9.9
|
|||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
prometheus-client>=0.16.0
|
prometheus-client>=0.16.0
|
||||||
PyJWT>=2.8.0
|
PyJWT>=2.8.0
|
||||||
|
stripe>=7.0.0
|
||||||
Binary file not shown.
Binary file not shown.
584
routes/admin.py
584
routes/admin.py
@@ -1,8 +1,12 @@
|
|||||||
from flask import Blueprint, jsonify
|
from flask import Blueprint, jsonify, request, render_template, flash, redirect, url_for, current_app
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from models import db, Room, RoomFile, User, DocuPulseSettings
|
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle, Customer
|
||||||
|
from extensions import csrf
|
||||||
|
from utils.event_logger import log_event
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from routes.auth import require_password_change
|
||||||
|
|
||||||
admin = Blueprint('admin', __name__)
|
admin = Blueprint('admin', __name__)
|
||||||
|
|
||||||
@@ -253,4 +257,578 @@ def get_usage_stats():
|
|||||||
stats = DocuPulseSettings.get_usage_stats()
|
stats = DocuPulseSettings.get_usage_stats()
|
||||||
return jsonify(stats)
|
return jsonify(stats)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
|
def update_help_article(article_id):
|
||||||
|
"""Update a help article"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
article = HelpArticle.query.get_or_404(article_id)
|
||||||
|
|
||||||
|
title = request.form.get('title')
|
||||||
|
category = request.form.get('category')
|
||||||
|
body = request.form.get('body')
|
||||||
|
order_index = int(request.form.get('order_index', 0))
|
||||||
|
is_published = request.form.get('is_published') == 'true'
|
||||||
|
|
||||||
|
if not title or not category or not body:
|
||||||
|
return jsonify({'error': 'Title, category, and body are required'}), 400
|
||||||
|
|
||||||
|
# Validate category
|
||||||
|
valid_categories = HelpArticle.get_categories().keys()
|
||||||
|
if category not in valid_categories:
|
||||||
|
return jsonify({'error': 'Invalid category'}), 400
|
||||||
|
|
||||||
|
article.title = title
|
||||||
|
article.category = category
|
||||||
|
article.body = body
|
||||||
|
article.order_index = order_index
|
||||||
|
article.is_published = is_published
|
||||||
|
article.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log the event
|
||||||
|
log_event(
|
||||||
|
event_type='help_article_update',
|
||||||
|
details={
|
||||||
|
'article_id': article.id,
|
||||||
|
'title': article.title,
|
||||||
|
'category': article.category,
|
||||||
|
'updated_by': f"{current_user.username} {current_user.last_name}"
|
||||||
|
},
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Article updated successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
|
def delete_help_article(article_id):
|
||||||
|
"""Delete a help article"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
article = HelpArticle.query.get_or_404(article_id)
|
||||||
|
|
||||||
|
# Log the event before deletion
|
||||||
|
log_event(
|
||||||
|
event_type='help_article_delete',
|
||||||
|
details={
|
||||||
|
'article_id': article.id,
|
||||||
|
'title': article.title,
|
||||||
|
'category': article.category,
|
||||||
|
'deleted_by': f"{current_user.username} {current_user.last_name}"
|
||||||
|
},
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.delete(article)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Article deleted successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
# Help Articles API endpoints
|
||||||
|
@admin.route('/api/admin/help-articles', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
|
def get_help_articles():
|
||||||
|
"""Get all help articles"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
articles = HelpArticle.query.order_by(HelpArticle.category.asc(), HelpArticle.order_index.asc(), HelpArticle.created_at.desc()).all()
|
||||||
|
|
||||||
|
articles_data = []
|
||||||
|
for article in articles:
|
||||||
|
articles_data.append({
|
||||||
|
'id': article.id,
|
||||||
|
'title': article.title,
|
||||||
|
'category': article.category,
|
||||||
|
'body': article.body,
|
||||||
|
'created_at': article.created_at.isoformat() if article.created_at else None,
|
||||||
|
'updated_at': article.updated_at.isoformat() if article.updated_at else None,
|
||||||
|
'created_by': article.created_by,
|
||||||
|
'is_published': article.is_published,
|
||||||
|
'order_index': article.order_index
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'articles': articles_data})
|
||||||
|
|
||||||
|
@admin.route('/api/admin/help-articles', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
|
def create_help_article():
|
||||||
|
"""Create a new help article"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
title = request.form.get('title')
|
||||||
|
category = request.form.get('category')
|
||||||
|
body = request.form.get('body')
|
||||||
|
order_index = int(request.form.get('order_index', 0))
|
||||||
|
is_published = request.form.get('is_published') == 'true'
|
||||||
|
|
||||||
|
if not title or not category or not body:
|
||||||
|
return jsonify({'error': 'Title, category, and body are required'}), 400
|
||||||
|
|
||||||
|
# Validate category
|
||||||
|
valid_categories = HelpArticle.get_categories().keys()
|
||||||
|
if category not in valid_categories:
|
||||||
|
return jsonify({'error': 'Invalid category'}), 400
|
||||||
|
|
||||||
|
article = HelpArticle(
|
||||||
|
title=title,
|
||||||
|
category=category,
|
||||||
|
body=body,
|
||||||
|
order_index=order_index,
|
||||||
|
is_published=is_published,
|
||||||
|
created_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(article)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log the event
|
||||||
|
log_event(
|
||||||
|
event_type='help_article_create',
|
||||||
|
details={
|
||||||
|
'article_id': article.id,
|
||||||
|
'title': article.title,
|
||||||
|
'category': article.category,
|
||||||
|
'created_by': f"{current_user.username} {current_user.last_name}"
|
||||||
|
},
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Article created successfully', 'article_id': article.id})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
|
def get_help_article(article_id):
|
||||||
|
"""Get a specific help article"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
article = HelpArticle.query.get_or_404(article_id)
|
||||||
|
|
||||||
|
article_data = {
|
||||||
|
'id': article.id,
|
||||||
|
'title': article.title,
|
||||||
|
'category': article.category,
|
||||||
|
'body': article.body,
|
||||||
|
'created_at': article.created_at.isoformat() if article.created_at else None,
|
||||||
|
'updated_at': article.updated_at.isoformat() if article.updated_at else None,
|
||||||
|
'created_by': article.created_by,
|
||||||
|
'is_published': article.is_published,
|
||||||
|
'order_index': article.order_index
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
from utils.stripe_utils import create_stripe_product
|
||||||
|
|
||||||
|
# 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')
|
||||||
|
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 Stripe ID fields
|
||||||
|
stripe_product_id = request.form.get('stripe_product_id', '')
|
||||||
|
stripe_monthly_price_id = request.form.get('stripe_monthly_price_id', '')
|
||||||
|
stripe_annual_price_id = request.form.get('stripe_annual_price_id', '')
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
stripe_product_id=stripe_product_id,
|
||||||
|
stripe_monthly_price_id=stripe_monthly_price_id,
|
||||||
|
stripe_annual_price_id=stripe_annual_price_id,
|
||||||
|
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()
|
||||||
|
|
||||||
|
# If no Stripe IDs provided and plan is not custom, try to create Stripe product
|
||||||
|
if not is_custom and not stripe_product_id:
|
||||||
|
try:
|
||||||
|
stripe_data = create_stripe_product(plan)
|
||||||
|
plan.stripe_product_id = stripe_data['product_id']
|
||||||
|
plan.stripe_monthly_price_id = stripe_data['monthly_price_id']
|
||||||
|
plan.stripe_annual_price_id = stripe_data['annual_price_id']
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as stripe_error:
|
||||||
|
# Log the error but don't fail the plan creation
|
||||||
|
current_app.logger.warning(f"Failed to create Stripe product for plan {plan.name}: {str(stripe_error)}")
|
||||||
|
|
||||||
|
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,
|
||||||
|
'stripe_product_id': plan.stripe_product_id,
|
||||||
|
'stripe_monthly_price_id': plan.stripe_monthly_price_id,
|
||||||
|
'stripe_annual_price_id': plan.stripe_annual_price_id,
|
||||||
|
'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
|
||||||
|
from utils.stripe_utils import update_stripe_product
|
||||||
|
|
||||||
|
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')
|
||||||
|
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 Stripe ID fields
|
||||||
|
stripe_product_id = request.form.get('stripe_product_id', '')
|
||||||
|
stripe_monthly_price_id = request.form.get('stripe_monthly_price_id', '')
|
||||||
|
stripe_annual_price_id = request.form.get('stripe_annual_price_id', '')
|
||||||
|
|
||||||
|
# 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.stripe_product_id = stripe_product_id
|
||||||
|
plan.stripe_monthly_price_id = stripe_monthly_price_id
|
||||||
|
plan.stripe_annual_price_id = stripe_annual_price_id
|
||||||
|
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()
|
||||||
|
|
||||||
|
# If plan has existing Stripe product and is not custom, try to update it
|
||||||
|
if not is_custom and plan.stripe_product_id:
|
||||||
|
try:
|
||||||
|
stripe_data = update_stripe_product(plan)
|
||||||
|
plan.stripe_product_id = stripe_data['product_id']
|
||||||
|
plan.stripe_monthly_price_id = stripe_data['monthly_price_id']
|
||||||
|
plan.stripe_annual_price_id = stripe_data['annual_price_id']
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as stripe_error:
|
||||||
|
# Log the error but don't fail the plan update
|
||||||
|
current_app.logger.warning(f"Failed to update Stripe product for plan {plan.name}: {str(stripe_error)}")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@admin.route('/api/admin/pricing-plans', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_pricing_plans():
|
||||||
|
"""Get all active pricing plans for instance launch"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
from models import PricingPlan
|
||||||
|
|
||||||
|
# Get all active pricing plans ordered by order_index
|
||||||
|
plans = PricingPlan.query.filter_by(is_active=True).order_by(PricingPlan.order_index).all()
|
||||||
|
|
||||||
|
plans_data = []
|
||||||
|
for plan in plans:
|
||||||
|
plans_data.append({
|
||||||
|
'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,
|
||||||
|
'format_quota_display': {
|
||||||
|
'room_quota': plan.format_quota_display('room_quota'),
|
||||||
|
'conversation_quota': plan.format_quota_display('conversation_quota'),
|
||||||
|
'storage_quota_gb': plan.format_quota_display('storage_quota_gb'),
|
||||||
|
'manager_quota': plan.format_quota_display('manager_quota'),
|
||||||
|
'admin_quota': plan.format_quota_display('admin_quota')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'plans': plans_data
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@admin.route('/customers')
|
||||||
|
@login_required
|
||||||
|
@require_password_change
|
||||||
|
def customers():
|
||||||
|
"""View all customers"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
flash('Access denied. Admin privileges required.', 'error')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
# Check if this is a MASTER instance
|
||||||
|
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
|
||||||
|
if not is_master:
|
||||||
|
flash('Access denied. Master admin privileges required.', 'error')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
# Get all customers with pagination
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = 20
|
||||||
|
|
||||||
|
customers = Customer.query.order_by(Customer.created_at.desc()).paginate(
|
||||||
|
page=page, per_page=per_page, error_out=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template('admin/customers.html', customers=customers)
|
||||||
|
|
||||||
|
@admin.route('/customers/<int:customer_id>')
|
||||||
|
@login_required
|
||||||
|
@require_password_change
|
||||||
|
def get_customer_details(customer_id):
|
||||||
|
"""Get customer details for modal"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Access denied'}), 403
|
||||||
|
|
||||||
|
# Check if this is a MASTER instance
|
||||||
|
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
|
||||||
|
if not is_master:
|
||||||
|
return jsonify({'error': 'Access denied'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
customer = Customer.query.get_or_404(customer_id)
|
||||||
|
|
||||||
|
# Get the associated plan
|
||||||
|
plan = None
|
||||||
|
if customer.subscription_plan_id:
|
||||||
|
from models import PricingPlan
|
||||||
|
plan = PricingPlan.query.get(customer.subscription_plan_id)
|
||||||
|
|
||||||
|
html = render_template('admin/customer_details_modal.html', customer=customer, plan=plan)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'html': html
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
@@ -2,9 +2,11 @@ from flask import Blueprint, jsonify, request, current_app, make_response, flash
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from models import (
|
from models import (
|
||||||
KeyValueSettings, User, Room, Conversation, RoomFile,
|
KeyValueSettings, User, Room, Conversation, RoomFile,
|
||||||
SiteSettings, DocuPulseSettings, Event, Mail, ManagementAPIKey, PasswordSetupToken, PasswordResetToken
|
SiteSettings, DocuPulseSettings, Event, Mail, ManagementAPIKey, PasswordSetupToken, PasswordResetToken,
|
||||||
|
HelpArticle
|
||||||
)
|
)
|
||||||
from extensions import db, csrf
|
from extensions import db, csrf
|
||||||
|
from utils import log_event
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import os
|
import os
|
||||||
import jwt
|
import jwt
|
||||||
@@ -563,4 +565,34 @@ def generate_password_reset_token(current_user, user_id):
|
|||||||
'reset_url': reset_url,
|
'reset_url': reset_url,
|
||||||
'expires_at': reset_token.expires_at.isoformat(),
|
'expires_at': reset_token.expires_at.isoformat(),
|
||||||
'user_email': user.email
|
'user_email': user.email
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Version Information
|
||||||
|
@admin_api.route('/version-info', methods=['GET'])
|
||||||
|
@csrf.exempt
|
||||||
|
@token_required
|
||||||
|
def get_version_info(current_user):
|
||||||
|
"""Get version information from environment variables"""
|
||||||
|
try:
|
||||||
|
version_info = {
|
||||||
|
'app_version': os.environ.get('APP_VERSION', 'unknown'),
|
||||||
|
'git_commit': os.environ.get('GIT_COMMIT', 'unknown'),
|
||||||
|
'git_branch': os.environ.get('GIT_BRANCH', 'unknown'),
|
||||||
|
'deployed_at': os.environ.get('DEPLOYED_AT', 'unknown'),
|
||||||
|
'ismaster': os.environ.get('ISMASTER', 'false'),
|
||||||
|
'port': os.environ.get('PORT', 'unknown'),
|
||||||
|
'pricing_tier_name': os.environ.get('PRICING_TIER_NAME', 'unknown')
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(version_info)
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error getting version info: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'error': str(e),
|
||||||
|
'app_version': 'unknown',
|
||||||
|
'git_commit': 'unknown',
|
||||||
|
'git_branch': 'unknown',
|
||||||
|
'deployed_at': 'unknown',
|
||||||
|
'pricing_tier_name': 'unknown'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,6 @@ def list_gitea_repos():
|
|||||||
return jsonify({'message': 'Missing required fields'}), 400
|
return jsonify({'message': 'Missing required fields'}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try different authentication methods
|
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
}
|
}
|
||||||
@@ -761,48 +760,6 @@ def download_docker_compose():
|
|||||||
else:
|
else:
|
||||||
content = response.text
|
content = response.text
|
||||||
|
|
||||||
# Add version.txt creation to the docker-compose content
|
|
||||||
if commit_hash:
|
|
||||||
# Create version information with both tag and commit hash
|
|
||||||
version_info = {
|
|
||||||
'tag': latest_tag or 'unknown',
|
|
||||||
'commit': commit_hash,
|
|
||||||
'branch': data['branch'],
|
|
||||||
'deployed_at': datetime.utcnow().isoformat()
|
|
||||||
}
|
|
||||||
version_json = json.dumps(version_info, indent=2)
|
|
||||||
|
|
||||||
# Add a command to create version.txt with the version information
|
|
||||||
version_command = f'echo \'{version_json}\' > /app/version.txt'
|
|
||||||
|
|
||||||
# Find the web service and add the command
|
|
||||||
if 'web:' in content:
|
|
||||||
# Add the command to create version.txt before the main command
|
|
||||||
lines = content.split('\n')
|
|
||||||
new_lines = []
|
|
||||||
in_web_service = False
|
|
||||||
command_added = False
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
new_lines.append(line)
|
|
||||||
|
|
||||||
if line.strip() == 'web:':
|
|
||||||
in_web_service = True
|
|
||||||
elif in_web_service and line.strip().startswith('command:'):
|
|
||||||
# Add the version.txt creation command before the main command
|
|
||||||
new_lines.append(f' - sh -c "{version_command} && {line.split("command:")[1].strip()}"')
|
|
||||||
command_added = True
|
|
||||||
continue
|
|
||||||
elif in_web_service and line.strip() and not line.startswith(' ') and not line.startswith('#'):
|
|
||||||
# We've left the web service section
|
|
||||||
if not command_added:
|
|
||||||
# If no command was found, add a new command section
|
|
||||||
new_lines.append(f' command: sh -c "{version_command} && python app.py"')
|
|
||||||
command_added = True
|
|
||||||
in_web_service = False
|
|
||||||
|
|
||||||
content = '\n'.join(new_lines)
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'content': content,
|
'content': content,
|
||||||
@@ -831,6 +788,9 @@ def deploy_stack():
|
|||||||
if not portainer_settings:
|
if not portainer_settings:
|
||||||
return jsonify({'error': 'Portainer settings not configured'}), 400
|
return jsonify({'error': 'Portainer settings not configured'}), 400
|
||||||
|
|
||||||
|
# Define timeout early to ensure it's available throughout the function
|
||||||
|
stack_timeout = current_app.config.get('STACK_DEPLOYMENT_TIMEOUT', 300) # Default to 5 minutes
|
||||||
|
|
||||||
# Verify Portainer authentication
|
# Verify Portainer authentication
|
||||||
auth_response = requests.get(
|
auth_response = requests.get(
|
||||||
f"{portainer_settings['url'].rstrip('/')}/api/status",
|
f"{portainer_settings['url'].rstrip('/')}/api/status",
|
||||||
@@ -872,6 +832,7 @@ def deploy_stack():
|
|||||||
# Log the request data
|
# Log the request data
|
||||||
current_app.logger.info(f"Creating stack with data: {json.dumps(data)}")
|
current_app.logger.info(f"Creating stack with data: {json.dumps(data)}")
|
||||||
current_app.logger.info(f"Using endpoint ID: {endpoint_id}")
|
current_app.logger.info(f"Using endpoint ID: {endpoint_id}")
|
||||||
|
current_app.logger.info(f"Using timeout: {stack_timeout} seconds")
|
||||||
|
|
||||||
# First, check if a stack with this name already exists
|
# First, check if a stack with this name already exists
|
||||||
stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks"
|
stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks"
|
||||||
@@ -891,9 +852,12 @@ def deploy_stack():
|
|||||||
if stack['Name'] == data['name']:
|
if stack['Name'] == data['name']:
|
||||||
current_app.logger.info(f"Found existing stack: {stack['Name']} (ID: {stack['Id']})")
|
current_app.logger.info(f"Found existing stack: {stack['Name']} (ID: {stack['Id']})")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'name': stack['Name'],
|
'success': True,
|
||||||
'id': stack['Id'],
|
'data': {
|
||||||
'status': 'existing'
|
'name': stack['Name'],
|
||||||
|
'id': stack['Id'],
|
||||||
|
'status': 'existing'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# If no existing stack found, proceed with creation
|
# If no existing stack found, proceed with creation
|
||||||
@@ -906,7 +870,7 @@ def deploy_stack():
|
|||||||
# Add endpointId as a query parameter
|
# Add endpointId as a query parameter
|
||||||
params = {'endpointId': endpoint_id}
|
params = {'endpointId': endpoint_id}
|
||||||
|
|
||||||
# Set a longer timeout for stack creation (10 minutes)
|
# Use a configurable timeout for stack creation initiation
|
||||||
create_response = requests.post(
|
create_response = requests.post(
|
||||||
url,
|
url,
|
||||||
headers={
|
headers={
|
||||||
@@ -916,7 +880,7 @@ def deploy_stack():
|
|||||||
},
|
},
|
||||||
params=params,
|
params=params,
|
||||||
json=request_body,
|
json=request_body,
|
||||||
timeout=600 # 10 minutes timeout for stack creation
|
timeout=stack_timeout # Use configurable timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log the response details
|
# Log the response details
|
||||||
@@ -936,15 +900,26 @@ def deploy_stack():
|
|||||||
return jsonify({'error': f'Failed to create stack: {error_message}'}), 500
|
return jsonify({'error': f'Failed to create stack: {error_message}'}), 500
|
||||||
|
|
||||||
stack_info = create_response.json()
|
stack_info = create_response.json()
|
||||||
|
current_app.logger.info(f"Stack creation initiated: {stack_info['Name']} (ID: {stack_info['Id']})")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'name': stack_info['Name'],
|
'success': True,
|
||||||
'id': stack_info['Id'],
|
'data': {
|
||||||
'status': 'created'
|
'name': stack_info['Name'],
|
||||||
|
'id': stack_info['Id'],
|
||||||
|
'status': 'creating'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
current_app.logger.error("Request timed out while deploying stack")
|
current_app.logger.error(f"Request timed out after {stack_timeout} seconds while initiating stack deployment")
|
||||||
return jsonify({'error': 'Request timed out while deploying stack. The operation may still be in progress.'}), 504
|
current_app.logger.error(f"Stack name: {data.get('name', 'unknown') if 'data' in locals() else 'unknown'}")
|
||||||
|
current_app.logger.error(f"Portainer URL: {portainer_settings.get('url', 'unknown') if 'portainer_settings' in locals() else 'unknown'}")
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Request timed out after {stack_timeout} seconds while initiating stack deployment. The operation may still be in progress.',
|
||||||
|
'timeout_seconds': stack_timeout,
|
||||||
|
'stack_name': data.get('name', 'unknown') if 'data' in locals() else 'unknown'
|
||||||
|
}), 504
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Error deploying stack: {str(e)}")
|
current_app.logger.error(f"Error deploying stack: {str(e)}")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
@@ -954,8 +929,8 @@ def deploy_stack():
|
|||||||
def check_stack_status():
|
def check_stack_status():
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or 'stack_name' not in data:
|
if not data or ('stack_name' not in data and 'stack_id' not in data):
|
||||||
return jsonify({'error': 'Missing stack_name field'}), 400
|
return jsonify({'error': 'Missing stack_name or stack_id field'}), 400
|
||||||
|
|
||||||
# Get Portainer settings
|
# Get Portainer settings
|
||||||
portainer_settings = KeyValueSettings.get_value('portainer_settings')
|
portainer_settings = KeyValueSettings.get_value('portainer_settings')
|
||||||
@@ -981,86 +956,123 @@ def check_stack_status():
|
|||||||
|
|
||||||
endpoint_id = endpoints[0]['Id']
|
endpoint_id = endpoints[0]['Id']
|
||||||
|
|
||||||
# Get stack information
|
# Get stack information - support both stack_name and stack_id
|
||||||
stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks"
|
if 'stack_id' in data:
|
||||||
stacks_response = requests.get(
|
# Get stack by ID
|
||||||
stacks_url,
|
stack_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
|
||||||
headers={
|
stack_response = requests.get(
|
||||||
'X-API-Key': portainer_settings['api_key'],
|
stack_url,
|
||||||
'Accept': 'application/json'
|
headers={
|
||||||
},
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
params={'filters': json.dumps({'Name': data['stack_name']})},
|
'Accept': 'application/json'
|
||||||
timeout=30
|
},
|
||||||
)
|
params={'endpointId': endpoint_id},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
if not stacks_response.ok:
|
if not stack_response.ok:
|
||||||
return jsonify({'error': 'Failed to get stack information'}), 500
|
return jsonify({'error': f'Stack with ID {data["stack_id"]} not found'}), 404
|
||||||
|
|
||||||
stacks = stacks_response.json()
|
target_stack = stack_response.json()
|
||||||
target_stack = None
|
else:
|
||||||
|
# Get stack by name (existing logic)
|
||||||
for stack in stacks:
|
stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks"
|
||||||
if stack['Name'] == data['stack_name']:
|
stacks_response = requests.get(
|
||||||
target_stack = stack
|
stacks_url,
|
||||||
break
|
headers={
|
||||||
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
params={'filters': json.dumps({'Name': data['stack_name']})},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
if not target_stack:
|
if not stacks_response.ok:
|
||||||
return jsonify({'error': f'Stack {data["stack_name"]} not found'}), 404
|
return jsonify({'error': 'Failed to get stack information'}), 500
|
||||||
|
|
||||||
|
stacks = stacks_response.json()
|
||||||
|
target_stack = None
|
||||||
|
|
||||||
|
for stack in stacks:
|
||||||
|
if stack['Name'] == data['stack_name']:
|
||||||
|
target_stack = stack
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_stack:
|
||||||
|
return jsonify({'error': f'Stack {data["stack_name"]} not found'}), 404
|
||||||
|
|
||||||
# Get stack services to check their status
|
# Get stack services to check their status
|
||||||
services_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/services"
|
services_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/services"
|
||||||
services_response = requests.get(
|
current_app.logger.info(f"Checking services for stack {target_stack['Name']} at endpoint {endpoint_id}")
|
||||||
services_url,
|
|
||||||
headers={
|
|
||||||
'X-API-Key': portainer_settings['api_key'],
|
|
||||||
'Accept': 'application/json'
|
|
||||||
},
|
|
||||||
params={'filters': json.dumps({'label': f'com.docker.stack.namespace={data["stack_name"]}'})},
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
if not services_response.ok:
|
|
||||||
return jsonify({'error': 'Failed to get stack services'}), 500
|
|
||||||
|
|
||||||
services = services_response.json()
|
|
||||||
|
|
||||||
# Check if all services are running
|
try:
|
||||||
all_running = True
|
services_response = requests.get(
|
||||||
service_statuses = []
|
services_url,
|
||||||
|
headers={
|
||||||
for service in services:
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
replicas_running = service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0)
|
'Accept': 'application/json'
|
||||||
replicas_actual = service.get('ServiceStatus', {}).get('RunningTasks', 0)
|
},
|
||||||
|
params={'filters': json.dumps({'label': f'com.docker.stack.namespace={target_stack["Name"]}'})},
|
||||||
service_status = {
|
timeout=30
|
||||||
'name': service.get('Spec', {}).get('Name', 'Unknown'),
|
)
|
||||||
'replicas_expected': replicas_running,
|
|
||||||
'replicas_running': replicas_actual,
|
|
||||||
'status': 'running' if replicas_actual >= replicas_running else 'not_running'
|
|
||||||
}
|
|
||||||
|
|
||||||
service_statuses.append(service_status)
|
|
||||||
|
|
||||||
if replicas_actual < replicas_running:
|
|
||||||
all_running = False
|
|
||||||
|
|
||||||
# Determine overall stack status
|
current_app.logger.info(f"Services API response status: {services_response.status_code}")
|
||||||
if all_running and len(services) > 0:
|
|
||||||
status = 'active'
|
if services_response.ok:
|
||||||
elif len(services) > 0:
|
services = services_response.json()
|
||||||
status = 'partial'
|
service_statuses = []
|
||||||
else:
|
|
||||||
status = 'inactive'
|
for service in services:
|
||||||
|
service_statuses.append({
|
||||||
|
'name': service.get('Spec', {}).get('Name', 'Unknown'),
|
||||||
|
'replicas': service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0),
|
||||||
|
'running_replicas': service.get('ServiceStatus', {}).get('RunningTasks', 0),
|
||||||
|
'desired_replicas': service.get('ServiceStatus', {}).get('DesiredTasks', 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Determine overall stack status
|
||||||
|
if not service_statuses:
|
||||||
|
status = 'starting' # No services found yet
|
||||||
|
else:
|
||||||
|
all_running = all(s['running_replicas'] >= s['desired_replicas'] for s in service_statuses if s['desired_replicas'] > 0)
|
||||||
|
any_running = any(s['running_replicas'] > 0 for s in service_statuses)
|
||||||
|
|
||||||
|
if all_running:
|
||||||
|
status = 'active'
|
||||||
|
elif any_running:
|
||||||
|
status = 'partial'
|
||||||
|
else:
|
||||||
|
status = 'inactive'
|
||||||
|
else:
|
||||||
|
# Services API failed, but stack exists - assume it's still starting up
|
||||||
|
current_app.logger.warning(f"Failed to get services for stack {target_stack['Name']}: {services_response.status_code} - {services_response.text}")
|
||||||
|
|
||||||
|
# Provide more specific error context
|
||||||
|
if services_response.status_code == 404:
|
||||||
|
current_app.logger.info(f"Services endpoint not found for stack {target_stack['Name']} - stack may still be initializing")
|
||||||
|
elif services_response.status_code == 403:
|
||||||
|
current_app.logger.warning(f"Access denied to services for stack {target_stack['Name']} - check Portainer permissions")
|
||||||
|
elif services_response.status_code >= 500:
|
||||||
|
current_app.logger.warning(f"Portainer server error when getting services for stack {target_stack['Name']}")
|
||||||
|
|
||||||
|
services = []
|
||||||
|
service_statuses = []
|
||||||
|
status = 'starting' # Stack exists but services not available yet
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Exception occurred while getting services, but stack exists
|
||||||
|
current_app.logger.warning(f"Exception getting services for stack {target_stack['Name']}: {str(e)}")
|
||||||
|
services = []
|
||||||
|
service_statuses = []
|
||||||
|
status = 'starting' # Stack exists but services not available yet
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': {
|
'data': {
|
||||||
'stack_name': data['stack_name'],
|
'name': target_stack['Name'],
|
||||||
'stack_id': target_stack['Id'],
|
'stack_id': target_stack['Id'],
|
||||||
'status': status,
|
'status': status,
|
||||||
'services': service_statuses,
|
'services': service_statuses
|
||||||
'total_services': len(services),
|
|
||||||
'running_services': len([s for s in service_statuses if s['status'] == 'running'])
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1087,13 +1099,9 @@ def save_instance():
|
|||||||
|
|
||||||
if existing_instance:
|
if existing_instance:
|
||||||
# Update existing instance
|
# Update existing instance
|
||||||
existing_instance.port = data['port']
|
existing_instance.portainer_stack_id = data['stack_id']
|
||||||
existing_instance.domains = data['domains']
|
existing_instance.portainer_stack_name = data['stack_name']
|
||||||
existing_instance.stack_id = data['stack_id']
|
|
||||||
existing_instance.stack_name = data['stack_name']
|
|
||||||
existing_instance.status = data['status']
|
existing_instance.status = data['status']
|
||||||
existing_instance.repository = data['repository']
|
|
||||||
existing_instance.branch = data['branch']
|
|
||||||
existing_instance.deployed_version = data.get('deployed_version', 'unknown')
|
existing_instance.deployed_version = data.get('deployed_version', 'unknown')
|
||||||
existing_instance.deployed_branch = data.get('deployed_branch', data['branch'])
|
existing_instance.deployed_branch = data.get('deployed_branch', data['branch'])
|
||||||
existing_instance.version_checked_at = datetime.utcnow()
|
existing_instance.version_checked_at = datetime.utcnow()
|
||||||
@@ -1104,13 +1112,9 @@ def save_instance():
|
|||||||
'message': 'Instance data updated successfully',
|
'message': 'Instance data updated successfully',
|
||||||
'data': {
|
'data': {
|
||||||
'name': existing_instance.name,
|
'name': existing_instance.name,
|
||||||
'port': existing_instance.port,
|
'portainer_stack_id': existing_instance.portainer_stack_id,
|
||||||
'domains': existing_instance.domains,
|
'portainer_stack_name': existing_instance.portainer_stack_name,
|
||||||
'stack_id': existing_instance.stack_id,
|
|
||||||
'stack_name': existing_instance.stack_name,
|
|
||||||
'status': existing_instance.status,
|
'status': existing_instance.status,
|
||||||
'repository': existing_instance.repository,
|
|
||||||
'branch': existing_instance.branch,
|
|
||||||
'deployed_version': existing_instance.deployed_version,
|
'deployed_version': existing_instance.deployed_version,
|
||||||
'deployed_branch': existing_instance.deployed_branch
|
'deployed_branch': existing_instance.deployed_branch
|
||||||
}
|
}
|
||||||
@@ -1123,14 +1127,11 @@ def save_instance():
|
|||||||
rooms_count=0,
|
rooms_count=0,
|
||||||
conversations_count=0,
|
conversations_count=0,
|
||||||
data_size=0.0,
|
data_size=0.0,
|
||||||
payment_plan='Basic',
|
payment_plan=data.get('payment_plan', 'Basic'),
|
||||||
main_url=f"https://{data['domains'][0]}" if data['domains'] else f"http://localhost:{data['port']}",
|
main_url=f"https://{data['domains'][0]}" if data['domains'] else f"http://localhost:{data['port']}",
|
||||||
status=data['status'],
|
status=data['status'],
|
||||||
port=data['port'],
|
portainer_stack_id=data['stack_id'],
|
||||||
stack_id=data['stack_id'],
|
portainer_stack_name=data['stack_name'],
|
||||||
stack_name=data['stack_name'],
|
|
||||||
repository=data['repository'],
|
|
||||||
branch=data['branch'],
|
|
||||||
deployed_version=data.get('deployed_version', 'unknown'),
|
deployed_version=data.get('deployed_version', 'unknown'),
|
||||||
deployed_branch=data.get('deployed_branch', data['branch'])
|
deployed_branch=data.get('deployed_branch', data['branch'])
|
||||||
)
|
)
|
||||||
@@ -1142,13 +1143,9 @@ def save_instance():
|
|||||||
'message': 'Instance data saved successfully',
|
'message': 'Instance data saved successfully',
|
||||||
'data': {
|
'data': {
|
||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'port': instance.port,
|
'portainer_stack_id': instance.portainer_stack_id,
|
||||||
'domains': instance.domains,
|
'portainer_stack_name': instance.portainer_stack_name,
|
||||||
'stack_id': instance.stack_id,
|
|
||||||
'stack_name': instance.stack_name,
|
|
||||||
'status': instance.status,
|
'status': instance.status,
|
||||||
'repository': instance.repository,
|
|
||||||
'branch': instance.branch,
|
|
||||||
'deployed_version': instance.deployed_version,
|
'deployed_version': instance.deployed_version,
|
||||||
'deployed_branch': instance.deployed_branch
|
'deployed_branch': instance.deployed_branch
|
||||||
}
|
}
|
||||||
@@ -1866,4 +1863,216 @@ def copy_smtp_settings():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Error copying SMTP settings: {str(e)}")
|
current_app.logger.error(f"Error copying SMTP settings: {str(e)}")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@launch_api.route('/update-stack', methods=['POST'])
|
||||||
|
@csrf.exempt
|
||||||
|
def update_stack():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'stack_id' not in data:
|
||||||
|
return jsonify({'error': 'Missing required fields'}), 400
|
||||||
|
|
||||||
|
# Get Portainer settings
|
||||||
|
portainer_settings = KeyValueSettings.get_value('portainer_settings')
|
||||||
|
if not portainer_settings:
|
||||||
|
return jsonify({'error': 'Portainer settings not configured'}), 400
|
||||||
|
|
||||||
|
# Define timeout early to ensure it's available throughout the function
|
||||||
|
stack_timeout = current_app.config.get('STACK_DEPLOYMENT_TIMEOUT', 300) # Default to 5 minutes
|
||||||
|
|
||||||
|
# Verify Portainer authentication
|
||||||
|
auth_response = requests.get(
|
||||||
|
f"{portainer_settings['url'].rstrip('/')}/api/status",
|
||||||
|
headers={
|
||||||
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=30 # 30 seconds timeout for status check
|
||||||
|
)
|
||||||
|
|
||||||
|
if not auth_response.ok:
|
||||||
|
current_app.logger.error(f"Portainer authentication failed: {auth_response.text}")
|
||||||
|
return jsonify({'error': 'Failed to authenticate with Portainer'}), 401
|
||||||
|
|
||||||
|
# Get Portainer endpoint ID (assuming it's the first endpoint)
|
||||||
|
endpoint_response = requests.get(
|
||||||
|
f"{portainer_settings['url'].rstrip('/')}/api/endpoints",
|
||||||
|
headers={
|
||||||
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=30 # 30 seconds timeout for endpoint check
|
||||||
|
)
|
||||||
|
if not endpoint_response.ok:
|
||||||
|
error_text = endpoint_response.text
|
||||||
|
try:
|
||||||
|
error_json = endpoint_response.json()
|
||||||
|
error_text = error_json.get('message', error_text)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return jsonify({'error': f'Failed to get Portainer endpoints: {error_text}'}), 500
|
||||||
|
|
||||||
|
endpoints = endpoint_response.json()
|
||||||
|
if not endpoints:
|
||||||
|
return jsonify({'error': 'No Portainer endpoints found'}), 400
|
||||||
|
|
||||||
|
endpoint_id = endpoints[0]['Id']
|
||||||
|
|
||||||
|
# Log the request data
|
||||||
|
current_app.logger.info(f"Updating stack with ID: {data['stack_id']}")
|
||||||
|
current_app.logger.info(f"Using endpoint ID: {endpoint_id}")
|
||||||
|
current_app.logger.info(f"Using timeout: {stack_timeout} seconds")
|
||||||
|
|
||||||
|
# First, verify the stack exists and get its current configuration
|
||||||
|
stack_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
|
||||||
|
stack_response = requests.get(
|
||||||
|
stack_url,
|
||||||
|
headers={
|
||||||
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
params={'endpointId': endpoint_id},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if not stack_response.ok:
|
||||||
|
return jsonify({'error': f'Stack with ID {data["stack_id"]} not found'}), 404
|
||||||
|
|
||||||
|
stack_info = stack_response.json()
|
||||||
|
current_app.logger.info(f"Found existing stack: {stack_info['Name']} (ID: {stack_info['Id']})")
|
||||||
|
|
||||||
|
# Get the current stack file content from Portainer
|
||||||
|
stack_file_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}/file"
|
||||||
|
stack_file_response = requests.get(
|
||||||
|
stack_file_url,
|
||||||
|
headers={
|
||||||
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
params={'endpointId': endpoint_id},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if not stack_file_response.ok:
|
||||||
|
current_app.logger.error(f"Failed to get stack file: {stack_file_response.text}")
|
||||||
|
return jsonify({'error': f'Failed to get existing stack file: {stack_file_response.text}'}), 500
|
||||||
|
|
||||||
|
stack_file_data = stack_file_response.json()
|
||||||
|
current_stack_file_content = stack_file_data.get('StackFileContent')
|
||||||
|
|
||||||
|
if not current_stack_file_content:
|
||||||
|
current_app.logger.error("No StackFileContent found in existing stack")
|
||||||
|
return jsonify({'error': 'No existing stack file content found'}), 500
|
||||||
|
|
||||||
|
current_app.logger.info("Retrieved existing stack file content")
|
||||||
|
|
||||||
|
# Get existing environment variables from the stack
|
||||||
|
existing_env_vars = stack_file_data.get('Env', [])
|
||||||
|
current_app.logger.info(f"Retrieved {len(existing_env_vars)} existing environment variables")
|
||||||
|
|
||||||
|
# Create a dictionary of existing environment variables for easy lookup
|
||||||
|
existing_env_dict = {env['name']: env['value'] for env in existing_env_vars}
|
||||||
|
current_app.logger.info(f"Existing environment variables: {list(existing_env_dict.keys())}")
|
||||||
|
|
||||||
|
# Get new environment variables from the request
|
||||||
|
new_env_vars = data.get('Env', [])
|
||||||
|
current_app.logger.info(f"New environment variables to update: {[env['name'] for env in new_env_vars]}")
|
||||||
|
|
||||||
|
# Merge existing and new environment variables
|
||||||
|
# Start with existing variables
|
||||||
|
merged_env_vars = existing_env_vars.copy()
|
||||||
|
|
||||||
|
# Update with new variables (this will overwrite existing ones with the same name)
|
||||||
|
for new_env in new_env_vars:
|
||||||
|
# Find if this environment variable already exists
|
||||||
|
existing_index = None
|
||||||
|
for i, existing_env in enumerate(merged_env_vars):
|
||||||
|
if existing_env['name'] == new_env['name']:
|
||||||
|
existing_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if existing_index is not None:
|
||||||
|
# Update existing variable
|
||||||
|
merged_env_vars[existing_index]['value'] = new_env['value']
|
||||||
|
current_app.logger.info(f"Updated environment variable: {new_env['name']} = {new_env['value']}")
|
||||||
|
else:
|
||||||
|
# Add new variable
|
||||||
|
merged_env_vars.append(new_env)
|
||||||
|
current_app.logger.info(f"Added new environment variable: {new_env['name']} = {new_env['value']}")
|
||||||
|
|
||||||
|
current_app.logger.info(f"Final merged environment variables: {[env['name'] for env in merged_env_vars]}")
|
||||||
|
|
||||||
|
# Update the stack using Portainer's update API
|
||||||
|
update_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
|
||||||
|
current_app.logger.info(f"Making update request to: {update_url}")
|
||||||
|
|
||||||
|
# Prepare the request body for stack update
|
||||||
|
request_body = {
|
||||||
|
'StackFileContent': current_stack_file_content, # Use existing stack file content
|
||||||
|
'Env': merged_env_vars # Use merged environment variables
|
||||||
|
}
|
||||||
|
|
||||||
|
# If new StackFileContent is provided, use it instead
|
||||||
|
if 'StackFileContent' in data:
|
||||||
|
request_body['StackFileContent'] = data['StackFileContent']
|
||||||
|
current_app.logger.info("Using provided StackFileContent for update")
|
||||||
|
else:
|
||||||
|
current_app.logger.info("Using existing StackFileContent for update")
|
||||||
|
|
||||||
|
# Add endpointId as a query parameter
|
||||||
|
params = {'endpointId': endpoint_id}
|
||||||
|
|
||||||
|
# Use a configurable timeout for stack update initiation
|
||||||
|
update_response = requests.put(
|
||||||
|
update_url,
|
||||||
|
headers={
|
||||||
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
params=params,
|
||||||
|
json=request_body,
|
||||||
|
timeout=stack_timeout # Use configurable timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log the response details
|
||||||
|
current_app.logger.info(f"Update response status: {update_response.status_code}")
|
||||||
|
current_app.logger.info(f"Update response headers: {dict(update_response.headers)}")
|
||||||
|
|
||||||
|
response_text = update_response.text
|
||||||
|
current_app.logger.info(f"Update response body: {response_text}")
|
||||||
|
|
||||||
|
if not update_response.ok:
|
||||||
|
error_message = response_text
|
||||||
|
try:
|
||||||
|
error_json = update_response.json()
|
||||||
|
error_message = error_json.get('message', error_message)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return jsonify({'error': f'Failed to update stack: {error_message}'}), 500
|
||||||
|
|
||||||
|
# Stack update initiated successfully
|
||||||
|
current_app.logger.info(f"Stack update initiated: {stack_info['Name']} (ID: {stack_info['Id']})")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'name': stack_info['Name'],
|
||||||
|
'id': stack_info['Id'],
|
||||||
|
'status': 'updating'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
current_app.logger.error(f"Request timed out after {stack_timeout} seconds while initiating stack update")
|
||||||
|
current_app.logger.error(f"Stack ID: {data.get('stack_id', 'unknown') if 'data' in locals() else 'unknown'}")
|
||||||
|
current_app.logger.error(f"Portainer URL: {portainer_settings.get('url', 'unknown') if 'portainer_settings' in locals() else 'unknown'}")
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Request timed out after {stack_timeout} seconds while initiating stack update. The operation may still be in progress.',
|
||||||
|
'timeout_seconds': stack_timeout,
|
||||||
|
'stack_id': data.get('stack_id', 'unknown') if 'data' in locals() else 'unknown'
|
||||||
|
}), 504
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error updating stack: {str(e)}")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
799
routes/main.py
799
routes/main.py
@@ -1,7 +1,8 @@
|
|||||||
from flask import render_template, Blueprint, redirect, url_for, request, flash, Response, jsonify, session, current_app
|
from flask import render_template, Blueprint, redirect, url_for, request, flash, Response, jsonify, session, current_app
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from models import User, db, Room, RoomFile, RoomMemberPermission, SiteSettings, Event, Conversation, Message, MessageAttachment, Notif, EmailTemplate, Mail, KeyValueSettings, DocuPulseSettings, PasswordSetupToken, Instance, ManagementAPIKey
|
from models import User, db, Room, RoomFile, RoomMemberPermission, SiteSettings, Event, Conversation, Message, MessageAttachment, Notif, EmailTemplate, Mail, KeyValueSettings, DocuPulseSettings, PasswordSetupToken, Instance, ManagementAPIKey, Customer, PricingPlan
|
||||||
from routes.auth import require_password_change
|
from routes.auth import require_password_change
|
||||||
|
from extensions import csrf
|
||||||
import os
|
import os
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from sqlalchemy import func, case, literal_column, text
|
from sqlalchemy import func, case, literal_column, text
|
||||||
@@ -19,6 +20,8 @@ import smtplib
|
|||||||
import requests
|
import requests
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import socket
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import stripe
|
||||||
|
|
||||||
# Set up logging to show in console
|
# Set up logging to show in console
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -350,7 +353,7 @@ def init_routes(main_bp):
|
|||||||
try:
|
try:
|
||||||
# Construct the health check URL
|
# Construct the health check URL
|
||||||
health_url = f"{instance.main_url.rstrip('/')}/health"
|
health_url = f"{instance.main_url.rstrip('/')}/health"
|
||||||
response = requests.get(health_url, timeout=5)
|
response = requests.get(health_url, timeout=30) # Increased timeout to 30 seconds
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -386,46 +389,10 @@ def init_routes(main_bp):
|
|||||||
gitea_repo = git_settings.get('repo') if git_settings else None
|
gitea_repo = git_settings.get('repo') if git_settings else None
|
||||||
|
|
||||||
for instance in instances:
|
for instance in instances:
|
||||||
# 1. Check status
|
# Check status
|
||||||
status_info = check_instance_status(instance)
|
status_info = check_instance_status(instance)
|
||||||
instance.status = status_info['status']
|
instance.status = status_info['status']
|
||||||
instance.status_details = status_info['details']
|
instance.status_details = status_info['details']
|
||||||
|
|
||||||
# 2. Check deployed version
|
|
||||||
deployed_version = None
|
|
||||||
deployed_tag = None
|
|
||||||
deployed_commit = None
|
|
||||||
try:
|
|
||||||
version_url = f"{instance.main_url.rstrip('/')}/api/version"
|
|
||||||
resp = requests.get(version_url, timeout=5)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
version_data = resp.json()
|
|
||||||
deployed_version = version_data.get('version', 'unknown')
|
|
||||||
deployed_tag = version_data.get('tag', 'unknown')
|
|
||||||
deployed_commit = version_data.get('commit', 'unknown')
|
|
||||||
except Exception as e:
|
|
||||||
deployed_version = None
|
|
||||||
deployed_tag = None
|
|
||||||
deployed_commit = None
|
|
||||||
|
|
||||||
instance.deployed_version = deployed_tag or deployed_version or 'unknown'
|
|
||||||
instance.deployed_branch = instance.deployed_branch or 'master'
|
|
||||||
|
|
||||||
# 3. Check latest version from Gitea (if settings available)
|
|
||||||
latest_version = None
|
|
||||||
deployed_branch = instance.deployed_branch or 'master'
|
|
||||||
if gitea_url and gitea_token and gitea_repo:
|
|
||||||
try:
|
|
||||||
headers = {'Accept': 'application/json', 'Authorization': f'token {gitea_token}'}
|
|
||||||
# Gitea API: /api/v1/repos/{owner}/{repo}/commits/{branch}
|
|
||||||
commit_url = f"{gitea_url}/api/v1/repos/{gitea_repo}/commits/{deployed_branch}"
|
|
||||||
commit_resp = requests.get(commit_url, headers=headers, timeout=5)
|
|
||||||
if commit_resp.status_code == 200:
|
|
||||||
latest_version = commit_resp.json().get('sha')
|
|
||||||
except Exception as e:
|
|
||||||
latest_version = None
|
|
||||||
instance.latest_version = latest_version or 'unknown'
|
|
||||||
instance.version_checked_at = datetime.utcnow()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -527,13 +494,197 @@ def init_routes(main_bp):
|
|||||||
return jsonify({'error': 'Unauthorized'}), 403
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
instance = Instance.query.get_or_404(instance_id)
|
instance = Instance.query.get_or_404(instance_id)
|
||||||
|
|
||||||
|
# Get Portainer settings
|
||||||
|
portainer_settings = KeyValueSettings.get_value('portainer_settings')
|
||||||
|
if not portainer_settings:
|
||||||
|
current_app.logger.warning(f"Portainer settings not configured, proceeding with database deletion only for instance {instance.name}")
|
||||||
|
# Continue with database deletion even if Portainer is not configured
|
||||||
|
try:
|
||||||
|
db.session.delete(instance)
|
||||||
|
db.session.commit()
|
||||||
|
current_app.logger.info(f"Successfully deleted instance from database: {instance.name}")
|
||||||
|
return jsonify({'message': 'Instance deleted from database (Portainer not configured)'})
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f"Error deleting instance {instance.name} from database: {str(e)}")
|
||||||
|
return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# First, delete the Portainer stack and its volumes if stack information exists
|
||||||
|
if instance.portainer_stack_id and instance.portainer_stack_name:
|
||||||
|
current_app.logger.info(f"Deleting Portainer stack: {instance.portainer_stack_name} (ID: {instance.portainer_stack_id})")
|
||||||
|
|
||||||
|
# Get Portainer endpoint ID (assuming it's the first endpoint)
|
||||||
|
try:
|
||||||
|
endpoint_response = requests.get(
|
||||||
|
f"{portainer_settings['url'].rstrip('/')}/api/endpoints",
|
||||||
|
headers={
|
||||||
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if not endpoint_response.ok:
|
||||||
|
current_app.logger.error(f"Failed to get Portainer endpoints: {endpoint_response.text}")
|
||||||
|
return jsonify({'error': 'Failed to get Portainer endpoints'}), 500
|
||||||
|
|
||||||
|
endpoints = endpoint_response.json()
|
||||||
|
if not endpoints:
|
||||||
|
current_app.logger.error("No Portainer endpoints found")
|
||||||
|
return jsonify({'error': 'No Portainer endpoints found'}), 400
|
||||||
|
|
||||||
|
endpoint_id = endpoints[0]['Id']
|
||||||
|
|
||||||
|
# Delete the stack (this will also remove associated volumes)
|
||||||
|
delete_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{instance.portainer_stack_id}"
|
||||||
|
delete_response = requests.delete(
|
||||||
|
delete_url,
|
||||||
|
headers={
|
||||||
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
params={'endpointId': endpoint_id},
|
||||||
|
timeout=60 # Give more time for stack deletion
|
||||||
|
)
|
||||||
|
|
||||||
|
if delete_response.ok:
|
||||||
|
current_app.logger.info(f"Successfully deleted Portainer stack: {instance.portainer_stack_name}")
|
||||||
|
else:
|
||||||
|
current_app.logger.warning(f"Failed to delete Portainer stack: {delete_response.status_code} - {delete_response.text}")
|
||||||
|
# Continue with database deletion even if Portainer deletion fails
|
||||||
|
|
||||||
|
# Also try to delete any orphaned volumes associated with this stack
|
||||||
|
try:
|
||||||
|
volumes_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/volumes"
|
||||||
|
volumes_response = requests.get(
|
||||||
|
volumes_url,
|
||||||
|
headers={
|
||||||
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if volumes_response.ok:
|
||||||
|
volumes = volumes_response.json().get('Volumes', [])
|
||||||
|
stack_volumes = [vol for vol in volumes if vol.get('Labels', {}).get('com.docker.stack.namespace') == instance.portainer_stack_name]
|
||||||
|
|
||||||
|
for volume in stack_volumes:
|
||||||
|
volume_name = volume.get('Name')
|
||||||
|
if volume_name:
|
||||||
|
delete_volume_url = f"{volumes_url}/{volume_name}"
|
||||||
|
volume_delete_response = requests.delete(
|
||||||
|
delete_volume_url,
|
||||||
|
headers={
|
||||||
|
'X-API-Key': portainer_settings['api_key'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if volume_delete_response.ok:
|
||||||
|
current_app.logger.info(f"Successfully deleted volume: {volume_name}")
|
||||||
|
else:
|
||||||
|
current_app.logger.warning(f"Failed to delete volume {volume_name}: {volume_delete_response.status_code}")
|
||||||
|
else:
|
||||||
|
current_app.logger.warning(f"Failed to get volumes list: {volumes_response.status_code}")
|
||||||
|
except Exception as volume_error:
|
||||||
|
current_app.logger.warning(f"Error cleaning up volumes: {str(volume_error)}")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as req_error:
|
||||||
|
current_app.logger.error(f"Network error during Portainer operations: {str(req_error)}")
|
||||||
|
# Continue with database deletion even if Portainer operations fail
|
||||||
|
else:
|
||||||
|
current_app.logger.info(f"No Portainer stack information found for instance {instance.name}, proceeding with database deletion only")
|
||||||
|
|
||||||
|
# Clean up NGINX proxy host if NGINX settings are configured
|
||||||
|
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
||||||
|
if nginx_settings and instance.main_url:
|
||||||
|
current_app.logger.info(f"Cleaning up NGINX proxy host for instance {instance.name}")
|
||||||
|
try:
|
||||||
|
# Extract domain from main_url
|
||||||
|
parsed_url = urlparse(instance.main_url)
|
||||||
|
domain = parsed_url.netloc
|
||||||
|
|
||||||
|
if domain:
|
||||||
|
# Get NGINX JWT token
|
||||||
|
token_response = requests.post(
|
||||||
|
f"{nginx_settings['url'].rstrip('/')}/api/tokens",
|
||||||
|
json={
|
||||||
|
'identity': nginx_settings['username'],
|
||||||
|
'secret': nginx_settings['password']
|
||||||
|
},
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_response.ok:
|
||||||
|
token_data = token_response.json()
|
||||||
|
token = token_data.get('token')
|
||||||
|
|
||||||
|
if token:
|
||||||
|
# Get all proxy hosts to find the one matching this domain
|
||||||
|
proxy_hosts_response = requests.get(
|
||||||
|
f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts",
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {token}',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if proxy_hosts_response.ok:
|
||||||
|
proxy_hosts = proxy_hosts_response.json()
|
||||||
|
|
||||||
|
# Find proxy host with matching domain
|
||||||
|
matching_proxy = None
|
||||||
|
for proxy_host in proxy_hosts:
|
||||||
|
if proxy_host.get('domain_names') and domain in proxy_host['domain_names']:
|
||||||
|
matching_proxy = proxy_host
|
||||||
|
break
|
||||||
|
|
||||||
|
if matching_proxy:
|
||||||
|
# Delete the proxy host
|
||||||
|
delete_response = requests.delete(
|
||||||
|
f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts/{matching_proxy['id']}",
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {token}',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if delete_response.ok:
|
||||||
|
current_app.logger.info(f"Successfully deleted NGINX proxy host for domain: {domain}")
|
||||||
|
else:
|
||||||
|
current_app.logger.warning(f"Failed to delete NGINX proxy host: {delete_response.status_code} - {delete_response.text}")
|
||||||
|
else:
|
||||||
|
current_app.logger.info(f"No NGINX proxy host found for domain: {domain}")
|
||||||
|
else:
|
||||||
|
current_app.logger.warning(f"Failed to get NGINX proxy hosts: {proxy_hosts_response.status_code}")
|
||||||
|
else:
|
||||||
|
current_app.logger.warning("No NGINX token received")
|
||||||
|
else:
|
||||||
|
current_app.logger.warning(f"Failed to authenticate with NGINX: {token_response.status_code}")
|
||||||
|
except Exception as nginx_error:
|
||||||
|
current_app.logger.warning(f"Error cleaning up NGINX proxy host: {str(nginx_error)}")
|
||||||
|
# Continue with database deletion even if NGINX cleanup fails
|
||||||
|
else:
|
||||||
|
current_app.logger.info(f"No NGINX settings configured or no main_url for instance {instance.name}, skipping NGINX cleanup")
|
||||||
|
|
||||||
|
# Now delete the instance from the database
|
||||||
db.session.delete(instance)
|
db.session.delete(instance)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'message': 'Instance deleted successfully'})
|
|
||||||
|
current_app.logger.info(f"Successfully deleted instance: {instance.name}")
|
||||||
|
return jsonify({'message': 'Instance and all associated resources (Portainer stack, volumes, NGINX proxy host) deleted successfully'})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({'error': str(e)}), 400
|
current_app.logger.error(f"Error deleting instance {instance.name}: {str(e)}")
|
||||||
|
return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500
|
||||||
|
|
||||||
@main_bp.route('/instances/<int:instance_id>/status')
|
@main_bp.route('/instances/<int:instance_id>/status')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -625,6 +776,32 @@ def init_routes(main_bp):
|
|||||||
|
|
||||||
return render_template('main/instance_detail.html', instance=instance)
|
return render_template('main/instance_detail.html', instance=instance)
|
||||||
|
|
||||||
|
@main_bp.route('/api/instances/<int:instance_id>')
|
||||||
|
@login_required
|
||||||
|
@require_password_change
|
||||||
|
def get_instance_data(instance_id):
|
||||||
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
instance = Instance.query.get_or_404(instance_id)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'instance': {
|
||||||
|
'id': instance.id,
|
||||||
|
'name': instance.name,
|
||||||
|
'company': instance.company,
|
||||||
|
'main_url': instance.main_url,
|
||||||
|
'status': instance.status,
|
||||||
|
'payment_plan': instance.payment_plan,
|
||||||
|
'portainer_stack_id': instance.portainer_stack_id,
|
||||||
|
'portainer_stack_name': instance.portainer_stack_name,
|
||||||
|
'deployed_version': instance.deployed_version,
|
||||||
|
'deployed_branch': instance.deployed_branch,
|
||||||
|
'connection_token': instance.connection_token
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
@main_bp.route('/instances/<int:instance_id>/auth-status')
|
@main_bp.route('/instances/<int:instance_id>/auth-status')
|
||||||
@login_required
|
@login_required
|
||||||
@require_password_change
|
@require_password_change
|
||||||
@@ -661,6 +838,188 @@ def init_routes(main_bp):
|
|||||||
'is_valid': is_valid
|
'is_valid': is_valid
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@main_bp.route('/instances/<int:instance_id>/version-info')
|
||||||
|
@login_required
|
||||||
|
@require_password_change
|
||||||
|
def instance_version_info(instance_id):
|
||||||
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
instance = Instance.query.get_or_404(instance_id)
|
||||||
|
|
||||||
|
# Check if instance has a connection token
|
||||||
|
if not instance.connection_token:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Instance not authenticated',
|
||||||
|
'deployed_version': instance.deployed_version,
|
||||||
|
'deployed_branch': instance.deployed_branch
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get JWT token using the connection token
|
||||||
|
jwt_response = requests.post(
|
||||||
|
f"{instance.main_url.rstrip('/')}/api/admin/management-token",
|
||||||
|
headers={
|
||||||
|
'X-API-Key': instance.connection_token,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if jwt_response.status_code != 200:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Failed to authenticate with instance',
|
||||||
|
'deployed_version': instance.deployed_version,
|
||||||
|
'deployed_branch': instance.deployed_branch
|
||||||
|
})
|
||||||
|
|
||||||
|
jwt_data = jwt_response.json()
|
||||||
|
jwt_token = jwt_data.get('token')
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'No JWT token received',
|
||||||
|
'deployed_version': instance.deployed_version,
|
||||||
|
'deployed_branch': instance.deployed_branch
|
||||||
|
})
|
||||||
|
|
||||||
|
# Fetch version information from the instance
|
||||||
|
response = requests.get(
|
||||||
|
f"{instance.main_url.rstrip('/')}/api/admin/version-info",
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {jwt_token}',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
version_data = response.json()
|
||||||
|
|
||||||
|
# Update the instance with the fetched version information
|
||||||
|
instance.deployed_version = version_data.get('app_version', instance.deployed_version)
|
||||||
|
instance.deployed_branch = version_data.get('git_branch', instance.deployed_branch)
|
||||||
|
instance.version_checked_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'deployed_version': instance.deployed_version,
|
||||||
|
'deployed_branch': instance.deployed_branch,
|
||||||
|
'git_commit': version_data.get('git_commit'),
|
||||||
|
'deployed_at': version_data.get('deployed_at'),
|
||||||
|
'version_checked_at': instance.version_checked_at.isoformat() if instance.version_checked_at else None
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Failed to fetch version info: {response.status_code}',
|
||||||
|
'deployed_version': instance.deployed_version,
|
||||||
|
'deployed_branch': instance.deployed_branch
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error fetching version info: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Error fetching version info: {str(e)}',
|
||||||
|
'deployed_version': instance.deployed_version,
|
||||||
|
'deployed_branch': instance.deployed_branch
|
||||||
|
})
|
||||||
|
|
||||||
|
@main_bp.route('/api/latest-version')
|
||||||
|
@login_required
|
||||||
|
@require_password_change
|
||||||
|
def get_latest_version():
|
||||||
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get Git settings
|
||||||
|
git_settings = KeyValueSettings.get_value('git_settings')
|
||||||
|
if not git_settings:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Git settings not configured',
|
||||||
|
'latest_version': 'unknown',
|
||||||
|
'latest_commit': 'unknown',
|
||||||
|
'last_checked': None
|
||||||
|
})
|
||||||
|
|
||||||
|
latest_tag = None
|
||||||
|
latest_commit = None
|
||||||
|
|
||||||
|
if git_settings['provider'] == 'gitea':
|
||||||
|
headers = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': f'token {git_settings["token"]}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the latest tag
|
||||||
|
tags_response = requests.get(
|
||||||
|
f'{git_settings["url"]}/api/v1/repos/{git_settings["repo"]}/tags',
|
||||||
|
headers=headers,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if tags_response.status_code == 200:
|
||||||
|
tags_data = tags_response.json()
|
||||||
|
if tags_data:
|
||||||
|
# Sort tags by commit date (newest first) and get the latest
|
||||||
|
sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True)
|
||||||
|
if sorted_tags:
|
||||||
|
latest_tag = sorted_tags[0].get('name')
|
||||||
|
latest_commit = sorted_tags[0].get('commit', {}).get('id')
|
||||||
|
else:
|
||||||
|
# Try token as query parameter if header auth fails
|
||||||
|
tags_response = requests.get(
|
||||||
|
f'{git_settings["url"]}/api/v1/repos/{git_settings["repo"]}/tags?token={git_settings["token"]}',
|
||||||
|
headers={'Accept': 'application/json'},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if tags_response.status_code == 200:
|
||||||
|
tags_data = tags_response.json()
|
||||||
|
if tags_data:
|
||||||
|
sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True)
|
||||||
|
if sorted_tags:
|
||||||
|
latest_tag = sorted_tags[0].get('name')
|
||||||
|
latest_commit = sorted_tags[0].get('commit', {}).get('id')
|
||||||
|
|
||||||
|
elif git_settings['provider'] == 'gitlab':
|
||||||
|
headers = {
|
||||||
|
'PRIVATE-TOKEN': git_settings['token'],
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the latest tag
|
||||||
|
tags_response = requests.get(
|
||||||
|
f'{git_settings["url"]}/api/v4/projects/{git_settings["repo"].replace("/", "%2F")}/repository/tags',
|
||||||
|
headers=headers,
|
||||||
|
params={'order_by': 'version', 'sort': 'desc', 'per_page': 1},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if tags_response.status_code == 200:
|
||||||
|
tags_data = tags_response.json()
|
||||||
|
if tags_data:
|
||||||
|
latest_tag = tags_data[0].get('name')
|
||||||
|
latest_commit = tags_data[0].get('commit', {}).get('id')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'latest_version': latest_tag or 'unknown',
|
||||||
|
'latest_commit': latest_commit or 'unknown',
|
||||||
|
'repository': git_settings.get('repo', 'unknown'),
|
||||||
|
'provider': git_settings.get('provider', 'unknown'),
|
||||||
|
'last_checked': datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error fetching latest version: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Error fetching latest version: {str(e)}',
|
||||||
|
'latest_version': 'unknown',
|
||||||
|
'latest_commit': 'unknown',
|
||||||
|
'last_checked': datetime.utcnow().isoformat()
|
||||||
|
}), 500
|
||||||
|
|
||||||
UPLOAD_FOLDER = '/app/uploads/profile_pics'
|
UPLOAD_FOLDER = '/app/uploads/profile_pics'
|
||||||
if not os.path.exists(UPLOAD_FOLDER):
|
if not os.path.exists(UPLOAD_FOLDER):
|
||||||
os.makedirs(UPLOAD_FOLDER)
|
os.makedirs(UPLOAD_FOLDER)
|
||||||
@@ -976,7 +1335,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'
|
||||||
|
|
||||||
@@ -993,6 +1352,7 @@ def init_routes(main_bp):
|
|||||||
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
||||||
git_settings = KeyValueSettings.get_value('git_settings')
|
git_settings = KeyValueSettings.get_value('git_settings')
|
||||||
cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings')
|
cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings')
|
||||||
|
stripe_settings = KeyValueSettings.get_value('stripe_settings')
|
||||||
|
|
||||||
# Get management API key for the connections tab
|
# Get management API key for the connections tab
|
||||||
management_api_key = ManagementAPIKey.query.filter_by(is_active=True).first()
|
management_api_key = ManagementAPIKey.query.filter_by(is_active=True).first()
|
||||||
@@ -1034,6 +1394,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
|
||||||
@@ -1064,6 +1430,8 @@ 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,
|
||||||
|
stripe_settings=stripe_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'])
|
||||||
@@ -1761,13 +2129,12 @@ def init_routes(main_bp):
|
|||||||
email = data.get('email')
|
email = data.get('email')
|
||||||
api_key = data.get('api_key')
|
api_key = data.get('api_key')
|
||||||
zone_id = data.get('zone_id')
|
zone_id = data.get('zone_id')
|
||||||
server_ip = data.get('server_ip')
|
|
||||||
|
|
||||||
if not email or not api_key or not zone_id or not server_ip:
|
if not email or not api_key or not zone_id:
|
||||||
return jsonify({'error': 'Missing required fields'}), 400
|
return jsonify({'error': 'Missing required fields'}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Test Cloudflare connection by getting zone details
|
# Test Cloudflare connection
|
||||||
headers = {
|
headers = {
|
||||||
'X-Auth-Email': email,
|
'X-Auth-Email': email,
|
||||||
'X-Auth-Key': api_key,
|
'X-Auth-Key': api_key,
|
||||||
@@ -1778,21 +2145,77 @@ def init_routes(main_bp):
|
|||||||
response = requests.get(
|
response = requests.get(
|
||||||
f'https://api.cloudflare.com/client/v4/zones/{zone_id}',
|
f'https://api.cloudflare.com/client/v4/zones/{zone_id}',
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=10
|
timeout=5
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
zone_data = response.json()
|
return jsonify({'message': 'Connection successful'})
|
||||||
if zone_data.get('success'):
|
|
||||||
return jsonify({'message': 'Connection successful'})
|
|
||||||
else:
|
|
||||||
return jsonify({'error': f'API error: {zone_data.get("errors", [{}])[0].get("message", "Unknown error")}'}), 400
|
|
||||||
else:
|
else:
|
||||||
return jsonify({'error': f'Connection failed: HTTP {response.status_code}'}), 400
|
return jsonify({'error': f'Connection failed: {response.json().get("errors", [{}])[0].get("message", "Unknown error")}'}), 400
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': f'Connection failed: {str(e)}'}), 400
|
return jsonify({'error': f'Connection failed: {str(e)}'}), 400
|
||||||
|
|
||||||
|
@main_bp.route('/settings/save-stripe-connection', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def save_stripe_connection():
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
publishable_key = data.get('publishable_key')
|
||||||
|
secret_key = data.get('secret_key')
|
||||||
|
webhook_secret = data.get('webhook_secret')
|
||||||
|
test_mode = data.get('test_mode', False)
|
||||||
|
customer_portal_url = data.get('customer_portal_url', '')
|
||||||
|
|
||||||
|
if not publishable_key or not secret_key:
|
||||||
|
return jsonify({'error': 'Missing required fields'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save Stripe settings
|
||||||
|
KeyValueSettings.set_value('stripe_settings', {
|
||||||
|
'publishable_key': publishable_key,
|
||||||
|
'secret_key': secret_key,
|
||||||
|
'webhook_secret': webhook_secret,
|
||||||
|
'test_mode': test_mode,
|
||||||
|
'customer_portal_url': customer_portal_url
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'message': 'Settings saved successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@main_bp.route('/settings/test-stripe-connection', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def test_stripe_connection():
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
secret_key = data.get('secret_key')
|
||||||
|
|
||||||
|
if not secret_key:
|
||||||
|
return jsonify({'error': 'Missing required fields'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test Stripe connection by making a simple API call
|
||||||
|
import stripe
|
||||||
|
stripe.api_key = secret_key
|
||||||
|
|
||||||
|
# Try to get account information
|
||||||
|
account = stripe.Account.retrieve()
|
||||||
|
|
||||||
|
return jsonify({'message': 'Connection successful'})
|
||||||
|
|
||||||
|
except stripe.error.AuthenticationError:
|
||||||
|
return jsonify({'error': 'Invalid API key'}), 400
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
return jsonify({'error': f'Stripe error: {str(e)}'}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': f'Connection failed: {str(e)}'}), 400
|
||||||
|
|
||||||
@main_bp.route('/instances/launch-progress')
|
@main_bp.route('/instances/launch-progress')
|
||||||
@login_required
|
@login_required
|
||||||
@require_password_change
|
@require_password_change
|
||||||
@@ -1801,6 +2224,12 @@ def init_routes(main_bp):
|
|||||||
flash('This page is only available in master instances.', 'error')
|
flash('This page is only available in master instances.', 'error')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
# Get update parameters if this is an update operation
|
||||||
|
is_update = request.args.get('update', 'false').lower() == 'true'
|
||||||
|
instance_id = request.args.get('instance_id')
|
||||||
|
repo_id = request.args.get('repo')
|
||||||
|
branch = request.args.get('branch')
|
||||||
|
|
||||||
# Get NGINX settings
|
# Get NGINX settings
|
||||||
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
||||||
# Get Portainer settings
|
# Get Portainer settings
|
||||||
@@ -1811,7 +2240,11 @@ def init_routes(main_bp):
|
|||||||
return render_template('main/launch_progress.html',
|
return render_template('main/launch_progress.html',
|
||||||
nginx_settings=nginx_settings,
|
nginx_settings=nginx_settings,
|
||||||
portainer_settings=portainer_settings,
|
portainer_settings=portainer_settings,
|
||||||
cloudflare_settings=cloudflare_settings)
|
cloudflare_settings=cloudflare_settings,
|
||||||
|
is_update=is_update,
|
||||||
|
instance_id=instance_id,
|
||||||
|
repo_id=repo_id,
|
||||||
|
branch=branch)
|
||||||
|
|
||||||
@main_bp.route('/api/check-dns', methods=['POST'])
|
@main_bp.route('/api/check-dns', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -1893,6 +2326,13 @@ def init_routes(main_bp):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_password_change
|
@require_password_change
|
||||||
def create_dns_records():
|
def create_dns_records():
|
||||||
|
"""
|
||||||
|
Create or update DNS A records in Cloudflare.
|
||||||
|
|
||||||
|
Important: DNS records are created with proxied=False to avoid conflicts
|
||||||
|
with NGINX Proxy Manager. This ensures direct DNS resolution without
|
||||||
|
Cloudflare's proxy layer interfering with the NGINX configuration.
|
||||||
|
"""
|
||||||
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
||||||
return jsonify({'error': 'Unauthorized'}), 403
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
@@ -1939,7 +2379,7 @@ def init_routes(main_bp):
|
|||||||
'name': domain,
|
'name': domain,
|
||||||
'content': cloudflare_settings['server_ip'],
|
'content': cloudflare_settings['server_ip'],
|
||||||
'ttl': 1, # Auto TTL
|
'ttl': 1, # Auto TTL
|
||||||
'proxied': True
|
'proxied': False # DNS only - no Cloudflare proxy to avoid conflicts with NGINX
|
||||||
}
|
}
|
||||||
|
|
||||||
update_response = requests.put(
|
update_response = requests.put(
|
||||||
@@ -1960,7 +2400,7 @@ def init_routes(main_bp):
|
|||||||
'name': domain,
|
'name': domain,
|
||||||
'content': cloudflare_settings['server_ip'],
|
'content': cloudflare_settings['server_ip'],
|
||||||
'ttl': 1, # Auto TTL
|
'ttl': 1, # Auto TTL
|
||||||
'proxied': True
|
'proxied': False # DNS only - no Cloudflare proxy to avoid conflicts with NGINX
|
||||||
}
|
}
|
||||||
|
|
||||||
create_response = requests.post(
|
create_response = requests.post(
|
||||||
@@ -2011,40 +2451,229 @@ def init_routes(main_bp):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_password_change
|
@require_password_change
|
||||||
def development_wiki():
|
def development_wiki():
|
||||||
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
|
||||||
flash('This page is only available in master instances.', 'error')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
return render_template('wiki/base.html')
|
return render_template('wiki/base.html')
|
||||||
|
|
||||||
|
@main_bp.route('/support-articles')
|
||||||
|
@login_required
|
||||||
|
@require_password_change
|
||||||
|
def support_articles():
|
||||||
|
# Check if this is a master instance
|
||||||
|
is_master = os.environ.get('MASTER', 'false').lower() == 'true'
|
||||||
|
if not is_master:
|
||||||
|
flash('This page is only available on the master instance.', 'error')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
return render_template('admin/support_articles.html')
|
||||||
|
|
||||||
@main_bp.route('/api/version')
|
@main_bp.route('/api/version')
|
||||||
def api_version():
|
def api_version():
|
||||||
version_file = os.path.join(current_app.root_path, 'version.txt')
|
# Get version information from environment variables
|
||||||
version = 'unknown'
|
version = os.getenv('APP_VERSION', 'unknown')
|
||||||
version_data = {}
|
commit = os.getenv('GIT_COMMIT', 'unknown')
|
||||||
|
branch = os.getenv('GIT_BRANCH', 'unknown')
|
||||||
if os.path.exists(version_file):
|
deployed_at = os.getenv('DEPLOYED_AT', 'unknown')
|
||||||
with open(version_file, 'r') as f:
|
|
||||||
content = f.read().strip()
|
|
||||||
|
|
||||||
# Try to parse as JSON first (new format)
|
|
||||||
try:
|
|
||||||
version_data = json.loads(content)
|
|
||||||
version = version_data.get('tag', 'unknown')
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Fallback to old format (just commit hash)
|
|
||||||
version = content
|
|
||||||
version_data = {
|
|
||||||
'tag': 'unknown',
|
|
||||||
'commit': content,
|
|
||||||
'branch': 'unknown',
|
|
||||||
'deployed_at': 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'version': version,
|
'version': version,
|
||||||
'tag': version_data.get('tag', 'unknown'),
|
'tag': version,
|
||||||
'commit': version_data.get('commit', 'unknown'),
|
'commit': commit,
|
||||||
'branch': version_data.get('branch', 'unknown'),
|
'branch': branch,
|
||||||
'deployed_at': version_data.get('deployed_at', 'unknown')
|
'deployed_at': deployed_at
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@main_bp.route('/api/create-checkout-session', methods=['POST'])
|
||||||
|
@csrf.exempt
|
||||||
|
def create_checkout_session():
|
||||||
|
"""Create a Stripe checkout session for a pricing plan"""
|
||||||
|
current_app.logger.info("=== CHECKOUT SESSION REQUEST START ===")
|
||||||
|
current_app.logger.info(f"Request method: {request.method}")
|
||||||
|
current_app.logger.info(f"Request headers: {dict(request.headers)}")
|
||||||
|
current_app.logger.info(f"Request data: {request.get_data()}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.stripe_utils import create_checkout_session
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
current_app.logger.info(f"Parsed JSON data: {data}")
|
||||||
|
|
||||||
|
plan_id = data.get('plan_id')
|
||||||
|
billing_cycle = data.get('billing_cycle', 'monthly')
|
||||||
|
|
||||||
|
current_app.logger.info(f"Plan ID: {plan_id}")
|
||||||
|
current_app.logger.info(f"Billing cycle: {billing_cycle}")
|
||||||
|
|
||||||
|
if not plan_id:
|
||||||
|
current_app.logger.error("Plan ID is missing")
|
||||||
|
return jsonify({'error': 'Plan ID is required'}), 400
|
||||||
|
|
||||||
|
if billing_cycle not in ['monthly', 'annual']:
|
||||||
|
current_app.logger.error(f"Invalid billing cycle: {billing_cycle}")
|
||||||
|
return jsonify({'error': 'Invalid billing cycle'}), 400
|
||||||
|
|
||||||
|
current_app.logger.info("Calling create_checkout_session function...")
|
||||||
|
|
||||||
|
# Create checkout session
|
||||||
|
checkout_url = create_checkout_session(
|
||||||
|
plan_id=plan_id,
|
||||||
|
billing_cycle=billing_cycle,
|
||||||
|
success_url=url_for('main.checkout_success', _external=True),
|
||||||
|
cancel_url=url_for('main.public_home', _external=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
current_app.logger.info(f"Checkout URL created: {checkout_url}")
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'success': True,
|
||||||
|
'checkout_url': checkout_url
|
||||||
|
}
|
||||||
|
current_app.logger.info(f"Returning response: {response_data}")
|
||||||
|
|
||||||
|
return jsonify(response_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error creating checkout session: {str(e)}")
|
||||||
|
current_app.logger.error(f"Exception type: {type(e)}")
|
||||||
|
import traceback
|
||||||
|
current_app.logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
current_app.logger.info("=== CHECKOUT SESSION REQUEST END ===")
|
||||||
|
|
||||||
|
@main_bp.route('/api/checkout-success')
|
||||||
|
def checkout_success():
|
||||||
|
"""Handle successful checkout"""
|
||||||
|
session_id = request.args.get('session_id')
|
||||||
|
subscription_info = None
|
||||||
|
|
||||||
|
# Get Stripe settings for customer portal link
|
||||||
|
stripe_settings = KeyValueSettings.get_value('stripe_settings')
|
||||||
|
|
||||||
|
if session_id:
|
||||||
|
try:
|
||||||
|
from utils.stripe_utils import get_subscription_info
|
||||||
|
from models import Customer, PricingPlan
|
||||||
|
|
||||||
|
subscription_info = get_subscription_info(session_id)
|
||||||
|
|
||||||
|
# Log the subscription info for debugging
|
||||||
|
current_app.logger.info(f"Checkout success - Session ID: {session_id}")
|
||||||
|
current_app.logger.info(f"Subscription info: {subscription_info}")
|
||||||
|
|
||||||
|
# Save or update customer information
|
||||||
|
if 'customer_details' in subscription_info:
|
||||||
|
customer_details = subscription_info['customer_details']
|
||||||
|
current_app.logger.info(f"Customer details: {customer_details}")
|
||||||
|
|
||||||
|
# Try to find existing customer by email
|
||||||
|
customer = Customer.query.filter_by(email=customer_details.get('email')).first()
|
||||||
|
|
||||||
|
if customer:
|
||||||
|
# Update existing customer
|
||||||
|
current_app.logger.info(f"Updating existing customer: {customer.email}")
|
||||||
|
else:
|
||||||
|
# Create new customer
|
||||||
|
customer = Customer()
|
||||||
|
current_app.logger.info(f"Creating new customer: {customer_details.get('email')}")
|
||||||
|
|
||||||
|
# Update customer information
|
||||||
|
customer.email = customer_details.get('email')
|
||||||
|
customer.name = customer_details.get('name')
|
||||||
|
customer.phone = customer_details.get('phone')
|
||||||
|
|
||||||
|
# Update billing address
|
||||||
|
if customer_details.get('address'):
|
||||||
|
address = customer_details['address']
|
||||||
|
customer.billing_address_line1 = address.get('line1')
|
||||||
|
customer.billing_address_line2 = address.get('line2')
|
||||||
|
customer.billing_city = address.get('city')
|
||||||
|
customer.billing_state = address.get('state')
|
||||||
|
customer.billing_postal_code = address.get('postal_code')
|
||||||
|
customer.billing_country = address.get('country')
|
||||||
|
|
||||||
|
# Update shipping address
|
||||||
|
if customer_details.get('shipping'):
|
||||||
|
shipping = customer_details['shipping']
|
||||||
|
customer.shipping_address_line1 = shipping.get('address', {}).get('line1')
|
||||||
|
customer.shipping_address_line2 = shipping.get('address', {}).get('line2')
|
||||||
|
customer.shipping_city = shipping.get('address', {}).get('city')
|
||||||
|
customer.shipping_state = shipping.get('address', {}).get('state')
|
||||||
|
customer.shipping_postal_code = shipping.get('address', {}).get('postal_code')
|
||||||
|
customer.shipping_country = shipping.get('address', {}).get('country')
|
||||||
|
|
||||||
|
# Update tax information
|
||||||
|
if customer_details.get('tax_ids'):
|
||||||
|
tax_ids = customer_details['tax_ids']
|
||||||
|
if tax_ids:
|
||||||
|
# Store the first tax ID (most common case)
|
||||||
|
customer.tax_id_type = tax_ids[0].get('type')
|
||||||
|
customer.tax_id_value = tax_ids[0].get('value')
|
||||||
|
|
||||||
|
# Update Stripe and subscription information
|
||||||
|
customer.stripe_customer_id = subscription_info.get('customer_id')
|
||||||
|
customer.stripe_subscription_id = subscription_info.get('subscription_id')
|
||||||
|
customer.subscription_status = subscription_info.get('status')
|
||||||
|
customer.subscription_plan_id = subscription_info.get('plan_id')
|
||||||
|
customer.subscription_billing_cycle = subscription_info.get('billing_cycle')
|
||||||
|
customer.subscription_current_period_start = subscription_info.get('current_period_start')
|
||||||
|
customer.subscription_current_period_end = subscription_info.get('current_period_end')
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
if not customer.id:
|
||||||
|
db.session.add(customer)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
current_app.logger.info(f"Customer saved successfully: {customer.email}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error processing checkout success: {str(e)}")
|
||||||
|
flash('Payment successful, but there was an issue processing your subscription. Please contact support.', 'warning')
|
||||||
|
|
||||||
|
# Render the success page with subscription info and stripe settings
|
||||||
|
return render_template('checkout_success.html', subscription_info=subscription_info, stripe_settings=stripe_settings)
|
||||||
|
|
||||||
|
@main_bp.route('/api/debug/pricing-plans')
|
||||||
|
@login_required
|
||||||
|
def debug_pricing_plans():
|
||||||
|
"""Debug endpoint to check pricing plans"""
|
||||||
|
try:
|
||||||
|
from models import PricingPlan
|
||||||
|
|
||||||
|
plans = PricingPlan.query.all()
|
||||||
|
plans_data = []
|
||||||
|
|
||||||
|
for plan in plans:
|
||||||
|
plans_data.append({
|
||||||
|
'id': plan.id,
|
||||||
|
'name': plan.name,
|
||||||
|
'monthly_price': plan.monthly_price,
|
||||||
|
'annual_price': plan.annual_price,
|
||||||
|
'stripe_product_id': plan.stripe_product_id,
|
||||||
|
'stripe_monthly_price_id': plan.stripe_monthly_price_id,
|
||||||
|
'stripe_annual_price_id': plan.stripe_annual_price_id,
|
||||||
|
'is_custom': plan.is_custom,
|
||||||
|
'button_text': plan.button_text
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'plans': plans_data,
|
||||||
|
'count': len(plans_data)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error getting pricing plans: {str(e)}")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@main_bp.route('/preview-success')
|
||||||
|
def preview_success():
|
||||||
|
"""Preview the checkout success page with sample data"""
|
||||||
|
# Get Stripe settings for customer portal link
|
||||||
|
stripe_settings = KeyValueSettings.get_value('stripe_settings')
|
||||||
|
|
||||||
|
sample_subscription_info = {
|
||||||
|
'plan_name': 'Professional Plan',
|
||||||
|
'billing_cycle': 'monthly',
|
||||||
|
'status': 'active',
|
||||||
|
'amount': 29.99,
|
||||||
|
'currency': 'usd'
|
||||||
|
}
|
||||||
|
return render_template('checkout_success.html', subscription_info=sample_subscription_info, stripe_settings=stripe_settings)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for
|
from flask import Blueprint, render_template, redirect, url_for, request
|
||||||
from models import SiteSettings
|
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"""
|
||||||
@@ -23,11 +28,6 @@ def init_public_routes(public_bp):
|
|||||||
"""About page"""
|
"""About page"""
|
||||||
return render_template('public/about.html')
|
return render_template('public/about.html')
|
||||||
|
|
||||||
@public_bp.route('/blog')
|
|
||||||
def blog():
|
|
||||||
"""Blog page"""
|
|
||||||
return render_template('public/blog.html')
|
|
||||||
|
|
||||||
@public_bp.route('/careers')
|
@public_bp.route('/careers')
|
||||||
def careers():
|
def careers():
|
||||||
"""Careers page"""
|
"""Careers page"""
|
||||||
@@ -43,6 +43,35 @@ def init_public_routes(public_bp):
|
|||||||
"""Help Center page"""
|
"""Help Center page"""
|
||||||
return render_template('public/help.html')
|
return render_template('public/help.html')
|
||||||
|
|
||||||
|
@public_bp.route('/help/articles')
|
||||||
|
def help_articles():
|
||||||
|
"""Display help articles by category"""
|
||||||
|
category = request.args.get('category', '')
|
||||||
|
|
||||||
|
# Get all published articles grouped by category
|
||||||
|
all_articles = HelpArticle.get_all_published()
|
||||||
|
categories = HelpArticle.get_categories()
|
||||||
|
|
||||||
|
# If a specific category is requested, filter to that category
|
||||||
|
if category and category in categories:
|
||||||
|
articles = HelpArticle.get_articles_by_category(category)
|
||||||
|
category_name = categories[category]
|
||||||
|
else:
|
||||||
|
# Show all articles when no specific category is requested
|
||||||
|
articles = []
|
||||||
|
for category_articles in all_articles.values():
|
||||||
|
articles.extend(category_articles)
|
||||||
|
# Sort by order_index and then by created_at
|
||||||
|
articles.sort(key=lambda x: (x.order_index, x.created_at))
|
||||||
|
category_name = None
|
||||||
|
|
||||||
|
return render_template('public/help_articles.html',
|
||||||
|
articles=articles,
|
||||||
|
all_articles=all_articles,
|
||||||
|
categories=categories,
|
||||||
|
current_category=category,
|
||||||
|
category_name=category_name)
|
||||||
|
|
||||||
@public_bp.route('/contact')
|
@public_bp.route('/contact')
|
||||||
def contact():
|
def contact():
|
||||||
"""Contact page"""
|
"""Contact page"""
|
||||||
@@ -68,12 +97,7 @@ def init_public_routes(public_bp):
|
|||||||
"""Terms of Service page"""
|
"""Terms of Service page"""
|
||||||
return render_template('public/terms.html')
|
return render_template('public/terms.html')
|
||||||
|
|
||||||
@public_bp.route('/gdpr')
|
@public_bp.route('/cookies')
|
||||||
def gdpr():
|
def cookies():
|
||||||
"""GDPR page"""
|
"""Cookie Policy page"""
|
||||||
return render_template('public/gdpr.html')
|
return render_template('public/cookies.html')
|
||||||
|
|
||||||
@public_bp.route('/compliance')
|
|
||||||
def compliance():
|
|
||||||
"""Compliance page"""
|
|
||||||
return render_template('public/compliance.html')
|
|
||||||
59
set_version.py
Normal file
59
set_version.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Utility script to set version environment variables for local development.
|
||||||
|
This replaces the need for version.txt file creation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def get_git_info():
|
||||||
|
"""Get current git commit hash and branch"""
|
||||||
|
try:
|
||||||
|
# Get current commit hash
|
||||||
|
commit_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'],
|
||||||
|
text=True, stderr=subprocess.DEVNULL).strip()
|
||||||
|
|
||||||
|
# Get current branch
|
||||||
|
branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
|
text=True, stderr=subprocess.DEVNULL).strip()
|
||||||
|
|
||||||
|
# Get latest tag
|
||||||
|
try:
|
||||||
|
latest_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'],
|
||||||
|
text=True, stderr=subprocess.DEVNULL).strip()
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
latest_tag = 'unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'commit': commit_hash,
|
||||||
|
'branch': branch,
|
||||||
|
'tag': latest_tag
|
||||||
|
}
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
return {
|
||||||
|
'commit': 'unknown',
|
||||||
|
'branch': 'unknown',
|
||||||
|
'tag': 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_version_env():
|
||||||
|
"""Set version environment variables"""
|
||||||
|
git_info = get_git_info()
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
os.environ['APP_VERSION'] = git_info['tag']
|
||||||
|
os.environ['GIT_COMMIT'] = git_info['commit']
|
||||||
|
os.environ['GIT_BRANCH'] = git_info['branch']
|
||||||
|
os.environ['DEPLOYED_AT'] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
print("Version environment variables set:")
|
||||||
|
print(f"APP_VERSION: {os.environ['APP_VERSION']}")
|
||||||
|
print(f"GIT_COMMIT: {os.environ['GIT_COMMIT']}")
|
||||||
|
print(f"GIT_BRANCH: {os.environ['GIT_BRANCH']}")
|
||||||
|
print(f"DEPLOYED_AT: {os.environ['DEPLOYED_AT']}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
set_version_env()
|
||||||
263
static/css/instances.css
Normal file
263
static/css/instances.css
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/* Instances Page Styles */
|
||||||
|
|
||||||
|
/* Table Styles */
|
||||||
|
.table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version column styling */
|
||||||
|
.version-badge {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-badge {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make table responsive */
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip styling for version info */
|
||||||
|
.tooltip-inner {
|
||||||
|
max-width: 300px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version comparison styling */
|
||||||
|
.version-outdated {
|
||||||
|
background-color: #fff3cd !important;
|
||||||
|
border-color: #ffeaa7 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-current {
|
||||||
|
background-color: #d1ecf1 !important;
|
||||||
|
border-color: #bee5eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.bg-orange {
|
||||||
|
background-color: #fd7e14 !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.bg-orange:hover {
|
||||||
|
background-color: #e55a00 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pricing tier selection styles */
|
||||||
|
.pricing-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card.selected {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background-color: rgba(22, 118, 123, 0.05);
|
||||||
|
box-shadow: 0 4px 12px rgba(22, 118, 123, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card.selected::after {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card.border-primary {
|
||||||
|
border-color: var(--primary-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-info {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step Navigation Styles */
|
||||||
|
.step-item {
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: -50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-circle {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
color: #6c757d;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item.active .step-circle {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item.active .step-label {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item.completed .step-circle {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item.completed:not(:last-child)::after {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-pane {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-pane.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection Check Styles */
|
||||||
|
.connection-check {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.success {
|
||||||
|
color: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.error {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-details {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Footer Styles */
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end !important;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer button {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Infrastructure Tools Styles */
|
||||||
|
.infrastructure-tools .btn {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 100px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn:disabled:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn-outline-primary:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn-outline-success:hover {
|
||||||
|
background-color: #198754;
|
||||||
|
border-color: #198754;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn-outline-info:hover {
|
||||||
|
background-color: #0dcaf0;
|
||||||
|
border-color: #0dcaf0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn-outline-warning:hover {
|
||||||
|
background-color: #ffc107;
|
||||||
|
border-color: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn-outline-purple {
|
||||||
|
color: #6f42c1;
|
||||||
|
border-color: #6f42c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn-outline-purple:hover {
|
||||||
|
background-color: #6f42c1;
|
||||||
|
border-color: #6f42c1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn i {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infrastructure-tools .btn:hover i {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@
|
|||||||
background-color: #ffebee;
|
background-color: #ffebee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.step-item.warning {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
}
|
||||||
|
|
||||||
.step-icon {
|
.step-icon {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -72,6 +76,11 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.step-item.warning .step-icon {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.step-content {
|
.step-content {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
@@ -92,4 +101,8 @@
|
|||||||
|
|
||||||
.step-item.failed .step-status {
|
.step-item.failed .step-status {
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item.warning .step-status {
|
||||||
|
color: #856404;
|
||||||
}
|
}
|
||||||
1138
static/js/instance_detail.js
Normal file
1138
static/js/instance_detail.js
Normal file
File diff suppressed because it is too large
Load Diff
1943
static/js/instances.js
Normal file
1943
static/js/instances.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -529,6 +529,95 @@ async function saveCloudflareConnection(event) {
|
|||||||
saveModal.show();
|
saveModal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save Stripe Connection
|
||||||
|
async function saveStripeConnection(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const saveModal = new bootstrap.Modal(document.getElementById('saveConnectionModal'));
|
||||||
|
const messageElement = document.getElementById('saveConnectionMessage');
|
||||||
|
messageElement.textContent = '';
|
||||||
|
messageElement.className = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const publishableKey = document.getElementById('stripePublishableKey').value;
|
||||||
|
const secretKey = document.getElementById('stripeSecretKey').value;
|
||||||
|
const webhookSecret = document.getElementById('stripeWebhookSecret').value;
|
||||||
|
const customerPortalUrl = document.getElementById('stripeCustomerPortalUrl').value;
|
||||||
|
const testMode = document.getElementById('stripeTestMode').checked;
|
||||||
|
|
||||||
|
if (!publishableKey || !secretKey) {
|
||||||
|
throw new Error('Please fill in all required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/settings/save-stripe-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': getCsrfToken()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
publishable_key: publishableKey,
|
||||||
|
secret_key: secretKey,
|
||||||
|
webhook_secret: webhookSecret,
|
||||||
|
customer_portal_url: customerPortalUrl,
|
||||||
|
test_mode: testMode
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to save settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
messageElement.textContent = 'Settings saved successfully!';
|
||||||
|
messageElement.className = 'text-success';
|
||||||
|
} catch (error) {
|
||||||
|
messageElement.textContent = error.message || 'Failed to save settings';
|
||||||
|
messageElement.className = 'text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Stripe Connection
|
||||||
|
async function testStripeConnection() {
|
||||||
|
const saveModal = new bootstrap.Modal(document.getElementById('saveConnectionModal'));
|
||||||
|
const messageElement = document.getElementById('saveConnectionMessage');
|
||||||
|
messageElement.textContent = '';
|
||||||
|
messageElement.className = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secretKey = document.getElementById('stripeSecretKey').value;
|
||||||
|
|
||||||
|
if (!secretKey) {
|
||||||
|
throw new Error('Please enter your Stripe secret key first');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/settings/test-stripe-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': getCsrfToken()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
secret_key: secretKey
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Connection test failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
messageElement.textContent = 'Connection test successful!';
|
||||||
|
messageElement.className = 'text-success';
|
||||||
|
} catch (error) {
|
||||||
|
messageElement.textContent = error.message || 'Connection test failed';
|
||||||
|
messageElement.className = 'text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const gitSettings = JSON.parse(document.querySelector('meta[name="git-settings"]').getAttribute('content'));
|
const gitSettings = JSON.parse(document.querySelector('meta[name="git-settings"]').getAttribute('content'));
|
||||||
|
|||||||
312
static/js/settings/pricing.js
Normal file
312
static/js/settings/pricing.js
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
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('stripeProductId').value = plan.stripe_product_id || '';
|
||||||
|
document.getElementById('stripeMonthlyPriceId').value = plan.stripe_monthly_price_id || '';
|
||||||
|
document.getElementById('stripeAnnualPriceId').value = plan.stripe_annual_price_id || '';
|
||||||
|
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);
|
||||||
|
}
|
||||||
149
templates/admin/customer_details_modal.html
Normal file
149
templates/admin/customer_details_modal.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="mb-3">Customer Information</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr>
|
||||||
|
<td><strong>Name:</strong></td>
|
||||||
|
<td>{{ customer.name or 'N/A' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Email:</strong></td>
|
||||||
|
<td>{{ customer.email }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Phone:</strong></td>
|
||||||
|
<td>{{ customer.phone or 'N/A' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Created:</strong></td>
|
||||||
|
<td>{{ customer.created_at.strftime('%Y-%m-%d %H:%M') if customer.created_at else 'N/A' }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="mb-3">Subscription Information</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr>
|
||||||
|
<td><strong>Plan:</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if plan %}
|
||||||
|
<span class="badge bg-primary">{{ plan.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Plan {{ customer.subscription_plan_id or 'N/A' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Status:</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if customer.subscription_status %}
|
||||||
|
{% if customer.subscription_status == 'active' %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% elif customer.subscription_status == 'canceled' %}
|
||||||
|
<span class="badge bg-danger">Canceled</span>
|
||||||
|
{% elif customer.subscription_status == 'past_due' %}
|
||||||
|
<span class="badge bg-warning">Past Due</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ customer.subscription_status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No subscription</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Billing Cycle:</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if customer.subscription_billing_cycle %}
|
||||||
|
<span class="badge bg-info">{{ customer.subscription_billing_cycle.title() }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">N/A</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Current Period:</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if customer.subscription_current_period_start and customer.subscription_current_period_end %}
|
||||||
|
{{ customer.subscription_current_period_start.strftime('%Y-%m-%d') }} to {{ customer.subscription_current_period_end.strftime('%Y-%m-%d') }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">N/A</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if customer.billing_address_line1 or customer.shipping_address_line1 %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
{% if customer.billing_address_line1 %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="mb-3">Billing Address</h6>
|
||||||
|
<address class="mb-0">
|
||||||
|
{{ customer.billing_address_line1 }}<br>
|
||||||
|
{% if customer.billing_address_line2 %}{{ customer.billing_address_line2 }}<br>{% endif %}
|
||||||
|
{% if customer.billing_city %}{{ customer.billing_city }}{% endif %}
|
||||||
|
{% if customer.billing_state %}, {{ customer.billing_state }}{% endif %}
|
||||||
|
{% if customer.billing_postal_code %} {{ customer.billing_postal_code }}{% endif %}<br>
|
||||||
|
{% if customer.billing_country %}{{ customer.billing_country }}{% endif %}
|
||||||
|
</address>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if customer.shipping_address_line1 %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="mb-3">Shipping Address</h6>
|
||||||
|
<address class="mb-0">
|
||||||
|
{{ customer.shipping_address_line1 }}<br>
|
||||||
|
{% if customer.shipping_address_line2 %}{{ customer.shipping_address_line2 }}<br>{% endif %}
|
||||||
|
{% if customer.shipping_city %}{{ customer.shipping_city }}{% endif %}
|
||||||
|
{% if customer.shipping_state %}, {{ customer.shipping_state }}{% endif %}
|
||||||
|
{% if customer.shipping_postal_code %} {{ customer.shipping_postal_code }}{% endif %}<br>
|
||||||
|
{% if customer.shipping_country %}{{ customer.shipping_country }}{% endif %}
|
||||||
|
</address>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if customer.tax_id_type and customer.tax_id_value %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h6 class="mb-3">Tax Information</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr>
|
||||||
|
<td><strong>Tax ID Type:</strong></td>
|
||||||
|
<td>{{ customer.tax_id_type }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Tax ID Value:</strong></td>
|
||||||
|
<td>{{ customer.tax_id_value }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if customer.stripe_customer_id or customer.stripe_subscription_id %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h6 class="mb-3">Stripe Information</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
{% if customer.stripe_customer_id %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>Stripe Customer ID:</strong></td>
|
||||||
|
<td><code>{{ customer.stripe_customer_id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if customer.stripe_subscription_id %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>Stripe Subscription ID:</strong></td>
|
||||||
|
<td><code>{{ customer.stripe_subscription_id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
178
templates/admin/customers.html
Normal file
178
templates/admin/customers.html
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
{% extends "common/base.html" %}
|
||||||
|
{% from "components/header.html" import header %}
|
||||||
|
|
||||||
|
{% block title %}Customers - Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ header(
|
||||||
|
title="Customers",
|
||||||
|
description="Manage customer information and subscriptions",
|
||||||
|
icon="fa-users"
|
||||||
|
) }}
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
{% if customers.items %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Phone</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Billing Cycle</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for customer in customers.items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="avatar-sm me-3">
|
||||||
|
<div class="avatar-title bg-primary rounded-circle">
|
||||||
|
{{ customer.name[0] if customer.name else customer.email[0] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">{{ customer.name or 'N/A' }}</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ customer.email }}</td>
|
||||||
|
<td>{{ customer.phone or 'N/A' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if customer.subscription_plan_id %}
|
||||||
|
{% set plan = customer.plan %}
|
||||||
|
{% if plan %}
|
||||||
|
<span class="badge bg-primary">{{ plan.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Plan {{ customer.subscription_plan_id }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No plan</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if customer.subscription_status %}
|
||||||
|
{% if customer.subscription_status == 'active' %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% elif customer.subscription_status == 'canceled' %}
|
||||||
|
<span class="badge bg-danger">Canceled</span>
|
||||||
|
{% elif customer.subscription_status == 'past_due' %}
|
||||||
|
<span class="badge bg-warning">Past Due</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ customer.subscription_status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No subscription</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if customer.subscription_billing_cycle %}
|
||||||
|
<span class="badge bg-info">{{ customer.subscription_billing_cycle.title() }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">N/A</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ customer.created_at.strftime('%Y-%m-%d') if customer.created_at else 'N/A' }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
onclick="viewCustomerDetails({{ customer.id }})">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if customers.pages > 1 %}
|
||||||
|
<nav aria-label="Customer pagination">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if customers.has_prev %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('admin.customers', page=customers.prev_num) }}">Previous</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for page_num in customers.iter_pages() %}
|
||||||
|
{% if page_num %}
|
||||||
|
{% if page_num != customers.page %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('admin.customers', page=page_num) }}">{{ page_num }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ page_num }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">...</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if customers.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('admin.customers', page=customers.next_num) }}">Next</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||||
|
<h5 class="text-muted">No customers found</h5>
|
||||||
|
<p class="text-muted">Customers will appear here once they complete a purchase.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Details Modal -->
|
||||||
|
<div class="modal fade" id="customerDetailsModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Customer Details</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="customerDetailsContent">
|
||||||
|
<!-- Content will be loaded here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function viewCustomerDetails(customerId) {
|
||||||
|
// Load customer details via AJAX
|
||||||
|
fetch(`/admin/customers/${customerId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('customerDetailsContent').innerHTML = data.html;
|
||||||
|
new bootstrap.Modal(document.getElementById('customerDetailsModal')).show();
|
||||||
|
} else {
|
||||||
|
alert('Failed to load customer details');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to load customer details');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
543
templates/admin/support_articles.html
Normal file
543
templates/admin/support_articles.html
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
{% extends "common/base.html" %}
|
||||||
|
{% from "components/header.html" import header %}
|
||||||
|
|
||||||
|
{% block title %}Support Articles - DocuPulse{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.article-card {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.article-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.category-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
.note-editor {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
.note-editor.note-frame {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
.note-editor .note-editing-area {
|
||||||
|
background-color: var(--white);
|
||||||
|
}
|
||||||
|
.note-editor .note-toolbar {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.375rem 0.375rem 0 0;
|
||||||
|
}
|
||||||
|
.note-editor .note-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--white);
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
.note-editor .note-btn:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--white);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.note-editor .note-btn.active {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--white);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.note-editor .note-editing-area .note-editable {
|
||||||
|
color: var(--text-dark);
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.note-editor .note-status-output {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ header(
|
||||||
|
title="Support Articles",
|
||||||
|
description="Create and manage help articles for users",
|
||||||
|
icon="fa-life-ring",
|
||||||
|
buttons=[
|
||||||
|
{
|
||||||
|
'text': 'Create New Article',
|
||||||
|
'url': '#',
|
||||||
|
'onclick': 'showCreateArticleModal()',
|
||||||
|
'icon': 'fa-plus',
|
||||||
|
'class': 'btn-primary'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
) }}
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<!-- Articles List -->
|
||||||
|
<div class="row" id="articlesList">
|
||||||
|
<!-- Articles will be loaded here via AJAX -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Article Modal -->
|
||||||
|
<div class="modal fade" id="createArticleModal" tabindex="-1" aria-labelledby="createArticleModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="createArticleModalLabel">Create New Help Article</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form id="createArticleForm">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="articleTitle" class="form-label">Title</label>
|
||||||
|
<input type="text" class="form-control" id="articleTitle" name="title" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="articleCategory" class="form-label">Category</label>
|
||||||
|
<select class="form-select" id="articleCategory" name="category" required>
|
||||||
|
<option value="">Select Category</option>
|
||||||
|
<option value="getting-started">Getting Started</option>
|
||||||
|
<option value="user-management">User Management</option>
|
||||||
|
<option value="file-management">File Management</option>
|
||||||
|
<option value="communication">Communication</option>
|
||||||
|
<option value="security">Security & Privacy</option>
|
||||||
|
<option value="administration">Administration</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="articleBody" class="form-label">Content</label>
|
||||||
|
<textarea class="form-control" id="articleBody" name="body" rows="15" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="orderIndex" class="form-label">Order Index</label>
|
||||||
|
<input type="number" class="form-control" id="orderIndex" name="order_index" value="0" min="0">
|
||||||
|
<div class="form-text">Lower numbers appear first in the category</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isPublished" name="is_published" checked>
|
||||||
|
<label class="form-check-label" for="isPublished">
|
||||||
|
Publish immediately
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</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 Article</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Article Modal -->
|
||||||
|
<div class="modal fade" id="editArticleModal" tabindex="-1" aria-labelledby="editArticleModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="editArticleModalLabel">Edit Help Article</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form id="editArticleForm">
|
||||||
|
<input type="hidden" id="editArticleId" name="id">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editArticleTitle" class="form-label">Title</label>
|
||||||
|
<input type="text" class="form-control" id="editArticleTitle" name="title" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editArticleCategory" class="form-label">Category</label>
|
||||||
|
<select class="form-select" id="editArticleCategory" name="category" required>
|
||||||
|
<option value="">Select Category</option>
|
||||||
|
<option value="getting-started">Getting Started</option>
|
||||||
|
<option value="user-management">User Management</option>
|
||||||
|
<option value="file-management">File Management</option>
|
||||||
|
<option value="communication">Communication</option>
|
||||||
|
<option value="security">Security & Privacy</option>
|
||||||
|
<option value="administration">Administration</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editArticleBody" class="form-label">Content</label>
|
||||||
|
<textarea class="form-control" id="editArticleBody" name="body" rows="15" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editOrderIndex" class="form-label">Order Index</label>
|
||||||
|
<input type="number" class="form-control" id="editOrderIndex" name="order_index" value="0" min="0">
|
||||||
|
<div class="form-text">Lower numbers appear first in the category</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editIsPublished" name="is_published">
|
||||||
|
<label class="form-check-label" for="editIsPublished">
|
||||||
|
Published
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</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 Article</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteArticleModal" tabindex="-1" aria-labelledby="deleteArticleModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteArticleModalLabel">Delete Article</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to delete this article? This action cannot be undone.</p>
|
||||||
|
<p class="text-muted" id="deleteArticleTitle"></p>
|
||||||
|
<input type="hidden" id="deleteArticleId" value="">
|
||||||
|
</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="confirmDelete">Delete Article</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize Summernote for rich text editing
|
||||||
|
$('#articleBody, #editArticleBody').summernote({
|
||||||
|
height: 400,
|
||||||
|
toolbar: [
|
||||||
|
['style', ['style']],
|
||||||
|
['font', ['bold', 'underline', 'italic', 'strikethrough', 'clear']],
|
||||||
|
['fontname', ['fontname']],
|
||||||
|
['fontsize', ['fontsize']],
|
||||||
|
['color', ['color']],
|
||||||
|
['para', ['ul', 'ol', 'paragraph']],
|
||||||
|
['height', ['height']],
|
||||||
|
['table', ['table']],
|
||||||
|
['insert', ['link', 'picture', 'video', 'hr']],
|
||||||
|
['view', ['fullscreen', 'codeview', 'help']],
|
||||||
|
['undo', ['undo', 'redo']]
|
||||||
|
],
|
||||||
|
styleTags: [
|
||||||
|
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
||||||
|
],
|
||||||
|
fontNames: ['Arial', 'Arial Black', 'Comic Sans MS', 'Courier New', 'Helvetica', 'Impact', 'Tahoma', 'Times New Roman', 'Verdana'],
|
||||||
|
fontSizes: ['8', '9', '10', '11', '12', '14', '16', '18', '24', '36'],
|
||||||
|
callbacks: {
|
||||||
|
onInit: function() {
|
||||||
|
// Ensure proper styling when modal opens
|
||||||
|
$('.note-editor').css('border-color', 'var(--border-color)');
|
||||||
|
$('.note-editing-area').css('background-color', 'var(--white)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load articles on page load
|
||||||
|
loadArticles();
|
||||||
|
|
||||||
|
// Handle create article form submission
|
||||||
|
document.getElementById('createArticleForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
createArticle();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle edit article form submission
|
||||||
|
document.getElementById('editArticleForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
updateArticle();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle delete confirmation
|
||||||
|
document.getElementById('confirmDelete').addEventListener('click', function() {
|
||||||
|
deleteArticle();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadArticles() {
|
||||||
|
fetch('/api/admin/help-articles')
|
||||||
|
.then(response => {
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
return response.text().then(text => {
|
||||||
|
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
const articlesList = document.getElementById('articlesList');
|
||||||
|
articlesList.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.articles.length === 0) {
|
||||||
|
articlesList.innerHTML = `
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="fas fa-file-alt text-muted" style="font-size: 3rem; opacity: 0.5;"></i>
|
||||||
|
<h4 class="text-muted mt-3">No Articles Yet</h4>
|
||||||
|
<p class="text-muted">Create your first help article to get started.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryNames = {
|
||||||
|
'getting-started': 'Getting Started',
|
||||||
|
'user-management': 'User Management',
|
||||||
|
'file-management': 'File Management',
|
||||||
|
'communication': 'Communication',
|
||||||
|
'security': 'Security & Privacy',
|
||||||
|
'administration': 'Administration'
|
||||||
|
};
|
||||||
|
|
||||||
|
data.articles.forEach(article => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'col-lg-6 col-xl-4 mb-4';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card article-card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<span class="badge category-badge" style="background-color: var(--primary-color);">
|
||||||
|
${categoryNames[article.category]}
|
||||||
|
</span>
|
||||||
|
<span class="badge status-badge ${article.is_published ? 'bg-success' : 'bg-warning'}">
|
||||||
|
${article.is_published ? 'Published' : 'Draft'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title mb-2">${article.title}</h5>
|
||||||
|
<p class="card-text text-muted small mb-3">
|
||||||
|
${article.body.substring(0, 100)}${article.body.length > 100 ? '...' : ''}
|
||||||
|
</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<small class="text-muted">
|
||||||
|
Order: ${article.order_index} | Created: ${new Date(article.created_at).toLocaleDateString()}
|
||||||
|
</small>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-primary" onclick="editArticle(${article.id})">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger" onclick="confirmDelete(${article.id}, '${article.title}')">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
articlesList.appendChild(card);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading articles:', error);
|
||||||
|
showAlert('Error loading articles: ' + error.message, 'danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArticle() {
|
||||||
|
const form = document.getElementById('createArticleForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.set('body', $('#articleBody').summernote('code'));
|
||||||
|
formData.set('is_published', document.getElementById('isPublished').checked);
|
||||||
|
|
||||||
|
fetch('/api/admin/help-articles', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
// Check if response is JSON
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
// If not JSON, get the text and throw an error
|
||||||
|
return response.text().then(text => {
|
||||||
|
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createArticleModal'));
|
||||||
|
modal.hide();
|
||||||
|
form.reset();
|
||||||
|
$('#articleBody').summernote('code', '');
|
||||||
|
loadArticles();
|
||||||
|
showAlert('Article created successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error creating article:', error);
|
||||||
|
showAlert('Error creating article: ' + error.message, 'danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editArticle(articleId) {
|
||||||
|
fetch(`/api/admin/help-articles/${articleId}`)
|
||||||
|
.then(response => {
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
return response.text().then(text => {
|
||||||
|
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('editArticleId').value = data.article.id;
|
||||||
|
document.getElementById('editArticleTitle').value = data.article.title;
|
||||||
|
document.getElementById('editArticleCategory').value = data.article.category;
|
||||||
|
$('#editArticleBody').summernote('code', data.article.body);
|
||||||
|
document.getElementById('editOrderIndex').value = data.article.order_index;
|
||||||
|
document.getElementById('editIsPublished').checked = data.article.is_published;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('editArticleModal'));
|
||||||
|
modal.show();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading article:', error);
|
||||||
|
showAlert('Error loading article: ' + error.message, 'danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateArticle() {
|
||||||
|
const form = document.getElementById('editArticleForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.set('body', $('#editArticleBody').summernote('code'));
|
||||||
|
formData.set('is_published', document.getElementById('editIsPublished').checked);
|
||||||
|
|
||||||
|
fetch(`/api/admin/help-articles/${document.getElementById('editArticleId').value}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
return response.text().then(text => {
|
||||||
|
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('editArticleModal'));
|
||||||
|
modal.hide();
|
||||||
|
loadArticles();
|
||||||
|
showAlert('Article updated successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error updating article:', error);
|
||||||
|
showAlert('Error updating article: ' + error.message, 'danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(articleId, title) {
|
||||||
|
document.getElementById('deleteArticleId').value = articleId;
|
||||||
|
document.getElementById('deleteArticleTitle').textContent = title;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('deleteArticleModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteArticle() {
|
||||||
|
const articleId = document.getElementById('deleteArticleId').value;
|
||||||
|
|
||||||
|
fetch(`/api/admin/help-articles/${articleId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
return response.text().then(text => {
|
||||||
|
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteArticleModal'));
|
||||||
|
modal.hide();
|
||||||
|
loadArticles();
|
||||||
|
showAlert('Article deleted successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error deleting article:', error);
|
||||||
|
showAlert('Error deleting article: ' + error.message, 'danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAlert(message, type) {
|
||||||
|
const alertHtml = `
|
||||||
|
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||||
|
<i class="fas fa-${type === 'success' ? 'check-circle' : 'exclamation-triangle'} me-2"></i>
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const container = document.querySelector('.container-fluid');
|
||||||
|
container.insertAdjacentHTML('afterbegin', alertHtml);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
290
templates/checkout_success.html
Normal file
290
templates/checkout_success.html
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Payment Successful - DocuPulse</title>
|
||||||
|
<meta name="description" content="Your DocuPulse subscription has been activated successfully. Welcome to the future of document management.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.hero-section {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 120px 0 80px 0;
|
||||||
|
}
|
||||||
|
.success-card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
background: var(--white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.success-card .card-header {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.success-card .card-body {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.subscription-detail {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.subscription-detail:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.subscription-detail strong {
|
||||||
|
color: var(--text-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.next-step-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.next-step-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.next-step-item i {
|
||||||
|
width: 24px;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.next-step-item a {
|
||||||
|
color: var(--text-dark);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.next-step-item a:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.feature-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.feature-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--primary-color-rgb), 0.3);
|
||||||
|
}
|
||||||
|
.feature-item h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.feature-item p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.btn-outline-primary {
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-outline-primary:hover {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
color: var(--text-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.section-title i {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center text-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-check-circle" style="font-size: 4rem;"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="display-4 fw-bold mb-3">Payment Successful!</h1>
|
||||||
|
<p class="lead mb-4">Your subscription has been activated and your DocuPulse instance is being set up.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Success Details Section -->
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card success-card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-check-circle me-2"></i>
|
||||||
|
Payment Confirmation
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if subscription_info %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h5 class="section-title">
|
||||||
|
<i class="fas fa-receipt"></i>
|
||||||
|
Subscription Details
|
||||||
|
</h5>
|
||||||
|
<div class="subscription-detail">
|
||||||
|
<strong>Plan:</strong> {{ subscription_info.plan_name }}
|
||||||
|
</div>
|
||||||
|
<div class="subscription-detail">
|
||||||
|
<strong>Billing Cycle:</strong> {{ subscription_info.billing_cycle.title() }}
|
||||||
|
</div>
|
||||||
|
<div class="subscription-detail">
|
||||||
|
<strong>Status:</strong>
|
||||||
|
<span class="badge bg-success">{{ subscription_info.status.title() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="subscription-detail">
|
||||||
|
<strong>Amount:</strong> ${{ "%.2f"|format(subscription_info.amount) }} {{ subscription_info.currency.upper() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h5 class="section-title">
|
||||||
|
<i class="fas fa-rocket"></i>
|
||||||
|
Next Steps
|
||||||
|
</h5>
|
||||||
|
<div class="next-step-item">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
<span>Check your email for login credentials</span>
|
||||||
|
</div>
|
||||||
|
<div class="next-step-item">
|
||||||
|
<i class="fas fa-book"></i>
|
||||||
|
<a href="{{ url_for('public.help_center') }}">Read our getting started guide</a>
|
||||||
|
</div>
|
||||||
|
{% if stripe_settings and stripe_settings.customer_portal_url %}
|
||||||
|
<div class="next-step-item">
|
||||||
|
<i class="fas fa-credit-card"></i>
|
||||||
|
<a href="{{ stripe_settings.customer_portal_url }}" target="_blank">Manage your subscription & billing</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="next-step-item">
|
||||||
|
<i class="fas fa-headset"></i>
|
||||||
|
<a href="{{ url_for('public.contact') }}">Contact support if you need help</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center">
|
||||||
|
<h5 class="mb-3">Thank you for your purchase!</h5>
|
||||||
|
<p class="text-muted">Your payment has been processed successfully.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card success-card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
What happens next?
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="feature-grid">
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-rocket"></i>
|
||||||
|
</div>
|
||||||
|
<h6>Instance Setup</h6>
|
||||||
|
<p>Your DocuPulse instance will be automatically provisioned within the next few minutes.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
</div>
|
||||||
|
<h6>Welcome Email</h6>
|
||||||
|
<p>You'll receive an email with your login credentials and setup instructions.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-headset"></i>
|
||||||
|
</div>
|
||||||
|
<h6>Support Available</h6>
|
||||||
|
<p>Our support team is ready to help you get started with DocuPulse.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="{{ url_for('public.help_center') }}" class="btn btn-primary btn-lg me-3">
|
||||||
|
<i class="fas fa-question-circle me-2"></i>Get Help
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('public.contact') }}" class="btn btn-outline-primary btn-lg">
|
||||||
|
<i class="fas fa-envelope me-2"></i>Contact Support
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -63,11 +63,9 @@
|
|||||||
<li><a class="dropdown-item" href="{{ url_for('main.settings') }}"><i class="fas fa-cog"></i> Settings</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('main.settings') }}"><i class="fas fa-cog"></i> Settings</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if not is_master %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('main.profile') }}"><i class="fas fa-user"></i> Profile</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('main.profile') }}"><i class="fas fa-user"></i> Profile</a></li>
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<li><a class="dropdown-item" href="{{ url_for('main.settings') }}"><i class="fas fa-cog"></i> Settings</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('main.settings') }}"><i class="fas fa-cog"></i> Settings</a></li>
|
||||||
{% endif %}
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -100,6 +98,16 @@
|
|||||||
<i class="fas fa-book"></i> Development Wiki
|
<i class="fas fa-book"></i> Development Wiki
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'main.support_articles' %}active{% endif %}" href="{{ url_for('main.support_articles') }}">
|
||||||
|
<i class="fas fa-life-ring"></i> Support Articles
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'admin.customers' %}active{% endif %}" href="{{ url_for('admin.customers') }}">
|
||||||
|
<i class="fas fa-users"></i> Customers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}">
|
<a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}">
|
||||||
@@ -148,6 +156,18 @@
|
|||||||
<i class="fas fa-user"></i> Profile
|
<i class="fas fa-user"></i> Profile
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item d-lg-none">
|
||||||
|
<hr class="my-2">
|
||||||
|
<a class="nav-link" href="{{ url_for('main.settings') }}">
|
||||||
|
<i class="fas fa-cog"></i> Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item d-lg-none">
|
||||||
|
<a class="nav-link" href="{{ url_for('main.profile') }}">
|
||||||
|
<i class="fas fa-user"></i> Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="nav-item d-lg-none">
|
<li class="nav-item d-lg-none">
|
||||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">
|
<a class="nav-link" href="{{ url_for('auth.logout') }}">
|
||||||
|
|||||||
138
templates/components/animated_numbers.html
Normal file
138
templates/components/animated_numbers.html
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<!-- Animated Numbers Component -->
|
||||||
|
<div class="stats-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row text-center">
|
||||||
|
{% for stat in stats %}
|
||||||
|
<div class="col-md-{{ 12 // stats|length }}">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" data-value="{{ stat.value }}" data-suffix="{{ stat.suffix }}">{{ stat.display }}</span>
|
||||||
|
<div class="stat-label">{{ stat.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stats-section {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 80px 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to animate number counting
|
||||||
|
function animateNumber(element, endValue, suffix = '', duration = 2000) {
|
||||||
|
const start = performance.now();
|
||||||
|
const startValue = 0;
|
||||||
|
const difference = endValue - startValue;
|
||||||
|
|
||||||
|
function updateNumber(currentTime) {
|
||||||
|
const elapsed = currentTime - start;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
// Easing function for smooth animation
|
||||||
|
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||||
|
const currentValue = startValue + (difference * easeOutQuart);
|
||||||
|
|
||||||
|
// Format the number based on the suffix
|
||||||
|
let displayValue;
|
||||||
|
if (suffix === '%') {
|
||||||
|
displayValue = currentValue.toFixed(1) + suffix;
|
||||||
|
} else if (suffix === '-bit') {
|
||||||
|
displayValue = Math.round(currentValue) + suffix;
|
||||||
|
} else if (suffix === '/7') {
|
||||||
|
displayValue = Math.round(currentValue) + suffix;
|
||||||
|
} else if (suffix === '+') {
|
||||||
|
displayValue = Math.round(currentValue) + suffix;
|
||||||
|
} else if (suffix === 'K+') {
|
||||||
|
displayValue = Math.round(currentValue) + suffix;
|
||||||
|
} else {
|
||||||
|
displayValue = Math.round(currentValue) + (suffix || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
element.textContent = displayValue;
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(updateNumber);
|
||||||
|
} else {
|
||||||
|
// Ensure the final value is correct
|
||||||
|
element.textContent = element.getAttribute('data-value') + (suffix || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(updateNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize animated numbers when component is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const statsObserver = new IntersectionObserver(function(entries) {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const statNumbers = entry.target.querySelectorAll('.stat-number');
|
||||||
|
statNumbers.forEach((stat, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const value = parseFloat(stat.getAttribute('data-value'));
|
||||||
|
const suffix = stat.getAttribute('data-suffix') || '';
|
||||||
|
|
||||||
|
if (!isNaN(value)) {
|
||||||
|
animateNumber(stat, value, suffix, 2000);
|
||||||
|
}
|
||||||
|
}, index * 300); // Stagger the animations
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only trigger once
|
||||||
|
statsObserver.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.5 });
|
||||||
|
|
||||||
|
// Observe the stats section
|
||||||
|
const statsSection = document.querySelector('.stats-section');
|
||||||
|
if (statsSection) {
|
||||||
|
statsObserver.observe(statsSection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- CTA Buttons Component -->
|
<!-- CTA Buttons Component -->
|
||||||
<div class="d-flex justify-content-center gap-3 flex-wrap">
|
<div class="d-flex justify-content-center gap-3 flex-wrap">
|
||||||
<a href="{{ primary_url }}" class="btn btn-lg px-5 py-3" style="background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); border: none; color: white; border-radius: 25px; font-weight: 600;">
|
<a href="{{ primary_url }}" class="btn btn-primary btn-lg px-5 py-3">
|
||||||
<i class="{{ primary_icon }} me-2"></i>{{ primary_text }}
|
<i class="{{ primary_icon }} me-2"></i>{{ primary_text }}
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ secondary_url }}" class="btn btn-lg px-5 py-3" style="border: 2px solid var(--primary-color); color: var(--primary-color); background: transparent; border-radius: 25px; font-weight: 600;">
|
<a href="{{ secondary_url }}" class="btn btn-outline-primary btn-lg px-5 py-3">
|
||||||
<i class="{{ secondary_icon }} me-2"></i>{{ secondary_text }}
|
<i class="{{ secondary_icon }} me-2"></i>{{ secondary_text }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
111
templates/components/explainer_video_modal.html
Normal file
111
templates/components/explainer_video_modal.html
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<!-- Reusable Explainer Video Modal Component -->
|
||||||
|
<div class="modal fade" id="explainerVideoModal" tabindex="-1" aria-labelledby="explainerVideoModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="explainerVideoModalLabel">
|
||||||
|
<i class="fas fa-play-circle me-2"></i>{{ modal_title|default('DocuPulse Overview') }}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="ratio ratio-16x9">
|
||||||
|
<div class="bg-dark d-flex align-items-center justify-content-center" style="border-radius: 8px;">
|
||||||
|
<div class="text-center text-white">
|
||||||
|
<i class="fas fa-video fa-3x mb-3 opacity-50"></i>
|
||||||
|
<h5>{{ video_title|default('Explainer Video') }}</h5>
|
||||||
|
<p class="text-muted">{{ video_placeholder|default('Video placeholder - Replace with actual explainer video') }}</p>
|
||||||
|
<button class="btn btn-primary btn-lg px-4 py-3">
|
||||||
|
<i class="fas fa-play me-2"></i>Play Video
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<h6>{{ learning_title|default('What you\'ll learn:') }}</h6>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
{% for point in learning_points|default([
|
||||||
|
'How DocuPulse streamlines document management',
|
||||||
|
'Room-based collaboration features',
|
||||||
|
'Security and permission controls',
|
||||||
|
'Real-time messaging and notifications'
|
||||||
|
]) %}
|
||||||
|
<li><i class="fas fa-check text-success me-2"></i>{{ point }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary btn-lg px-4 py-3" data-bs-dismiss="modal">Close</button>
|
||||||
|
<a href="{{ cta_url|default(url_for('public.pricing')) }}" class="btn btn-primary btn-lg px-4 py-3">
|
||||||
|
<i class="fas fa-rocket me-2"></i>{{ cta_text|default('Get Started Now') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Unified button styling for modal */
|
||||||
|
.modal .btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0.6;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.modal .btn-close:hover {
|
||||||
|
background: rgba(108, 117, 125, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.modal .btn-close::before {
|
||||||
|
content: '×';
|
||||||
|
color: var(--text-dark);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.modal .btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .btn-secondary {
|
||||||
|
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.modal .btn-secondary:hover {
|
||||||
|
background: linear-gradient(135deg, #5a6268 0%, #495057 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(108, 117, 125, 0.3);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .btn-lg {
|
||||||
|
padding: 15px 40px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
<h6 class="mb-3" style="color: var(--white);">Company</h6>
|
<h6 class="mb-3" style="color: var(--white);">Company</h6>
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">About</a></li>
|
<li><a href="#" style="color: var(--border-light); text-decoration: none;">About</a></li>
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Blog</a></li>
|
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Careers</a></li>
|
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Careers</a></li>
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Press</a></li>
|
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Press</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -27,7 +26,6 @@
|
|||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Help Center</a></li>
|
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Help Center</a></li>
|
||||||
<li><a href="#contact" style="color: var(--border-light); text-decoration: none;">Contact</a></li>
|
<li><a href="#contact" style="color: var(--border-light); text-decoration: none;">Contact</a></li>
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Status</a></li>
|
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Security</a></li>
|
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Security</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,8 +34,7 @@
|
|||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Privacy</a></li>
|
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Privacy</a></li>
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Terms</a></li>
|
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Terms</a></li>
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">GDPR</a></li>
|
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Cookies</a></li>
|
||||||
<li><a href="#" style="color: var(--border-light); text-decoration: none;">Compliance</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu" style="background-color: var(--white); border: 1px solid var(--border-color); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);">
|
<ul class="dropdown-menu" style="background-color: var(--white); border: 1px solid var(--border-color); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);">
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.about') }}" style="color: var(--text-dark);">About</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('public.about') }}" style="color: var(--text-dark);">About</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.blog') }}" style="color: var(--text-dark);">Blog</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.careers') }}" style="color: var(--text-dark);">Careers</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('public.careers') }}" style="color: var(--text-dark);">Careers</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.press') }}" style="color: var(--text-dark);">Press</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('public.press') }}" style="color: var(--text-dark);">Press</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -36,7 +35,6 @@
|
|||||||
<ul class="dropdown-menu" style="background-color: var(--white); border: 1px solid var(--border-color); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);">
|
<ul class="dropdown-menu" style="background-color: var(--white); border: 1px solid var(--border-color); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);">
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.help_center') }}" style="color: var(--text-dark);">Help Center</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('public.help_center') }}" style="color: var(--text-dark);">Help Center</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.contact') }}" style="color: var(--text-dark);">Contact</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('public.contact') }}" style="color: var(--text-dark);">Contact</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.status') }}" style="color: var(--text-dark);">Status</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.security') }}" style="color: var(--text-dark);">Security</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('public.security') }}" style="color: var(--text-dark);">Security</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@@ -47,8 +45,7 @@
|
|||||||
<ul class="dropdown-menu" style="background-color: var(--white); border: 1px solid var(--border-color); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);">
|
<ul class="dropdown-menu" style="background-color: var(--white); border: 1px solid var(--border-color); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);">
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.privacy') }}" style="color: var(--text-dark);">Privacy</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('public.privacy') }}" style="color: var(--text-dark);">Privacy</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.terms') }}" style="color: var(--text-dark);">Terms</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('public.terms') }}" style="color: var(--text-dark);">Terms</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.gdpr') }}" style="color: var(--text-dark);">GDPR</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('public.cookies') }}" style="color: var(--text-dark);">Cookies</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('public.compliance') }}" style="color: var(--text-dark);">Compliance</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
73
templates/components/hero_section.html
Normal file
73
templates/components/hero_section.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<!-- Reusable Hero Section Component -->
|
||||||
|
<section class="hero-section">
|
||||||
|
<div class="floating-elements">
|
||||||
|
<div class="floating-element"></div>
|
||||||
|
<div class="floating-element"></div>
|
||||||
|
<div class="floating-element"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container text-center position-relative">
|
||||||
|
<h1 class="display-{{ title_size|default('3') }} fw-bold mb-4">{{ title }}</h1>
|
||||||
|
<p class="lead fs-{{ description_size|default('4') }} mb-5">{{ description }}</p>
|
||||||
|
{% if buttons %}
|
||||||
|
<div class="d-flex justify-content-center gap-3 flex-wrap">
|
||||||
|
{% for button in buttons %}
|
||||||
|
{% if button.type == 'link' %}
|
||||||
|
<a href="{{ button.url }}" class="btn btn-{{ button.style|default('light') }} btn-lg px-5 py-3">
|
||||||
|
{% if button.icon %}<i class="{{ button.icon }} me-2"></i>{% endif %}{{ button.text }}
|
||||||
|
</a>
|
||||||
|
{% elif button.type == 'modal' %}
|
||||||
|
<button type="button" class="btn btn-{{ button.style|default('outline-light') }} btn-lg px-5 py-3" data-bs-toggle="modal" data-bs-target="{{ button.target }}">
|
||||||
|
{% if button.icon %}<i class="{{ button.icon }} me-2"></i>{% endif %}{{ button.text }}
|
||||||
|
</button>
|
||||||
|
{% elif button.type == 'button' %}
|
||||||
|
<button type="button" class="btn btn-{{ button.style|default('light') }} btn-lg px-5 py-3" onclick="{{ button.onclick }}">
|
||||||
|
{% if button.icon %}<i class="{{ button.icon }} me-2"></i>{% endif %}{{ button.text }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero-section {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 120px 0 100px 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-elements {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-element {
|
||||||
|
position: absolute;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.3;
|
||||||
|
animation: float-around 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-element:nth-child(1) { top: 20%; left: 10%; animation-delay: 0s; }
|
||||||
|
.floating-element:nth-child(2) { top: 60%; right: 15%; animation-delay: 2s; }
|
||||||
|
.floating-element:nth-child(3) { bottom: 30%; left: 20%; animation-delay: 4s; }
|
||||||
|
|
||||||
|
@keyframes float-around {
|
||||||
|
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||||
|
25% { transform: translate(20px, -20px) rotate(90deg); }
|
||||||
|
50% { transform: translate(-10px, -40px) rotate(180deg); }
|
||||||
|
75% { transform: translate(-30px, -10px) rotate(270deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,104 @@
|
|||||||
<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 %}
|
||||||
|
<!-- Debug info -->
|
||||||
|
<div style="display: none;" id="pricing-debug">
|
||||||
|
<h4>Debug: Pricing Plans Found</h4>
|
||||||
|
{% for plan in pricing_plans %}
|
||||||
|
<div>
|
||||||
|
Plan: {{ plan.name }} (ID: {{ plan.id }})
|
||||||
|
- Monthly Price ID: {{ plan.stripe_monthly_price_id or 'None' }}
|
||||||
|
- Annual Price ID: {{ plan.stripe_annual_price_id or 'None' }}
|
||||||
|
- Is Custom: {{ plan.is_custom }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Dynamic Payment Button -->
|
||||||
|
{% if plan.is_custom %}
|
||||||
|
<a href="{{ contact_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>
|
||||||
|
{% else %}
|
||||||
|
{% if plan.stripe_monthly_price_id or plan.stripe_annual_price_id %}
|
||||||
|
<button class="btn {% if plan.is_popular %}btn-primary{% else %}btn-outline-primary{% endif %} btn-lg w-100 mt-auto px-4 py-3 checkout-button"
|
||||||
|
data-plan-id="{{ plan.id }}"
|
||||||
|
data-monthly-product-id="{{ plan.stripe_monthly_price_id or '' }}"
|
||||||
|
data-annual-product-id="{{ plan.stripe_annual_price_id or '' }}"
|
||||||
|
data-plan-name="{{ plan.name }}"
|
||||||
|
data-monthly-price="{{ plan.monthly_price or 0 }}"
|
||||||
|
data-annual-price="{{ plan.annual_price or 0 }}">
|
||||||
|
{{ plan.button_text }}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ contact_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>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</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">
|
||||||
@@ -14,7 +112,7 @@
|
|||||||
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
||||||
<span class="monthly-price">€29</span>
|
<span class="monthly-price">€29</span>
|
||||||
<span class="annual-price" style="display: none;">€23</span>
|
<span class="annual-price" style="display: none;">€23</span>
|
||||||
<span class="fs-6 text-muted">/month</span>
|
<span class="fs-6 text-muted price-period">/month</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-unstyled mb-4">
|
<ul class="list-unstyled mb-4">
|
||||||
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 5 rooms</li>
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 5 rooms</li>
|
||||||
@@ -24,7 +122,7 @@
|
|||||||
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Email support</li>
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Email support</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ contact_url }}" class="btn btn-outline-primary w-100 mt-auto">Get Started</a>
|
<a href="{{ contact_url }}" class="btn btn-outline-primary btn-lg w-100 mt-auto px-4 py-3">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +139,7 @@
|
|||||||
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
||||||
<span class="monthly-price">€99</span>
|
<span class="monthly-price">€99</span>
|
||||||
<span class="annual-price" style="display: none;">€79</span>
|
<span class="annual-price" style="display: none;">€79</span>
|
||||||
<span class="fs-6 text-muted">/month</span>
|
<span class="fs-6 text-muted price-period">/month</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-unstyled mb-4">
|
<ul class="list-unstyled mb-4">
|
||||||
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 25 rooms</li>
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 25 rooms</li>
|
||||||
@@ -51,7 +149,7 @@
|
|||||||
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Priority support</li>
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Priority support</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ contact_url }}" class="btn btn-primary w-100 mt-auto">Get Started</a>
|
<a href="{{ contact_url }}" class="btn btn-primary btn-lg w-100 mt-auto px-4 py-3">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +161,7 @@
|
|||||||
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
<div class="display-4 fw-bold mb-3" style="color: var(--primary-color);">
|
||||||
<span class="monthly-price">€299</span>
|
<span class="monthly-price">€299</span>
|
||||||
<span class="annual-price" style="display: none;">€239</span>
|
<span class="annual-price" style="display: none;">€239</span>
|
||||||
<span class="fs-6 text-muted">/month</span>
|
<span class="fs-6 text-muted price-period">/month</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-unstyled mb-4">
|
<ul class="list-unstyled mb-4">
|
||||||
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 100 rooms</li>
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Up to 100 rooms</li>
|
||||||
@@ -73,7 +171,7 @@
|
|||||||
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>24/7 dedicated support</li>
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>24/7 dedicated support</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ contact_url }}" class="btn btn-outline-primary w-100 mt-auto">Get Started</a>
|
<a href="{{ contact_url }}" class="btn btn-outline-primary btn-lg w-100 mt-auto px-4 py-3">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +190,7 @@
|
|||||||
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Dedicated account manager</li>
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Dedicated account manager</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ contact_url }}" class="btn btn-outline-primary w-100 mt-auto">Contact Sales</a>
|
<a href="{{ contact_url }}" class="btn btn-outline-primary btn-lg w-100 mt-auto px-4 py-3">Contact Sales</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,14 +205,37 @@
|
|||||||
</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() {
|
||||||
|
// Debug: Log pricing plans info
|
||||||
|
console.log('=== PRICING DEBUG INFO ===');
|
||||||
|
const checkoutButtons = document.querySelectorAll('.checkout-button');
|
||||||
|
console.log('Found checkout buttons:', checkoutButtons.length);
|
||||||
|
|
||||||
|
checkoutButtons.forEach((button, index) => {
|
||||||
|
console.log(`Button ${index + 1}:`, {
|
||||||
|
planId: button.getAttribute('data-plan-id'),
|
||||||
|
monthlyProductId: button.getAttribute('data-monthly-product-id'),
|
||||||
|
annualProductId: button.getAttribute('data-annual-product-id'),
|
||||||
|
planName: button.getAttribute('data-plan-name'),
|
||||||
|
monthlyPrice: button.getAttribute('data-monthly-price'),
|
||||||
|
annualPrice: button.getAttribute('data-annual-price')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show debug info if needed (uncomment to show)
|
||||||
|
// document.getElementById('pricing-debug').style.display = 'block';
|
||||||
|
|
||||||
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');
|
||||||
|
const pricePeriods = document.querySelectorAll('.price-period');
|
||||||
|
|
||||||
// Add CSS for switch styling
|
// Add CSS for switch styling
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
@@ -126,18 +247,159 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
.form-check-input:focus {
|
.form-check-input:focus {
|
||||||
box-shadow: 0 0 0 0.25rem rgba(var(--primary-color-rgb), 0.25) !important;
|
box-shadow: 0 0 0 0.25rem rgba(var(--primary-color-rgb), 0.25) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.price-number {
|
||||||
|
display: inline-block;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Function to animate number counting
|
||||||
|
function animateNumber(element, startValue, endValue, duration = 500) {
|
||||||
|
const start = performance.now();
|
||||||
|
const difference = endValue - startValue;
|
||||||
|
|
||||||
|
function updateNumber(currentTime) {
|
||||||
|
const elapsed = currentTime - start;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
// Easing function for smooth animation
|
||||||
|
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||||
|
const currentValue = startValue + (difference * easeOutQuart);
|
||||||
|
|
||||||
|
element.textContent = '€' + Math.round(currentValue);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(updateNumber);
|
||||||
|
} else {
|
||||||
|
// Ensure the final value is correct
|
||||||
|
element.textContent = '€' + endValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(updateNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to handle Stripe checkout
|
||||||
|
async function handleCheckout(planId, billingCycle) {
|
||||||
|
console.log('handleCheckout called with:', { planId, billingCycle });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody = {
|
||||||
|
plan_id: planId,
|
||||||
|
billing_cycle: billingCycle
|
||||||
|
};
|
||||||
|
console.log('Sending request to /api/create-checkout-session with body:', requestBody);
|
||||||
|
|
||||||
|
const response = await fetch('/api/create-checkout-session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Response status:', response.status);
|
||||||
|
console.log('Response ok:', response.ok);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Response error text:', errorText);
|
||||||
|
throw new Error(`Failed to create checkout session: ${response.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Response data:', data);
|
||||||
|
|
||||||
|
if (data.checkout_url) {
|
||||||
|
console.log('Redirecting to checkout URL:', data.checkout_url);
|
||||||
|
window.location.href = data.checkout_url;
|
||||||
|
} else {
|
||||||
|
console.error('No checkout URL received in response');
|
||||||
|
alert('Failed to create checkout session. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Checkout error:', error);
|
||||||
|
alert('Failed to start checkout. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click handlers for checkout buttons
|
||||||
|
document.querySelectorAll('.checkout-button').forEach(button => {
|
||||||
|
console.log('Adding click handler to checkout button:', button);
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
console.log('Checkout button clicked!');
|
||||||
|
console.log('Button data attributes:', {
|
||||||
|
planId: this.getAttribute('data-plan-id'),
|
||||||
|
monthlyProductId: this.getAttribute('data-monthly-product-id'),
|
||||||
|
annualProductId: this.getAttribute('data-annual-product-id'),
|
||||||
|
planName: this.getAttribute('data-plan-name'),
|
||||||
|
monthlyPrice: this.getAttribute('data-monthly-price'),
|
||||||
|
annualPrice: this.getAttribute('data-annual-price')
|
||||||
|
});
|
||||||
|
|
||||||
|
const planId = this.getAttribute('data-plan-id');
|
||||||
|
const monthlyProductId = this.getAttribute('data-monthly-product-id');
|
||||||
|
const annualProductId = this.getAttribute('data-annual-product-id');
|
||||||
|
const planName = this.getAttribute('data-plan-name');
|
||||||
|
const monthlyPrice = parseFloat(this.getAttribute('data-monthly-price'));
|
||||||
|
const annualPrice = parseFloat(this.getAttribute('data-annual-price'));
|
||||||
|
|
||||||
|
// Determine which billing cycle to use based on billing toggle
|
||||||
|
const isAnnual = billingToggle.checked;
|
||||||
|
const billingCycle = isAnnual ? 'annual' : 'monthly';
|
||||||
|
|
||||||
|
console.log('Billing toggle state:', { isAnnual, billingCycle });
|
||||||
|
console.log('Plan ID:', planId);
|
||||||
|
|
||||||
|
if (planId) {
|
||||||
|
console.log('Calling handleCheckout with planId and billingCycle');
|
||||||
|
handleCheckout(planId, billingCycle);
|
||||||
|
} else {
|
||||||
|
console.log('No plan ID found, redirecting to contact form');
|
||||||
|
// Fallback to contact form if no plan configured
|
||||||
|
window.location.href = '{{ contact_url }}';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
billingToggle.addEventListener('change', function() {
|
billingToggle.addEventListener('change', function() {
|
||||||
if (this.checked) {
|
if (this.checked) {
|
||||||
// Show annual prices
|
// Switch to annual prices with animation
|
||||||
monthlyPrices.forEach(price => price.style.display = 'none');
|
monthlyPrices.forEach((price, index) => {
|
||||||
annualPrices.forEach(price => price.style.display = 'inline');
|
const monthlyValue = parseInt(price.textContent.replace('€', ''));
|
||||||
|
const annualValue = parseInt(annualPrices[index].textContent.replace('€', ''));
|
||||||
|
|
||||||
|
// Store the original monthly value for later use
|
||||||
|
price.setAttribute('data-original-monthly', monthlyValue);
|
||||||
|
|
||||||
|
// Simply animate the number change
|
||||||
|
animateNumber(price, monthlyValue, annualValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update price periods to show "/year"
|
||||||
|
document.querySelectorAll('.fs-6.text-muted, .price-period').forEach(period => {
|
||||||
|
if (period.textContent.includes('/month')) {
|
||||||
|
period.textContent = '/year';
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Show monthly prices
|
// Switch to monthly prices with animation
|
||||||
monthlyPrices.forEach(price => price.style.display = 'inline');
|
monthlyPrices.forEach((price, index) => {
|
||||||
annualPrices.forEach(price => price.style.display = 'none');
|
const currentValue = parseInt(price.textContent.replace('€', ''));
|
||||||
|
const originalMonthlyValue = parseInt(price.getAttribute('data-original-monthly'));
|
||||||
|
|
||||||
|
// Simply animate the number change back to monthly
|
||||||
|
animateNumber(price, currentValue, originalMonthlyValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update price periods to show "/month"
|
||||||
|
document.querySelectorAll('.fs-6.text-muted, .price-period').forEach(period => {
|
||||||
|
if (period.textContent.includes('/year')) {
|
||||||
|
period.textContent = '/month';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/home.css') }}?v={{ 'css/home.css'|asset_version }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/home.css') }}?v={{ 'css/home.css'|asset_version }}">
|
||||||
<style>
|
<style>
|
||||||
.admin-link {
|
.admin-link {
|
||||||
@@ -38,7 +39,9 @@
|
|||||||
.hero-section {
|
.hero-section {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 100px 0;
|
padding: 120px 0 100px 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.stats-section {
|
.stats-section {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
@@ -64,16 +67,100 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
padding: 12px 30px;
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
.btn-outline-primary {
|
.btn-outline-primary {
|
||||||
border: 2px solid var(--primary-color);
|
border: 2px solid var(--primary-color);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
padding: 12px 30px;
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.btn-outline-primary:hover {
|
.btn-outline-primary:hover {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: linear-gradient(135deg, #5a6268 0%, #495057 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(108, 117, 125, 0.3);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-light {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-light:hover {
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
.btn-outline-light {
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||||
|
color: white;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-outline-light:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
.btn-lg {
|
||||||
|
padding: 15px 40px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.btn-close {
|
||||||
|
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.btn-close:hover {
|
||||||
|
background: linear-gradient(135deg, #5a6268 0%, #495057 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(108, 117, 125, 0.3);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.btn-close::before {
|
||||||
|
content: '×';
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Navigation dropdown styles */
|
/* Navigation dropdown styles */
|
||||||
@@ -101,26 +188,46 @@
|
|||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
color: var(--text-dark);
|
color: var(--text-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure hero buttons are clickable */
|
||||||
|
.hero-section .d-flex {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section .btn {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include 'components/header_nav.html' %}
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="hero-section">
|
{% with
|
||||||
<div class="container text-center">
|
title="Enterprise Document Management Made Simple",
|
||||||
<h1 class="display-3 fw-bold mb-4">Enterprise Document Management<br>Made Simple</h1>
|
description="Secure, intelligent, and scalable document management platform designed for modern enterprises. Streamline workflows, enhance collaboration, and protect your data.",
|
||||||
<p class="lead mb-5 fs-4">Secure, intelligent, and scalable document management platform designed for modern enterprises. Streamline workflows, enhance collaboration, and protect your data.</p>
|
buttons=[
|
||||||
<div class="d-flex justify-content-center gap-3 flex-wrap">
|
{
|
||||||
<a href="#contact" class="btn btn-light btn-lg px-5 py-3">
|
'type': 'link',
|
||||||
<i class="fas fa-rocket me-2"></i>Get Started
|
'url': url_for('public.pricing'),
|
||||||
</a>
|
'text': 'Get Started',
|
||||||
<a href="#features" class="btn btn-outline-light btn-lg px-5 py-3">
|
'icon': 'fas fa-rocket',
|
||||||
<i class="fas fa-play me-2"></i>Learn More
|
'style': 'light'
|
||||||
</a>
|
},
|
||||||
</div>
|
{
|
||||||
</div>
|
'type': 'modal',
|
||||||
</section>
|
'target': '#explainerVideoModal',
|
||||||
|
'text': 'Learn More',
|
||||||
|
'icon': 'fas fa-play',
|
||||||
|
'style': 'outline-light'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
%}
|
||||||
|
{% include 'components/hero_section.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<!-- Features Section -->
|
<!-- Features Section -->
|
||||||
<section id="features" class="py-5">
|
<section id="features" class="py-5">
|
||||||
@@ -287,7 +394,7 @@
|
|||||||
<div class="col-md-8 text-center">
|
<div class="col-md-8 text-center">
|
||||||
<h2 class="display-5 fw-bold mb-3">Ready to Get Started?</h2>
|
<h2 class="display-5 fw-bold mb-3">Ready to Get Started?</h2>
|
||||||
<p class="lead text-muted mb-5">Contact us today to learn how DocuPulse can transform your document management</p>
|
<p class="lead text-muted mb-5">Contact us today to learn how DocuPulse can transform your document management</p>
|
||||||
{% with primary_url=url_for('public.pricing'), primary_icon='fas fa-rocket', primary_text='Get Started', secondary_url='#contact', secondary_icon='fas fa-envelope', secondary_text='Contact Sales' %}
|
{% with primary_url=url_for('public.pricing'), primary_icon='fas fa-rocket', primary_text='Get Started', secondary_url=url_for('public.contact'), secondary_icon='fas fa-envelope', secondary_text='Contact Sales' %}
|
||||||
{% include 'components/cta_buttons.html' %}
|
{% include 'components/cta_buttons.html' %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
@@ -297,6 +404,24 @@
|
|||||||
|
|
||||||
{% include 'components/footer_nav.html' %}
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Explainer Video Modal -->
|
||||||
|
{% with
|
||||||
|
modal_title='DocuPulse Overview',
|
||||||
|
video_title='Explainer Video',
|
||||||
|
video_placeholder='Video placeholder - Replace with actual explainer video',
|
||||||
|
learning_title='What you\'ll learn:',
|
||||||
|
learning_points=[
|
||||||
|
'How DocuPulse streamlines document management',
|
||||||
|
'Room-based collaboration features',
|
||||||
|
'Security and permission controls',
|
||||||
|
'Real-time messaging and notifications'
|
||||||
|
],
|
||||||
|
cta_url=url_for('public.pricing'),
|
||||||
|
cta_text='Get Started Now'
|
||||||
|
%}
|
||||||
|
{% include 'components/explainer_video_modal.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<!-- Hidden Admin Link -->
|
<!-- Hidden Admin Link -->
|
||||||
<div class="admin-link">
|
<div class="admin-link">
|
||||||
<a href="{{ url_for('auth.login') }}" title="Admin Login">
|
<a href="{{ url_for('auth.login') }}" title="Admin Login">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ header(
|
{{ header(
|
||||||
title="Launching Instance",
|
title=is_update and "Updating Instance" or "Launching Instance",
|
||||||
description="Setting up your new DocuPulse instance",
|
description=is_update and "Updating your DocuPulse instance with the latest version" or "Setting up your new DocuPulse instance",
|
||||||
icon="fa-rocket"
|
icon="fa-arrow-up" if is_update else "fa-rocket"
|
||||||
) }}
|
) }}
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
@@ -78,6 +78,12 @@
|
|||||||
|
|
||||||
// Pass CSRF token to JavaScript
|
// Pass CSRF token to JavaScript
|
||||||
window.csrfToken = '{{ csrf_token }}';
|
window.csrfToken = '{{ csrf_token }}';
|
||||||
|
|
||||||
|
// Pass update parameters if this is an update operation
|
||||||
|
window.isUpdate = {{ 'true' if is_update else 'false' }};
|
||||||
|
window.updateInstanceId = '{{ instance_id or "" }}';
|
||||||
|
window.updateRepoId = '{{ repo_id or "" }}';
|
||||||
|
window.updateBranch = '{{ branch or "" }}';
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/launch_progress.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/launch_progress.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
432
templates/public/about.html
Normal file
432
templates/public/about.html
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>About Us - DocuPulse</title>
|
||||||
|
<meta name="description" content="Learn about DocuPulse - the team behind the enterprise document management platform that's transforming how businesses handle their documents.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.about-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-member {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-avatar {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
color: white;
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:nth-child(odd) .timeline-content {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 50%;
|
||||||
|
padding-right: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:nth-child(even) .timeline-content {
|
||||||
|
margin-left: 50%;
|
||||||
|
margin-right: 0;
|
||||||
|
padding-left: 40px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 25px;
|
||||||
|
box-shadow: 0 5px 15px var(--shadow-color);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.values-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 30px;
|
||||||
|
margin-top: 50px;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 10px 25px var(--shadow-color);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-card h4,
|
||||||
|
.value-card p {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timeline::before {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:nth-child(odd) .timeline-content,
|
||||||
|
.timeline-item:nth-child(even) .timeline-content {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
padding-left: 50px;
|
||||||
|
padding-right: 20px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles to match home page */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.btn-outline-primary {
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-outline-primary:hover {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
.btn-lg {
|
||||||
|
padding: 15px 40px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
{% with
|
||||||
|
title="About DocuPulse",
|
||||||
|
description="We're building the future of enterprise document management, one secure collaboration at a time.",
|
||||||
|
title_size="4",
|
||||||
|
description_size="5"
|
||||||
|
%}
|
||||||
|
{% include 'components/hero_section.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Mission & Vision -->
|
||||||
|
<section class="about-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="mission-card">
|
||||||
|
<div class="mission-icon">
|
||||||
|
<i class="fas fa-bullseye"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 fw-bold mb-3">Our Mission</h3>
|
||||||
|
<p class="text-muted">To revolutionize how enterprises manage, collaborate on, and secure their documents by providing an intuitive, scalable, and secure platform that empowers teams to work more efficiently.</p>
|
||||||
|
<p class="text-muted mb-0">We believe that document management shouldn't be a barrier to productivity, but a catalyst for innovation and growth.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="mission-card">
|
||||||
|
<div class="mission-icon">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 fw-bold mb-3">Our Vision</h3>
|
||||||
|
<p class="text-muted">To become the world's most trusted enterprise document management platform, setting the standard for security, collaboration, and user experience in the digital workplace.</p>
|
||||||
|
<p class="text-muted mb-0">We envision a future where document management is seamless, intelligent, and empowers every organization to achieve their full potential.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Company Stats -->
|
||||||
|
{% with stats=[
|
||||||
|
{'value': 500, 'suffix': '+', 'display': '500+', 'label': 'Enterprise Clients'},
|
||||||
|
{'value': 50, 'suffix': 'K+', 'display': '50K+', 'label': 'Active Users'},
|
||||||
|
{'value': 99.9, 'suffix': '%', 'display': '99.9%', 'label': 'Uptime'},
|
||||||
|
{'value': 24, 'suffix': '/7', 'display': '24/7', 'label': 'Support'}
|
||||||
|
] %}
|
||||||
|
{% include 'components/animated_numbers.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Our Values -->
|
||||||
|
<section class="about-section" style="background-color: var(--bg-color);">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Our Values</h2>
|
||||||
|
<p class="lead text-muted">The principles that guide everything we do</p>
|
||||||
|
</div>
|
||||||
|
<div class="values-grid">
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Security First</h4>
|
||||||
|
<p class="text-muted">We prioritize the security and privacy of our users' data above all else, implementing enterprise-grade security measures and maintaining strict compliance standards.</p>
|
||||||
|
</div>
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">User-Centric Design</h4>
|
||||||
|
<p class="text-muted">Every feature we build is designed with the user in mind, ensuring intuitive experiences that enhance productivity rather than hinder it.</p>
|
||||||
|
</div>
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-rocket"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Innovation</h4>
|
||||||
|
<p class="text-muted">We continuously push the boundaries of what's possible in document management, embracing new technologies and methodologies.</p>
|
||||||
|
</div>
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-handshake"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Trust & Transparency</h4>
|
||||||
|
<p class="text-muted">We build lasting relationships with our clients through honest communication, transparent practices, and reliable service delivery.</p>
|
||||||
|
</div>
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-cogs"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Excellence</h4>
|
||||||
|
<p class="text-muted">We strive for excellence in everything we do, from code quality to customer support, ensuring the highest standards of service.</p>
|
||||||
|
</div>
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-heart"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Passion</h4>
|
||||||
|
<p class="text-muted">Our team is passionate about solving real-world problems and making a positive impact on how organizations work and collaborate.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Our Story -->
|
||||||
|
<section class="about-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Our Story</h2>
|
||||||
|
<p class="lead text-muted">From startup to enterprise solution</p>
|
||||||
|
</div>
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<h4 class="fw-bold">2020 - The Beginning</h4>
|
||||||
|
<p class="text-muted">Founded by a team of experienced developers and enterprise architects who saw the need for a better document management solution.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<h4 class="fw-bold">2021 - First Release</h4>
|
||||||
|
<p class="text-muted">Launched our first version with core document management features and room-based collaboration.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<h4 class="fw-bold">2022 - Enterprise Growth</h4>
|
||||||
|
<p class="text-muted">Expanded to serve enterprise clients with advanced security features and compliance capabilities.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<h4 class="fw-bold">2023 - Global Expansion</h4>
|
||||||
|
<p class="text-muted">Reached 500+ enterprise clients worldwide and launched advanced AI-powered features.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<h4 class="fw-bold">2024 - Innovation Leader</h4>
|
||||||
|
<p class="text-muted">Continuing to innovate with cutting-edge features and expanding our global presence.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Leadership Team -->
|
||||||
|
<section class="about-section" style="background-color: var(--bg-color);">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Leadership Team</h2>
|
||||||
|
<p class="lead text-muted">Meet the people driving our mission forward</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="team-member">
|
||||||
|
<div class="team-avatar">EW</div>
|
||||||
|
<h4 class="fw-bold">Eric Wyns</h4>
|
||||||
|
<p class="text-primary fw-semibold">CEO & Co-Founder</p>
|
||||||
|
<p class="text-muted">Discription here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="team-member">
|
||||||
|
<div class="team-avatar">KA</div>
|
||||||
|
<h4 class="fw-bold">Kobe Amerijckx</h4>
|
||||||
|
<p class="text-primary fw-semibold">CTO & Co-Founder</p>
|
||||||
|
<p class="text-muted">Expert in cloud architecture and security with a background in building scalable platforms.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="team-member">
|
||||||
|
<div class="team-avatar">KT</div>
|
||||||
|
<h4 class="fw-bold">Kelly Tordeur</h4>
|
||||||
|
<p class="text-primary fw-semibold">VP of Product</p>
|
||||||
|
<p class="text-muted">Product leader with experience in user experience design and enterprise software development.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<section class="about-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8 text-center">
|
||||||
|
<h2 class="display-5 fw-bold mb-4">Join Us in Shaping the Future</h2>
|
||||||
|
<p class="lead text-muted mb-5">Ready to experience the next generation of document management? Let's work together to transform how your organization handles documents.</p>
|
||||||
|
{% with primary_url=url_for('public.contact'), primary_icon='fas fa-envelope', primary_text='Get in Touch', secondary_url=url_for('public.careers'), secondary_icon='fas fa-users', secondary_text='Join Our Team' %}
|
||||||
|
{% include 'components/cta_buttons.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
392
templates/public/careers.html
Normal file
392
templates/public/careers.html
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Careers - DocuPulse</title>
|
||||||
|
<meta name="description" content="Join the DocuPulse team and help us build the future of enterprise document management. Explore open positions and learn about our culture.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.careers-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.culture-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.culture-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.culture-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 10px 25px var(--shadow-color);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-tag {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-form {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
padding: 12px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0.2rem var(--primary-opacity-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-photo {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
height: 300px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles to match home page */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.btn-outline-primary {
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-outline-primary:hover {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
.btn-lg {
|
||||||
|
padding: 15px 40px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
{% with
|
||||||
|
title="Join Our Team",
|
||||||
|
description="Help us build the future of enterprise document management. We're looking for passionate individuals who want to make a difference.",
|
||||||
|
title_size="4",
|
||||||
|
description_size="5"
|
||||||
|
%}
|
||||||
|
{% include 'components/hero_section.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Company Culture -->
|
||||||
|
<section class="careers-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Our Culture</h2>
|
||||||
|
<p class="lead text-muted">What makes DocuPulse a great place to work</p>
|
||||||
|
</div>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="culture-card">
|
||||||
|
<div class="culture-icon">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 fw-bold mb-3">Collaborative Environment</h3>
|
||||||
|
<p class="text-muted">We believe in the power of teamwork. Every voice matters, and we encourage open communication and knowledge sharing across all levels.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="culture-card">
|
||||||
|
<div class="culture-icon">
|
||||||
|
<i class="fas fa-rocket"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 fw-bold mb-3">Innovation First</h3>
|
||||||
|
<p class="text-muted">We're constantly pushing boundaries and exploring new technologies. If you have an idea, we want to hear it and help you bring it to life.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="culture-card">
|
||||||
|
<div class="culture-icon">
|
||||||
|
<i class="fas fa-heart"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 fw-bold mb-3">Work-Life Balance</h3>
|
||||||
|
<p class="text-muted">We understand that great work comes from happy, well-rested people. We offer flexible schedules and encourage taking time to recharge.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Company Stats -->
|
||||||
|
{% with stats=[
|
||||||
|
{'value': 15, 'suffix': '+', 'display': '15+', 'label': 'Team Members'},
|
||||||
|
{'value': 10, 'suffix': '+', 'display': '10+', 'label': 'Countries'},
|
||||||
|
{'value': 4.8, 'suffix': '', 'display': '4.8', 'label': 'Glassdoor Rating'},
|
||||||
|
{'value': 95, 'suffix': '%', 'display': '95%', 'label': 'Retention Rate'}
|
||||||
|
] %}
|
||||||
|
{% include 'components/animated_numbers.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Open Positions -->
|
||||||
|
<section id="positions" class="careers-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Open Positions</h2>
|
||||||
|
<p class="lead text-muted">Find your perfect role at DocuPulse</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Engineering Positions -->
|
||||||
|
<div class="job-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="fw-bold mb-2">Senior Full Stack Engineer</h4>
|
||||||
|
<p class="text-muted mb-2">Engineering • Full-time • Remote</p>
|
||||||
|
</div>
|
||||||
|
<span class="job-tag">Remote</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-3">Join our engineering team to build scalable, secure, and user-friendly features for our enterprise document management platform.</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<span class="job-tag">Python</span>
|
||||||
|
<span class="job-tag">React</span>
|
||||||
|
<span class="job-tag">PostgreSQL</span>
|
||||||
|
<span class="job-tag">AWS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="job-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="fw-bold mb-2">DevOps Engineer</h4>
|
||||||
|
<p class="text-muted mb-2">Engineering • Full-time • Hybrid</p>
|
||||||
|
</div>
|
||||||
|
<span class="job-tag">Hybrid</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-3">Help us build and maintain our cloud infrastructure, ensuring high availability and security for our enterprise customers.</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<span class="job-tag">Docker</span>
|
||||||
|
<span class="job-tag">Kubernetes</span>
|
||||||
|
<span class="job-tag">AWS</span>
|
||||||
|
<span class="job-tag">Terraform</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="job-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="fw-bold mb-2">Frontend Engineer</h4>
|
||||||
|
<p class="text-muted mb-2">Engineering • Full-time • Remote</p>
|
||||||
|
</div>
|
||||||
|
<span class="job-tag">Remote</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-3">Create beautiful, intuitive user interfaces that make document management effortless for our users.</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<span class="job-tag">React</span>
|
||||||
|
<span class="job-tag">TypeScript</span>
|
||||||
|
<span class="job-tag">CSS3</span>
|
||||||
|
<span class="job-tag">Bootstrap</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Positions -->
|
||||||
|
<div class="job-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="fw-bold mb-2">Product Manager</h4>
|
||||||
|
<p class="text-muted mb-2">Product • Full-time • Hybrid</p>
|
||||||
|
</div>
|
||||||
|
<span class="job-tag">Hybrid</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-3">Drive product strategy and execution, working closely with engineering and design teams to deliver exceptional user experiences.</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<span class="job-tag">Product Strategy</span>
|
||||||
|
<span class="job-tag">User Research</span>
|
||||||
|
<span class="job-tag">Agile</span>
|
||||||
|
<span class="job-tag">Analytics</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sales Positions -->
|
||||||
|
<div class="job-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="fw-bold mb-2">Enterprise Sales Representative</h4>
|
||||||
|
<p class="text-muted mb-2">Sales • Full-time • Remote</p>
|
||||||
|
</div>
|
||||||
|
<span class="job-tag">Remote</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-3">Help enterprise organizations transform their document management processes with our innovative platform.</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<span class="job-tag">B2B Sales</span>
|
||||||
|
<span class="job-tag">Enterprise</span>
|
||||||
|
<span class="job-tag">SaaS</span>
|
||||||
|
<span class="job-tag">CRM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="application-form">
|
||||||
|
<h4 class="fw-bold mb-4">General Application</h4>
|
||||||
|
<p class="text-muted mb-4">Don't see a perfect fit? Send us your resume and we'll keep you in mind for future opportunities.</p>
|
||||||
|
<form>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">Full Name *</label>
|
||||||
|
<input type="text" class="form-control" id="name" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email Address *</label>
|
||||||
|
<input type="email" class="form-control" id="email" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="position" class="form-label">Position of Interest</label>
|
||||||
|
<select class="form-control" id="position">
|
||||||
|
<option value="">Select a position</option>
|
||||||
|
<option value="engineering">Engineering</option>
|
||||||
|
<option value="product">Product</option>
|
||||||
|
<option value="sales">Sales</option>
|
||||||
|
<option value="marketing">Marketing</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="message" class="form-label">Why DocuPulse?</label>
|
||||||
|
<textarea class="form-control" id="message" rows="4" placeholder="Tell us why you'd like to join our team..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="resume" class="form-label">Resume/CV</label>
|
||||||
|
<input type="file" class="form-control" id="resume" accept=".pdf,.doc,.docx">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>Submit Application
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Team Photo Section -->
|
||||||
|
<section class="careers-section" style="background-color: var(--bg-color);">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="team-photo">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<h2 class="display-5 fw-bold mb-4">Meet Our Team</h2>
|
||||||
|
<p class="lead text-muted mb-4">We're a diverse group of passionate individuals from around the world, united by our mission to revolutionize document management.</p>
|
||||||
|
<p class="text-muted mb-4">Our team values creativity, collaboration, and continuous learning. We believe that the best solutions come from diverse perspectives and open dialogue.</p>
|
||||||
|
<a href="{{ url_for('public.about') }}" class="btn btn-outline-primary btn-lg">
|
||||||
|
<i class="fas fa-users me-2"></i>Learn More About Us
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<section class="careers-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8 text-center">
|
||||||
|
<h2 class="display-5 fw-bold mb-4">Ready to Join Us?</h2>
|
||||||
|
<p class="lead text-muted mb-5">Take the first step towards an exciting career at DocuPulse. We can't wait to meet you!</p>
|
||||||
|
<div class="d-flex justify-content-center gap-3 flex-wrap">
|
||||||
|
<a href="#positions" class="btn btn-primary btn-lg px-5 py-3">
|
||||||
|
<i class="fas fa-search me-2"></i>View All Positions
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('public.contact') }}" class="btn btn-outline-primary btn-lg px-5 py-3">
|
||||||
|
<i class="fas fa-envelope me-2"></i>Get in Touch
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
536
templates/public/compliance.html
Normal file
536
templates/public/compliance.html
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Compliance & Certifications - DocuPulse</title>
|
||||||
|
<meta name="description" content="Learn about DocuPulse's compliance certifications including SOC 2, ISO 27001, GDPR, and other industry standards.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.legal-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 25px var(--shadow-color);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 80px 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header .container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 3px solid var(--primary-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box h5 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certification-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
|
gap: 25px;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certification-card {
|
||||||
|
background: var(--white);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certification-card:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.certification-card h4 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certification-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-certified {
|
||||||
|
background: rgba(40, 167, 69, 0.1);
|
||||||
|
color: #28a745;
|
||||||
|
border: 1px solid #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
color: #ffc107;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-in-progress {
|
||||||
|
background: rgba(0, 123, 255, 0.1);
|
||||||
|
color: #007bff;
|
||||||
|
border: 1px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-table {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 5px 15px var(--shadow-color);
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-table th {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-table td {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-updated {
|
||||||
|
background: var(--light-bg);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: relative;
|
||||||
|
padding-left: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li:before {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.legal-content {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certification-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-table {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Header Section -->
|
||||||
|
<section class="legal-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="display-4 fw-bold mb-3">Compliance & Certifications</h1>
|
||||||
|
<p class="lead opacity-75">Meeting the highest standards of security and compliance</p>
|
||||||
|
<p class="opacity-75">Last updated: December 2024</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Compliance Content -->
|
||||||
|
<section class="legal-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="legal-content">
|
||||||
|
<h2 class="section-title">1. Our Compliance Commitment</h2>
|
||||||
|
<p>At DocuPulse, we understand that compliance is not just about meeting regulatory requirements—it's about building trust with our customers and ensuring the highest standards of security and data protection. We maintain rigorous compliance programs and regularly undergo third-party audits to validate our security practices.</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<h5><i class="fas fa-certificate me-2"></i>Certification Status</h5>
|
||||||
|
<p class="mb-0">All our certifications are current and regularly audited. We provide compliance reports and documentation to enterprise customers upon request.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">2. Security Certifications</h2>
|
||||||
|
|
||||||
|
<div class="certification-grid">
|
||||||
|
<div class="certification-card">
|
||||||
|
<div class="certification-icon">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h4>SOC 2 Type II</h4>
|
||||||
|
<span class="status-badge status-certified">Certified</span>
|
||||||
|
<p>Service Organization Control 2 Type II certification demonstrates our commitment to security, availability, processing integrity, confidentiality, and privacy.</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Annual third-party audits</li>
|
||||||
|
<li>Security controls validation</li>
|
||||||
|
<li>Availability monitoring</li>
|
||||||
|
<li>Data protection measures</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Last Audit:</strong> December 2024</p>
|
||||||
|
<p><strong>Next Audit:</strong> December 2025</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="certification-card">
|
||||||
|
<div class="certification-icon">
|
||||||
|
<i class="fas fa-lock"></i>
|
||||||
|
</div>
|
||||||
|
<h4>ISO 27001</h4>
|
||||||
|
<span class="status-badge status-certified">Certified</span>
|
||||||
|
<p>International standard for information security management systems, ensuring systematic approach to managing sensitive company information.</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Information security framework</li>
|
||||||
|
<li>Risk management processes</li>
|
||||||
|
<li>Security controls implementation</li>
|
||||||
|
<li>Continuous improvement</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Certification Date:</strong> March 2024</p>
|
||||||
|
<p><strong>Valid Until:</strong> March 2027</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="certification-card">
|
||||||
|
<div class="certification-icon">
|
||||||
|
<i class="fas fa-cloud"></i>
|
||||||
|
</div>
|
||||||
|
<h4>Cloud Security Alliance</h4>
|
||||||
|
<span class="status-badge status-certified">Certified</span>
|
||||||
|
<p>CSA STAR certification demonstrates our compliance with cloud security best practices and industry standards.</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Cloud security controls</li>
|
||||||
|
<li>Data protection standards</li>
|
||||||
|
<li>Transparency reporting</li>
|
||||||
|
<li>Security assessment</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Certification Date:</strong> June 2024</p>
|
||||||
|
<p><strong>Valid Until:</strong> June 2025</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">3. Privacy & Data Protection</h2>
|
||||||
|
|
||||||
|
<div class="certification-grid">
|
||||||
|
<div class="certification-card">
|
||||||
|
<div class="certification-icon">
|
||||||
|
<i class="fas fa-user-shield"></i>
|
||||||
|
</div>
|
||||||
|
<h4>GDPR Compliance</h4>
|
||||||
|
<span class="status-badge status-certified">Compliant</span>
|
||||||
|
<p>Full compliance with the General Data Protection Regulation, ensuring the protection of EU residents' personal data.</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Data subject rights</li>
|
||||||
|
<li>Privacy by design</li>
|
||||||
|
<li>Data protection impact assessments</li>
|
||||||
|
<li>Breach notification procedures</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Compliance Date:</strong> May 2018</p>
|
||||||
|
<p><strong>Status:</strong> Continuously monitored</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="certification-card">
|
||||||
|
<div class="certification-icon">
|
||||||
|
<i class="fas fa-california"></i>
|
||||||
|
</div>
|
||||||
|
<h4>CCPA/CPRA</h4>
|
||||||
|
<span class="status-badge status-certified">Compliant</span>
|
||||||
|
<p>California Consumer Privacy Act and California Privacy Rights Act compliance for California residents.</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Consumer rights management</li>
|
||||||
|
<li>Data disclosure requirements</li>
|
||||||
|
<li>Opt-out mechanisms</li>
|
||||||
|
<li>Privacy notices</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Compliance Date:</strong> January 2020</p>
|
||||||
|
<p><strong>Status:</strong> Continuously monitored</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="certification-card">
|
||||||
|
<div class="certification-icon">
|
||||||
|
<i class="fas fa-globe"></i>
|
||||||
|
</div>
|
||||||
|
<h4>International Standards</h4>
|
||||||
|
<span class="status-badge status-certified">Compliant</span>
|
||||||
|
<p>Compliance with various international privacy and data protection regulations.</p>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>LGPD (Brazil)</li>
|
||||||
|
<li>PIPEDA (Canada)</li>
|
||||||
|
<li>POPIA (South Africa)</li>
|
||||||
|
<li>APEC Privacy Framework</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Status:</strong> Continuously monitored</p>
|
||||||
|
<p><strong>Updates:</strong> As regulations evolve</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">4. Industry-Specific Compliance</h2>
|
||||||
|
|
||||||
|
<div class="compliance-table">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Standard</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last Review</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>HIPAA</td>
|
||||||
|
<td>Health Insurance Portability and Accountability Act</td>
|
||||||
|
<td><span class="status-badge status-certified">Compliant</span></td>
|
||||||
|
<td>November 2024</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SOX</td>
|
||||||
|
<td>Sarbanes-Oxley Act for financial reporting</td>
|
||||||
|
<td><span class="status-badge status-certified">Compliant</span></td>
|
||||||
|
<td>October 2024</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>PCI DSS</td>
|
||||||
|
<td>Payment Card Industry Data Security Standard</td>
|
||||||
|
<td><span class="status-badge status-certified">Compliant</span></td>
|
||||||
|
<td>September 2024</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FedRAMP</td>
|
||||||
|
<td>Federal Risk and Authorization Management Program</td>
|
||||||
|
<td><span class="status-badge status-in-progress">In Progress</span></td>
|
||||||
|
<td>Q1 2025</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>NIST</td>
|
||||||
|
<td>National Institute of Standards and Technology</td>
|
||||||
|
<td><span class="status-badge status-certified">Compliant</span></td>
|
||||||
|
<td>August 2024</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">5. Security Controls & Measures</h2>
|
||||||
|
<p>Our comprehensive security program includes the following controls and measures:</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h4>Technical Controls</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Multi-factor authentication (MFA)</li>
|
||||||
|
<li>End-to-end encryption (AES-256)</li>
|
||||||
|
<li>Network security and firewalls</li>
|
||||||
|
<li>Intrusion detection and prevention</li>
|
||||||
|
<li>Vulnerability management</li>
|
||||||
|
<li>Security monitoring and alerting</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h4>Organizational Controls</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Security policies and procedures</li>
|
||||||
|
<li>Employee security training</li>
|
||||||
|
<li>Background checks and screening</li>
|
||||||
|
<li>Incident response procedures</li>
|
||||||
|
<li>Business continuity planning</li>
|
||||||
|
<li>Regular security assessments</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">6. Audit & Assessment Schedule</h2>
|
||||||
|
<p>We maintain a regular schedule of internal and external audits to ensure ongoing compliance:</p>
|
||||||
|
|
||||||
|
<div class="compliance-table">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Audit Type</th>
|
||||||
|
<th>Frequency</th>
|
||||||
|
<th>Next Due</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>SOC 2 Type II</td>
|
||||||
|
<td>Annual</td>
|
||||||
|
<td>December 2025</td>
|
||||||
|
<td><span class="status-badge status-certified">Scheduled</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>ISO 27001</td>
|
||||||
|
<td>Annual Surveillance</td>
|
||||||
|
<td>March 2025</td>
|
||||||
|
<td><span class="status-badge status-certified">Scheduled</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Penetration Testing</td>
|
||||||
|
<td>Quarterly</td>
|
||||||
|
<td>March 2025</td>
|
||||||
|
<td><span class="status-badge status-certified">Scheduled</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Vulnerability Assessment</td>
|
||||||
|
<td>Monthly</td>
|
||||||
|
<td>January 2025</td>
|
||||||
|
<td><span class="status-badge status-certified">Ongoing</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Security Training</td>
|
||||||
|
<td>Quarterly</td>
|
||||||
|
<td>March 2025</td>
|
||||||
|
<td><span class="status-badge status-certified">Scheduled</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">7. Compliance Documentation</h2>
|
||||||
|
<p>We provide comprehensive compliance documentation to our enterprise customers:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>SOC 2 Type II Reports:</strong> Available to enterprise customers under NDA</li>
|
||||||
|
<li><strong>ISO 27001 Certificates:</strong> Available upon request</li>
|
||||||
|
<li><strong>Security Questionnaires:</strong> Standardized responses for common frameworks</li>
|
||||||
|
<li><strong>Compliance Whitepapers:</strong> Detailed documentation of our controls</li>
|
||||||
|
<li><strong>Audit Reports:</strong> Third-party assessment results</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<h5><i class="fas fa-file-contract me-2"></i>Documentation Requests</h5>
|
||||||
|
<p class="mb-0">Enterprise customers can request compliance documentation by contacting our compliance team at compliance@docupulse.com. We typically respond within 2-3 business days.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">8. Continuous Improvement</h2>
|
||||||
|
<p>Our compliance program is continuously evolving to meet new requirements and best practices:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Regular review and updates of security policies</li>
|
||||||
|
<li>Monitoring of emerging threats and vulnerabilities</li>
|
||||||
|
<li>Adoption of new security technologies and practices</li>
|
||||||
|
<li>Participation in industry working groups and forums</li>
|
||||||
|
<li>Regular training and awareness programs for staff</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="contact-info">
|
||||||
|
<h3><i class="fas fa-certificate me-2"></i>Compliance Team</h3>
|
||||||
|
<p>For compliance-related questions or documentation requests, contact our compliance team:</p>
|
||||||
|
<p><strong>Email:</strong> <a href="mailto:compliance@docupulse.com">compliance@docupulse.com</a></p>
|
||||||
|
<p><strong>Address:</strong> DocuPulse Inc., 123 Business Ave, Suite 100, City, State 12345</p>
|
||||||
|
<p><strong>Phone:</strong> <a href="tel:+1-555-123-4567">+1 (555) 123-4567</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="last-updated">
|
||||||
|
<p class="mb-0"><strong>Last Updated:</strong> December 2024</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
<style>
|
<style>
|
||||||
.contact-section {
|
.contact-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
@@ -46,16 +47,40 @@
|
|||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
box-shadow: 0 0 0 0.2rem var(--primary-opacity-15);
|
box-shadow: 0 0 0 0.2rem var(--primary-opacity-15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Button styles to match other pages */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 25px;
|
||||||
padding: 12px 30px;
|
padding: 12px 30px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: linear-gradient(135deg, var(--primary-light) 0%, var(--secondary-light) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.btn-outline-primary {
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-outline-primary:hover {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
.btn-lg {
|
||||||
|
padding: 15px 40px;
|
||||||
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -63,12 +88,14 @@
|
|||||||
{% include 'components/header_nav.html' %}
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="hero-section">
|
{% with
|
||||||
<div class="container text-center">
|
title="Get in Touch",
|
||||||
<h1 class="display-4 fw-bold mb-4">Get in Touch</h1>
|
description="We're here to help with your enterprise document management needs",
|
||||||
<p class="lead fs-5">We're here to help with your enterprise document management needs</p>
|
title_size="4",
|
||||||
</div>
|
description_size="5"
|
||||||
</section>
|
%}
|
||||||
|
{% include 'components/hero_section.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<!-- Contact Information -->
|
<!-- Contact Information -->
|
||||||
<section class="contact-section">
|
<section class="contact-section">
|
||||||
|
|||||||
0
templates/public/cookies.html
Normal file
0
templates/public/cookies.html
Normal file
@@ -8,23 +8,50 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
<style>
|
<style>
|
||||||
.feature-section {
|
/* Enhanced Features Page Styles */
|
||||||
padding: 80px 0;
|
.hero-section {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 120px 0 80px 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-section {
|
||||||
|
padding: 100px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-section:nth-child(even) {
|
||||||
|
background: linear-gradient(135deg, var(--bg-color) 0%, rgba(var(--primary-color-rgb), 0.02) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.feature-card {
|
.feature-card {
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 40px 30px;
|
padding: 40px;
|
||||||
box-shadow: 0 10px 30px var(--shadow-color);
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
transition: all 0.4s ease;
|
transition: all 0.4s ease;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card::before {
|
.feature-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -36,27 +63,35 @@
|
|||||||
transform: scaleX(0);
|
transform: scaleX(0);
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card:hover::before {
|
.feature-card:hover::before {
|
||||||
transform: scaleX(1);
|
transform: scaleX(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card:hover {
|
.feature-card:hover {
|
||||||
transform: translateY(-10px);
|
transform: translateY(-10px);
|
||||||
box-shadow: 0 20px 40px var(--shadow-color-light);
|
box-shadow: 0 25px 50px var(--shadow-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon {
|
.feature-icon {
|
||||||
width: 90px;
|
width: 80px;
|
||||||
height: 90px;
|
height: 80px;
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
border-radius: 50%;
|
border-radius: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0 auto 30px;
|
margin-bottom: 25px;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 2.2rem;
|
font-size: 2rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feature-card:hover .feature-icon {
|
||||||
|
transform: scale(1.1) rotate(5deg);
|
||||||
|
}
|
||||||
|
|
||||||
.feature-icon::after {
|
.feature-icon::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -65,87 +100,27 @@
|
|||||||
right: -5px;
|
right: -5px;
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
border-radius: 50%;
|
border-radius: 25px;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
opacity: 0.3;
|
opacity: 0.2;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card:hover .feature-icon::after {
|
.feature-card:hover .feature-icon::after {
|
||||||
transform: scale(1.2);
|
transform: scale(1.2);
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
.hero-section {
|
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 120px 0 80px 0;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hero-section::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
.section-title {
|
|
||||||
color: var(--text-dark);
|
.feature-showcase {
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.section-title::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: -10px;
|
|
||||||
left: 0;
|
|
||||||
width: 60px;
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
.section-subtitle {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
.visual-guide-placeholder {
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 40px;
|
|
||||||
text-align: center;
|
|
||||||
min-height: 350px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 15px 35px var(--shadow-color);
|
|
||||||
}
|
|
||||||
.visual-guide-placeholder::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.1) 50%, transparent 70%);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.6s ease;
|
|
||||||
}
|
|
||||||
.visual-guide-placeholder:hover::before {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
.feature-highlight {
|
|
||||||
background: linear-gradient(135deg, var(--primary-bg-light) 0%, var(--secondary-bg-light) 100%);
|
background: linear-gradient(135deg, var(--primary-bg-light) 0%, var(--secondary-bg-light) 100%);
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
padding: 60px 40px;
|
padding: 60px 40px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 40px var(--shadow-color);
|
||||||
}
|
}
|
||||||
.feature-highlight::before {
|
|
||||||
|
.feature-showcase::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -50%;
|
top: -50%;
|
||||||
@@ -155,66 +130,253 @@
|
|||||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||||
animation: float 6s ease-in-out infinite;
|
animation: float 6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||||
50% { transform: translateY(-20px) rotate(180deg); }
|
50% { transform: translateY(-20px) rotate(180deg); }
|
||||||
}
|
}
|
||||||
.staggered-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 30px;
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
.staggered-item:nth-child(odd) {
|
|
||||||
transform: translateY(30px);
|
|
||||||
}
|
|
||||||
.floating-elements {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.floating-element {
|
|
||||||
position: absolute;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
|
||||||
border-radius: 50%;
|
|
||||||
opacity: 0.1;
|
|
||||||
animation: float-around 8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.floating-element:nth-child(1) { top: 20%; left: 10%; animation-delay: 0s; }
|
|
||||||
.floating-element:nth-child(2) { top: 60%; right: 15%; animation-delay: 2s; }
|
|
||||||
.floating-element:nth-child(3) { bottom: 30%; left: 20%; animation-delay: 4s; }
|
|
||||||
@keyframes float-around {
|
|
||||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
|
||||||
25% { transform: translate(20px, -20px) rotate(90deg); }
|
|
||||||
50% { transform: translate(-10px, -40px) rotate(180deg); }
|
|
||||||
75% { transform: translate(-30px, -10px) rotate(270deg); }
|
|
||||||
}
|
|
||||||
.gradient-text {
|
.gradient-text {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
.feature-badge {
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--text-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 30px;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-highlight {
|
||||||
|
background: linear-gradient(135deg, var(--white) 0%, rgba(var(--primary-color-rgb), 0.02) 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-highlight::before {
|
||||||
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -15px;
|
top: 0;
|
||||||
right: 20px;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-highlight:hover::before {
|
||||||
|
opacity: 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-highlight > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-highlight:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-demo {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-demo::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(45deg, transparent 30%, rgba(var(--primary-color-rgb), 0.05) 50%, transparent 70%);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-demo:hover::before {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-placeholder {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-placeholder i {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 8px 16px;
|
padding: 80px 0;
|
||||||
border-radius: 20px;
|
position: relative;
|
||||||
font-size: 0.8rem;
|
overflow: hidden;
|
||||||
font-weight: 600;
|
|
||||||
transform: rotate(3deg);
|
|
||||||
}
|
}
|
||||||
.additional-features .section-title::after {
|
|
||||||
display: none;
|
.stats-section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-light {
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||||
|
color: white;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-light:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: 15px 40px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unified button styling */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.btn-outline-primary {
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-outline-primary:hover {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: linear-gradient(135deg, #5a6268 0%, #495057 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(108, 117, 125, 0.3);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation classes */
|
||||||
|
.fade-in-up {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
transition: all 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-up.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-delay-1 { transition-delay: 0.1s; }
|
||||||
|
.stagger-delay-2 { transition-delay: 0.2s; }
|
||||||
|
.stagger-delay-3 { transition-delay: 0.3s; }
|
||||||
|
.stagger-delay-4 { transition-delay: 0.4s; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero-section {
|
||||||
|
padding: 100px 0 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-section {
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-showcase {
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -222,201 +384,171 @@
|
|||||||
{% include 'components/header_nav.html' %}
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="hero-section">
|
{% with
|
||||||
<div class="floating-elements">
|
title="Powerful Features for Modern Enterprises",
|
||||||
<div class="floating-element"></div>
|
description="Discover how DocuPulse transforms document management with intelligent workflows, secure collaboration, and scalable architecture.",
|
||||||
<div class="floating-element"></div>
|
title_size="4",
|
||||||
<div class="floating-element"></div>
|
description_size="5",
|
||||||
</div>
|
buttons=[
|
||||||
<div class="container text-center position-relative">
|
{
|
||||||
<h1 class="display-4 fw-bold mb-4">Powerful Features for Modern Enterprises</h1>
|
'type': 'modal',
|
||||||
<p class="lead fs-5">Discover how DocuPulse transforms document management with intelligent workflows, secure collaboration, and scalable architecture.</p>
|
'target': '#explainerVideoModal',
|
||||||
</div>
|
'text': 'Watch Demo',
|
||||||
</section>
|
'icon': 'fas fa-play',
|
||||||
|
'style': 'outline-light'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
%}
|
||||||
|
{% include 'components/hero_section.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<!-- Room-Based Workspaces Feature -->
|
<!-- Key Features Grid -->
|
||||||
<section class="feature-section">
|
<section class="feature-section">
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="feature-highlight">
|
|
||||||
<h2 class="section-title gradient-text">Room-Based Workspaces</h2>
|
|
||||||
<p class="section-subtitle">Create isolated collaboration environments with granular permissions, file storage, and real-time messaging for teams and projects.</p>
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Isolated team spaces for each project</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Granular permissions and access controls</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Integrated file storage and messaging</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Real-time collaboration tools</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="visual-guide-placeholder" style="background: var(--bg-color);">
|
|
||||||
<div>
|
|
||||||
<i class="fas fa-image fa-4x text-muted mb-3"></i>
|
|
||||||
<p class="text-muted fw-bold">Visual Guide: Room Workspace Interface</p>
|
|
||||||
<small class="text-muted">Screenshot or demo of room creation and management</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Client Document Requests Feature -->
|
|
||||||
<section class="feature-section" style="background-color: var(--bg-color);">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-lg-6 order-lg-2">
|
|
||||||
<div class="feature-highlight">
|
|
||||||
<h2 class="section-title gradient-text">Client Document Requests</h2>
|
|
||||||
<p class="section-subtitle">Streamlined communication system for requesting documents from clients with file attachments and conversation tracking.</p>
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Document request workflows</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Conversation tracking and history</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>File attachments and sharing</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Status tracking and notifications</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 order-lg-1">
|
|
||||||
<div class="visual-guide-placeholder" style="background: var(--white);">
|
|
||||||
<div>
|
|
||||||
<i class="fas fa-image fa-4x text-muted mb-3"></i>
|
|
||||||
<p class="text-muted fw-bold">Visual Guide: Document Request Flow</p>
|
|
||||||
<small class="text-muted">Workflow diagram or interface screenshot</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Advanced File Management Feature -->
|
|
||||||
<section class="feature-section">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="feature-highlight">
|
|
||||||
<h2 class="section-title gradient-text">Advanced File Management</h2>
|
|
||||||
<p class="section-subtitle">Upload, organize, search, and manage files with hierarchical folders and comprehensive metadata tracking.</p>
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Hierarchical folder structure</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Advanced search and filtering</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Metadata tracking and organization</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>File versioning and history</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="visual-guide-placeholder" style="background: var(--bg-color);">
|
|
||||||
<div>
|
|
||||||
<i class="fas fa-image fa-4x text-muted mb-3"></i>
|
|
||||||
<p class="text-muted fw-bold">Visual Guide: File Management Interface</p>
|
|
||||||
<small class="text-muted">File browser and organization interface</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Security & Compliance Feature -->
|
|
||||||
<section class="feature-section" style="background-color: var(--bg-color);">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-lg-6 order-lg-2">
|
|
||||||
<div class="feature-highlight">
|
|
||||||
<h2 class="section-title gradient-text">Security & Compliance</h2>
|
|
||||||
<p class="section-subtitle">Enterprise-grade security with comprehensive compliance features for data protection and regulatory requirements.</p>
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Role-based access controls</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>End-to-end encryption</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>GDPR and HIPAA compliance</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Comprehensive audit logging</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 order-lg-1">
|
|
||||||
<div class="visual-guide-placeholder" style="background: var(--white);">
|
|
||||||
<div>
|
|
||||||
<i class="fas fa-image fa-4x text-muted mb-3"></i>
|
|
||||||
<p class="text-muted fw-bold">Visual Guide: Security Dashboard</p>
|
|
||||||
<small class="text-muted">Security settings and compliance reports</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- White Labeling Feature -->
|
|
||||||
<section class="feature-section">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="feature-highlight">
|
|
||||||
<h2 class="section-title gradient-text">White Labeling</h2>
|
|
||||||
<p class="section-subtitle">Complete brand customization with custom logos, company colors, and personalized branding throughout the platform.</p>
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Custom company logo upload</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Brand color customization</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Personalized email templates</li>
|
|
||||||
<li class="mb-3"><i class="fas fa-check text-success me-2"></i>Complete platform rebranding</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="visual-guide-placeholder" style="background: var(--bg-color);">
|
|
||||||
<div>
|
|
||||||
<i class="fas fa-image fa-4x text-muted mb-3"></i>
|
|
||||||
<p class="text-muted fw-bold">Visual Guide: Brand Customization</p>
|
|
||||||
<small class="text-muted">Logo upload and color customization interface</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Additional Features Grid -->
|
|
||||||
<section class="feature-section additional-features" style="background-color: var(--bg-color);">
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="text-center mb-5">
|
<div class="text-center mb-5">
|
||||||
<h2 class="section-title gradient-text" style="position: relative;">Additional Features</h2>
|
<h2 class="section-title gradient-text">Core Features</h2>
|
||||||
<p class="section-subtitle">More powerful tools to enhance your document management workflow</p>
|
<p class="section-subtitle">Everything you need to revolutionize your document management workflow</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-grid">
|
||||||
|
<div class="feature-card fade-in-up">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-door-open"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 fw-bold mb-3">Room-Based Workspaces</h3>
|
||||||
|
<p class="text-muted mb-4">Create isolated collaboration environments with granular permissions, file storage, and real-time messaging for teams and projects.</p>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Isolated team spaces</li>
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Granular permissions</li>
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Real-time messaging</li>
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Integrated file storage</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card fade-in-up stagger-delay-1">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 fw-bold mb-3">Client Document Requests</h3>
|
||||||
|
<p class="text-muted mb-4">Streamlined communication system for requesting documents from clients with file attachments and conversation tracking.</p>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Document workflows</li>
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Conversation tracking</li>
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>File attachments</li>
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Status notifications</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card fade-in-up stagger-delay-2">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-folder-tree"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 fw-bold mb-3">Advanced File Management</h3>
|
||||||
|
<p class="text-muted mb-4">Upload, organize, search, and manage files with hierarchical folders and comprehensive metadata tracking.</p>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Hierarchical folders</li>
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Advanced search</li>
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>Metadata tracking</li>
|
||||||
|
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>File versioning</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Interactive Demo Section -->
|
||||||
|
<section class="feature-section" style="background-color: var(--bg-color);">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="feature-showcase">
|
||||||
|
<h2 class="section-title gradient-text">See It In Action</h2>
|
||||||
|
<p class="section-subtitle">Experience the intuitive interface and powerful features that make DocuPulse the choice for modern enterprises.</p>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-center p-3">
|
||||||
|
<i class="fas fa-users fa-2x mb-2" style="color: var(--primary-color);"></i>
|
||||||
|
<h6 class="fw-bold">Team Collaboration</h6>
|
||||||
|
<small class="text-muted">Real-time messaging and file sharing</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-center p-3">
|
||||||
|
<i class="fas fa-shield-alt fa-2x mb-2" style="color: var(--primary-color);"></i>
|
||||||
|
<h6 class="fw-bold">Enterprise Security</h6>
|
||||||
|
<small class="text-muted">Role-based access controls</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-center p-3">
|
||||||
|
<i class="fas fa-search fa-2x mb-2" style="color: var(--primary-color);"></i>
|
||||||
|
<h6 class="fw-bold">Smart Search</h6>
|
||||||
|
<small class="text-muted">Find files instantly</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-center p-3">
|
||||||
|
<i class="fas fa-bell fa-2x mb-2" style="color: var(--primary-color);"></i>
|
||||||
|
<h6 class="fw-bold">Notifications</h6>
|
||||||
|
<small class="text-muted">Stay updated in real-time</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="interactive-demo">
|
||||||
|
<div class="demo-placeholder">
|
||||||
|
<i class="fas fa-laptop"></i>
|
||||||
|
<h5 class="fw-bold">Interactive Demo</h5>
|
||||||
|
<p>Experience the platform firsthand</p>
|
||||||
|
<button class="btn btn-primary btn-lg px-4 py-3">
|
||||||
|
<i class="fas fa-play me-2"></i>Launch Demo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Advanced Features -->
|
||||||
|
<section class="feature-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="section-title gradient-text">Advanced Capabilities</h2>
|
||||||
|
<p class="section-subtitle">Enterprise-grade features for complex document management needs</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-lg-4 col-md-6">
|
<div class="col-lg-4">
|
||||||
<div class="feature-card">
|
<div class="feature-highlight fade-in-up">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-user-shield"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="h5 fw-bold mb-3">Team Management</h3>
|
<h3 class="h5 fw-bold mb-3">Security & Compliance</h3>
|
||||||
<p class="text-muted">Efficient team management with member roles, permissions, and collaborative workflows for seamless project execution.</p>
|
<p class="text-muted">Enterprise-grade security with comprehensive compliance features for data protection and regulatory requirements.</p>
|
||||||
<ul class="list-unstyled mt-3">
|
<ul class="list-unstyled mt-3">
|
||||||
<li><i class="fas fa-check text-success me-2"></i>Member roles</li>
|
<li><i class="fas fa-check text-success me-2"></i>Role-based access controls</li>
|
||||||
<li><i class="fas fa-check text-success me-2"></i>Collaborative workflows</li>
|
<li><i class="fas fa-check text-success me-2"></i>End-to-end encryption</li>
|
||||||
<li><i class="fas fa-check text-success me-2"></i>Project execution</li>
|
<li><i class="fas fa-check text-success me-2"></i>GDPR and HIPAA compliance</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4 col-md-6">
|
<div class="col-lg-4">
|
||||||
<div class="feature-card">
|
<div class="feature-highlight fade-in-up stagger-delay-1">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<i class="fas fa-star"></i>
|
<i class="fas fa-palette"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="h5 fw-bold mb-3">File Organization</h3>
|
<h3 class="h5 fw-bold mb-3">White Labeling</h3>
|
||||||
<p class="text-muted">Advanced file organization with starring, tagging, and intelligent search capabilities for quick access to important documents.</p>
|
<p class="text-muted">Complete brand customization with custom logos, company colors, and personalized branding throughout the platform.</p>
|
||||||
<ul class="list-unstyled mt-3">
|
<ul class="list-unstyled mt-3">
|
||||||
<li><i class="fas fa-check text-success me-2"></i>File starring</li>
|
<li><i class="fas fa-check text-success me-2"></i>Custom company logo</li>
|
||||||
<li><i class="fas fa-check text-success me-2"></i>Intelligent search</li>
|
<li><i class="fas fa-check text-success me-2"></i>Brand color customization</li>
|
||||||
<li><i class="fas fa-check text-success me-2"></i>Quick access</li>
|
<li><i class="fas fa-check text-success me-2"></i>Personalized templates</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4 col-md-6">
|
<div class="col-lg-4">
|
||||||
<div class="feature-card">
|
<div class="feature-highlight fade-in-up stagger-delay-2">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<i class="fas fa-bell"></i>
|
<i class="fas fa-bell"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,6 +565,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
{% with stats=[
|
||||||
|
{'value': 99.9, 'suffix': '%', 'display': '99.9%', 'label': 'Uptime'},
|
||||||
|
{'value': 256, 'suffix': '-bit', 'display': '256-bit', 'label': 'Encryption'},
|
||||||
|
{'value': 24, 'suffix': '/7', 'display': '24/7', 'label': 'Support'},
|
||||||
|
{'value': 1000, 'suffix': '+', 'display': '1000+', 'label': 'Organizations'}
|
||||||
|
] %}
|
||||||
|
{% include 'components/animated_numbers.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<!-- CTA Section -->
|
<!-- CTA Section -->
|
||||||
<section class="py-5">
|
<section class="py-5">
|
||||||
<div class="container text-center">
|
<div class="container text-center">
|
||||||
@@ -446,6 +588,48 @@
|
|||||||
|
|
||||||
{% include 'components/footer_nav.html' %}
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Explainer Video Modal -->
|
||||||
|
{% with
|
||||||
|
modal_title='DocuPulse Features Overview',
|
||||||
|
video_title='Features Demo Video',
|
||||||
|
video_placeholder='Video placeholder - Replace with actual features demo video',
|
||||||
|
learning_title='What you\'ll see:',
|
||||||
|
learning_points=[
|
||||||
|
'Room-based workspace creation and management',
|
||||||
|
'Document request workflows and client communication',
|
||||||
|
'Advanced file management and organization',
|
||||||
|
'Security features and permission controls',
|
||||||
|
'White labeling and brand customization'
|
||||||
|
],
|
||||||
|
cta_url=url_for('public.pricing'),
|
||||||
|
cta_text='Get Started Now'
|
||||||
|
%}
|
||||||
|
{% include 'components/explainer_video_modal.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Intersection Observer for animations
|
||||||
|
const observerOptions = {
|
||||||
|
threshold: 0.1,
|
||||||
|
rootMargin: '0px 0px -50px 0px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(function(entries) {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, observerOptions);
|
||||||
|
|
||||||
|
// Observe elements for fade-in animation
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const fadeElements = document.querySelectorAll('.fade-in-up');
|
||||||
|
fadeElements.forEach(el => {
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
465
templates/public/gdpr.html
Normal file
465
templates/public/gdpr.html
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>GDPR Compliance - DocuPulse</title>
|
||||||
|
<meta name="description" content="Learn about DocuPulse's GDPR compliance measures and how we protect your data rights under European data protection regulations.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.legal-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 25px var(--shadow-color);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 80px 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header .container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 3px solid var(--primary-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box h5 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rights-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-card {
|
||||||
|
background: var(--white);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 25px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-card:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 10px 25px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-card h4 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-icon {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-table {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 5px 15px var(--shadow-color);
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-table th {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-table td {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-updated {
|
||||||
|
background: var(--light-bg);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-compliant {
|
||||||
|
background: rgba(40, 167, 69, 0.1);
|
||||||
|
color: #28a745;
|
||||||
|
border: 1px solid #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
color: #ffc107;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.legal-content {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rights-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-table {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Header Section -->
|
||||||
|
<section class="legal-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="display-4 fw-bold mb-3">GDPR Compliance</h1>
|
||||||
|
<p class="lead opacity-75">Your data rights under European law</p>
|
||||||
|
<p class="opacity-75">Last updated: December 2024</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- GDPR Content -->
|
||||||
|
<section class="legal-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="legal-content">
|
||||||
|
<h2 class="section-title">1. GDPR Overview</h2>
|
||||||
|
<p>The General Data Protection Regulation (GDPR) is a comprehensive data protection law that applies to organizations processing personal data of individuals in the European Union (EU) and European Economic Area (EEA). DocuPulse is committed to full compliance with GDPR requirements.</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<h5><i class="fas fa-shield-alt me-2"></i>Our Commitment</h5>
|
||||||
|
<p class="mb-0">We are fully committed to protecting your privacy and ensuring compliance with GDPR. Our data processing activities are designed with privacy by design and default principles.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">2. Legal Basis for Processing</h2>
|
||||||
|
<p>Under GDPR, we process your personal data based on the following legal grounds:</p>
|
||||||
|
|
||||||
|
<div class="compliance-table">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Processing Purpose</th>
|
||||||
|
<th>Legal Basis</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Service Provision</td>
|
||||||
|
<td>Contract Performance</td>
|
||||||
|
<td>Processing necessary to provide our services</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Account Management</td>
|
||||||
|
<td>Contract Performance</td>
|
||||||
|
<td>Managing your account and billing</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Customer Support</td>
|
||||||
|
<td>Legitimate Interest</td>
|
||||||
|
<td>Providing support and improving service</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Security & Fraud Prevention</td>
|
||||||
|
<td>Legitimate Interest</td>
|
||||||
|
<td>Protecting our systems and users</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Marketing Communications</td>
|
||||||
|
<td>Consent</td>
|
||||||
|
<td>Only with your explicit consent</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Legal Compliance</td>
|
||||||
|
<td>Legal Obligation</td>
|
||||||
|
<td>Complying with applicable laws</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">3. Your Data Subject Rights</h2>
|
||||||
|
<p>Under GDPR, you have the following rights regarding your personal data:</p>
|
||||||
|
|
||||||
|
<div class="rights-grid">
|
||||||
|
<div class="right-card">
|
||||||
|
<div class="right-icon">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</div>
|
||||||
|
<h4>Right of Access</h4>
|
||||||
|
<p>You have the right to request access to your personal data and information about how we process it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-card">
|
||||||
|
<div class="right-icon">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</div>
|
||||||
|
<h4>Right to Rectification</h4>
|
||||||
|
<p>You can request correction of inaccurate or incomplete personal data we hold about you.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-card">
|
||||||
|
<div class="right-icon">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</div>
|
||||||
|
<h4>Right to Erasure</h4>
|
||||||
|
<p>You can request deletion of your personal data in certain circumstances (the "right to be forgotten").</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-card">
|
||||||
|
<div class="right-icon">
|
||||||
|
<i class="fas fa-pause"></i>
|
||||||
|
</div>
|
||||||
|
<h4>Right to Restriction</h4>
|
||||||
|
<p>You can request that we limit how we process your personal data in certain situations.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-card">
|
||||||
|
<div class="right-icon">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</div>
|
||||||
|
<h4>Right to Portability</h4>
|
||||||
|
<p>You can request a copy of your personal data in a structured, machine-readable format.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-card">
|
||||||
|
<div class="right-icon">
|
||||||
|
<i class="fas fa-ban"></i>
|
||||||
|
</div>
|
||||||
|
<h4>Right to Object</h4>
|
||||||
|
<p>You can object to processing of your personal data based on legitimate interests.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">4. How to Exercise Your Rights</h2>
|
||||||
|
<p>To exercise any of your GDPR rights, you can:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Use our self-service tools:</strong> Access and manage your data through your account settings</li>
|
||||||
|
<li><strong>Contact our Data Protection Officer:</strong> Email us at dpo@docupulse.com</li>
|
||||||
|
<li><strong>Submit a formal request:</strong> Use our data request form</li>
|
||||||
|
<li><strong>Contact us directly:</strong> Reach out to our support team</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<h5><i class="fas fa-clock me-2"></i>Response Time</h5>
|
||||||
|
<p class="mb-0">We will respond to your requests within 30 days. In complex cases, we may extend this period by up to 60 days, but we will notify you of any delay.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">5. Data Processing Details</h2>
|
||||||
|
<h4>5.1 Categories of Personal Data</h4>
|
||||||
|
<p>We process the following categories of personal data:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Identity Data:</strong> Name, email address, contact information</li>
|
||||||
|
<li><strong>Account Data:</strong> Username, password, profile information</li>
|
||||||
|
<li><strong>Usage Data:</strong> How you interact with our services</li>
|
||||||
|
<li><strong>Technical Data:</strong> IP address, browser type, device information</li>
|
||||||
|
<li><strong>Content Data:</strong> Documents and files you upload</li>
|
||||||
|
<li><strong>Communication Data:</strong> Messages and support requests</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>5.2 Data Retention Periods</h4>
|
||||||
|
<p>We retain your personal data for the following periods:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Account Data:</strong> Until account deletion or 2 years of inactivity</li>
|
||||||
|
<li><strong>Usage Data:</strong> 24 months for analytics purposes</li>
|
||||||
|
<li><strong>Support Communications:</strong> 3 years from last contact</li>
|
||||||
|
<li><strong>Billing Data:</strong> 7 years for tax and accounting purposes</li>
|
||||||
|
<li><strong>Security Logs:</strong> 12 months for security monitoring</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">6. International Data Transfers</h2>
|
||||||
|
<p>Your personal data may be transferred to and processed in countries outside the EU/EEA. We ensure appropriate safeguards are in place:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Standard Contractual Clauses:</strong> We use EU-approved SCCs for transfers</li>
|
||||||
|
<li><strong>Adequacy Decisions:</strong> We transfer to countries with adequate protection</li>
|
||||||
|
<li><strong>Certification Schemes:</strong> We rely on approved certification mechanisms</li>
|
||||||
|
<li><strong>Binding Corporate Rules:</strong> Where applicable, we use BCRs for intra-group transfers</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">7. Data Protection Measures</h2>
|
||||||
|
<p>We implement comprehensive technical and organizational measures to protect your data:</p>
|
||||||
|
|
||||||
|
<div class="compliance-table">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Measure</th>
|
||||||
|
<th>Implementation</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Encryption</td>
|
||||||
|
<td>AES-256 encryption at rest and in transit</td>
|
||||||
|
<td><span class="status-badge status-compliant">Compliant</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Access Controls</td>
|
||||||
|
<td>Role-based access and multi-factor authentication</td>
|
||||||
|
<td><span class="status-badge status-compliant">Compliant</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Data Minimization</td>
|
||||||
|
<td>Only collect data necessary for service provision</td>
|
||||||
|
<td><span class="status-badge status-compliant">Compliant</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Privacy by Design</td>
|
||||||
|
<td>Privacy considerations built into all systems</td>
|
||||||
|
<td><span class="status-badge status-compliant">Compliant</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Regular Audits</td>
|
||||||
|
<td>Annual privacy and security assessments</td>
|
||||||
|
<td><span class="status-badge status-compliant">Compliant</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Staff Training</td>
|
||||||
|
<td>Regular GDPR and privacy training</td>
|
||||||
|
<td><span class="status-badge status-compliant">Compliant</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">8. Data Breach Procedures</h2>
|
||||||
|
<p>In the unlikely event of a data breach, we have established procedures to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Detect and assess the breach within 72 hours</li>
|
||||||
|
<li>Notify the relevant supervisory authority</li>
|
||||||
|
<li>Inform affected individuals when required</li>
|
||||||
|
<li>Document all breach incidents and remedial actions</li>
|
||||||
|
<li>Implement measures to prevent future breaches</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">9. Third-Party Processors</h2>
|
||||||
|
<p>We work with carefully selected third-party processors who help us provide our services. All processors:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Are bound by data processing agreements</li>
|
||||||
|
<li>Implement appropriate security measures</li>
|
||||||
|
<li>Process data only as instructed by us</li>
|
||||||
|
<li>Are regularly audited for compliance</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">10. Supervisory Authority</h2>
|
||||||
|
<p>You have the right to lodge a complaint with your local data protection supervisory authority if you believe we have not addressed your concerns adequately.</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<h5><i class="fas fa-info-circle me-2"></i>EU Representative</h5>
|
||||||
|
<p class="mb-0">For EU residents, you can contact our EU representative at: DocuPulse EU Representative, [Address], [Email]</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-info">
|
||||||
|
<h3><i class="fas fa-user-shield me-2"></i>Data Protection Officer</h3>
|
||||||
|
<p>For any GDPR-related questions or to exercise your rights, contact our Data Protection Officer:</p>
|
||||||
|
<p><strong>Email:</strong> <a href="mailto:dpo@docupulse.com">dpo@docupulse.com</a></p>
|
||||||
|
<p><strong>Address:</strong> DocuPulse Inc., 123 Business Ave, Suite 100, City, State 12345</p>
|
||||||
|
<p><strong>Phone:</strong> <a href="tel:+1-555-123-4567">+1 (555) 123-4567</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="last-updated">
|
||||||
|
<p class="mb-0"><strong>Last Updated:</strong> December 2024</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
420
templates/public/help.html
Normal file
420
templates/public/help.html
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Help Center - DocuPulse</title>
|
||||||
|
<meta name="description" content="Get help with DocuPulse. Find answers to common questions, tutorials, and support resources.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.help-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 80px 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 50px;
|
||||||
|
padding: 15px 25px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px 30px;
|
||||||
|
box-shadow: 0 10px 25px var(--shadow-color);
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 25px;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
box-shadow: 0 5px 15px var(--shadow-color);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-question {
|
||||||
|
background: var(--white);
|
||||||
|
border: none;
|
||||||
|
padding: 20px 25px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-question:hover {
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-question[aria-expanded="true"] {
|
||||||
|
background: var(--primary-bg-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-answer {
|
||||||
|
padding: 0 25px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles to match other pages */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.btn-outline-primary {
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-outline-primary:hover {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
.btn-lg {
|
||||||
|
padding: 15px 40px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
{% with
|
||||||
|
title="Help Center",
|
||||||
|
description="Find answers to your questions and get the support you need",
|
||||||
|
title_size="4",
|
||||||
|
description_size="5"
|
||||||
|
%}
|
||||||
|
{% include 'components/hero_section.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Help Categories -->
|
||||||
|
<section class="help-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Browse by Category</h2>
|
||||||
|
<p class="lead text-muted">Find help organized by topic</p>
|
||||||
|
</div>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="category-card">
|
||||||
|
<div class="category-icon">
|
||||||
|
<i class="fas fa-rocket"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Getting Started</h4>
|
||||||
|
<p class="text-muted mb-4">Learn the basics of DocuPulse and set up your workspace</p>
|
||||||
|
<a href="{{ url_for('public.help_articles', category='getting-started') }}" class="btn btn-outline-primary">View Articles</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="category-card">
|
||||||
|
<div class="category-icon">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">User Management</h4>
|
||||||
|
<p class="text-muted mb-4">Manage users, permissions, and team collaboration</p>
|
||||||
|
<a href="{{ url_for('public.help_articles', category='user-management') }}" class="btn btn-outline-primary">View Articles</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="category-card">
|
||||||
|
<div class="category-icon">
|
||||||
|
<i class="fas fa-folder"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">File Management</h4>
|
||||||
|
<p class="text-muted mb-4">Upload, organize, and manage your documents</p>
|
||||||
|
<a href="{{ url_for('public.help_articles', category='file-management') }}" class="btn btn-outline-primary">View Articles</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="category-card">
|
||||||
|
<div class="category-icon">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Communication</h4>
|
||||||
|
<p class="text-muted mb-4">Use messaging and collaboration features</p>
|
||||||
|
<a href="{{ url_for('public.help_articles', category='communication') }}" class="btn btn-outline-primary">View Articles</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="category-card">
|
||||||
|
<div class="category-icon">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Security & Privacy</h4>
|
||||||
|
<p class="text-muted mb-4">Learn about security features and data protection</p>
|
||||||
|
<a href="{{ url_for('public.help_articles', category='security') }}" class="btn btn-outline-primary">View Articles</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<div class="category-card">
|
||||||
|
<div class="category-icon">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Administration</h4>
|
||||||
|
<p class="text-muted mb-4">Configure settings and manage your organization</p>
|
||||||
|
<a href="{{ url_for('public.help_articles', category='administration') }}" class="btn btn-outline-primary">View Articles</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ Section -->
|
||||||
|
<section class="help-section" style="background-color: var(--bg-color);">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Frequently Asked Questions</h2>
|
||||||
|
<p class="lead text-muted">Quick answers to common questions</p>
|
||||||
|
</div>
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="accordion" id="faqAccordion">
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question" type="button" data-bs-toggle="collapse" data-bs-target="#faq1" aria-expanded="true" aria-controls="faq1">
|
||||||
|
What is DocuPulse and what does it do?
|
||||||
|
</button>
|
||||||
|
<div id="faq1" class="collapse show" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="faq-answer">
|
||||||
|
DocuPulse is an enterprise document management platform that helps organizations securely store, organize, and collaborate on documents. It features room-based file organization, real-time messaging, user permissions, and comprehensive audit trails. The platform supports multi-tenant instances and provides both document management and team communication capabilities in one integrated solution.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq2" aria-expanded="false" aria-controls="faq2">
|
||||||
|
How do rooms work in DocuPulse?
|
||||||
|
</button>
|
||||||
|
<div id="faq2" class="collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="faq-answer">
|
||||||
|
Rooms are the primary organizational units in DocuPulse. Each room can contain files, folders, and have specific members with granular permissions. You can create rooms for different projects, departments, or teams. Room members can have different permission levels including view, download, upload, delete, rename, move, and share capabilities. Rooms provide a secure, organized way to manage documents and control access.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq3" aria-expanded="false" aria-controls="faq3">
|
||||||
|
What file types and sizes are supported?
|
||||||
|
</button>
|
||||||
|
<div id="faq3" class="collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="faq-answer">
|
||||||
|
DocuPulse supports a wide range of file types including documents (PDF, DOCX, DOC, TXT, RTF), spreadsheets (XLSX, XLS, ODS), presentations (PPTX, PPT), images (JPG, PNG, GIF, SVG), archives (ZIP, RAR, 7Z), code files (PY, JS, HTML, CSS), audio/video files, and CAD/design files.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq4" aria-expanded="false" aria-controls="faq4">
|
||||||
|
How does the conversation and messaging system work?
|
||||||
|
</button>
|
||||||
|
<div id="faq4" class="collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="faq-answer">
|
||||||
|
DocuPulse includes a built-in messaging system where you can create conversations with team members. Conversations support text messages and file attachments, allowing you to discuss documents and share files directly within the platform. Only administrators and managers can create conversations, and members receive notifications when added to conversations or when new messages are sent.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq5" aria-expanded="false" aria-controls="faq5">
|
||||||
|
What are the different user roles and permissions?
|
||||||
|
</button>
|
||||||
|
<div id="faq5" class="collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="faq-answer">
|
||||||
|
DocuPulse has three main user roles: Administrators (full system access), Managers (can create conversations and manage room members), and Regular Users. Within rooms, users can have granular permissions: view, download, upload, delete, rename, move, and share. These permissions are managed per room, allowing flexible access control based on project needs and user responsibilities.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq6" aria-expanded="false" aria-controls="faq6">
|
||||||
|
How does file organization and the trash system work?
|
||||||
|
</button>
|
||||||
|
<div id="faq6" class="collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="faq-answer">
|
||||||
|
Files in DocuPulse are organized in a hierarchical folder structure within rooms. You can create folders, move files between locations, and use the search function to find documents quickly. When files are deleted, they go to the trash where they remain for 30 days before permanent deletion. You can restore files from trash or permanently delete them. The platform also includes a starring system to mark important files for quick access.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq7" aria-expanded="false" aria-controls="faq7">
|
||||||
|
What security and audit features are available?
|
||||||
|
</button>
|
||||||
|
<div id="faq7" class="collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="faq-answer">
|
||||||
|
DocuPulse provides comprehensive security and audit capabilities. All user actions are logged as events, including file operations, user management, and system changes. The platform supports password policies, secure file storage, and detailed activity tracking. Administrators can view audit logs, monitor system usage, and export event data for compliance purposes. The system also includes notification features to keep users informed of important activities.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq8" aria-expanded="false" aria-controls="faq8">
|
||||||
|
Can I customize the platform appearance and settings?
|
||||||
|
</button>
|
||||||
|
<div id="faq8" class="collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="faq-answer">
|
||||||
|
Yes, DocuPulse offers extensive customization options. Administrators can customize the platform's color scheme, company branding, and logo. The system includes configurable SMTP settings for email notifications, customizable email templates, and various system settings. You can also configure storage limits, room limits, and other platform parameters to match your organization's needs.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq9" aria-expanded="false" aria-controls="faq9">
|
||||||
|
How does the notification system work?
|
||||||
|
</button>
|
||||||
|
<div id="faq9" class="collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="faq-answer">
|
||||||
|
DocuPulse includes a comprehensive notification system that alerts users to various events. You'll receive notifications for room invitations, conversation messages, file activities, and system events. Notifications can be marked as read/unread, filtered by type, and managed through the notifications panel. The system also supports email notifications for important events, which can be configured by administrators.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-question collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq10" aria-expanded="false" aria-controls="faq10">
|
||||||
|
What administrative tools and monitoring features are available?
|
||||||
|
</button>
|
||||||
|
<div id="faq10" class="collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="faq-answer">
|
||||||
|
Administrators have access to comprehensive management tools including user management, system monitoring, usage statistics, and audit logs. The platform provides dashboard views showing file counts, storage usage, user activity, and system health. Administrators can also manage email templates, configure SMTP settings, monitor events, and export data for reporting. The system includes tools for database verification and file system synchronization.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Contact Support -->
|
||||||
|
<section class="help-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="contact-card">
|
||||||
|
<h2 class="text-center mb-4">Still Need Help?</h2>
|
||||||
|
<p class="text-muted text-center mb-5">Our support team is here to help you get the most out of DocuPulse</p>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="contact-icon">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold">Email Support</h5>
|
||||||
|
<p class="text-muted">Get help via email</p>
|
||||||
|
<a href="mailto:support@docupulse.com" class="btn btn-outline-primary btn-sm">Contact Us</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="contact-icon">
|
||||||
|
<i class="fas fa-phone"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold">Phone Support</h5>
|
||||||
|
<p class="text-muted">Call us directly</p>
|
||||||
|
<a href="tel:+15551234567" class="btn btn-outline-primary btn-sm">Call Now</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
295
templates/public/help_articles.html
Normal file
295
templates/public/help_articles.html
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% if category_name %}{{ category_name }} - {% endif %}Help Articles - DocuPulse</title>
|
||||||
|
<meta name="description" content="Browse help articles and documentation for DocuPulse organized by category.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.category-nav {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 5px 15px var(--shadow-color);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-link {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 20px;
|
||||||
|
margin: 5px 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-dark);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-link:hover {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-link.active {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--white);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 5px 15px var(--shadow-color);
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content {
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content h1, .article-content h2, .article-content h3,
|
||||||
|
.article-content h4, .article-content h5, .article-content h6 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-top: 25px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content p {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content ul, .article-content ol {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content code {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content pre {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content blockquote {
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content th, .article-content td {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content th {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-nav {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 2px 8px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item.active {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 4rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.btn-outline-primary {
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-outline-primary:hover {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Breadcrumb Navigation -->
|
||||||
|
<div class="container mt-4">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb breadcrumb-nav">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('public.help_center') }}">Help Center</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">
|
||||||
|
{% if category_name %}{{ category_name }}{% else %}All Articles{% endif %}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Category Navigation -->
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<div class="category-nav">
|
||||||
|
<h5 class="mb-3" style="color: var(--primary-color);">
|
||||||
|
<i class="fas fa-folder me-2"></i>Categories
|
||||||
|
</h5>
|
||||||
|
<a href="{{ url_for('public.help_articles') }}"
|
||||||
|
class="category-link {% if not current_category %}active{% endif %}">
|
||||||
|
<i class="fas fa-th-large me-2"></i>All Articles
|
||||||
|
</a>
|
||||||
|
{% for category_key, category_name in categories.items() %}
|
||||||
|
<a href="{{ url_for('public.help_articles', category=category_key) }}"
|
||||||
|
class="category-link {% if current_category == category_key %}active{% endif %}">
|
||||||
|
<i class="fas fa-{{ 'rocket' if category_key == 'getting-started' else 'users' if category_key == 'user-management' else 'folder' if category_key == 'file-management' else 'comments' if category_key == 'communication' else 'shield-alt' if category_key == 'security' else 'cog' }} me-2"></i>
|
||||||
|
{{ category_name }}
|
||||||
|
{% if all_articles.get(category_key) %}
|
||||||
|
<span class="badge bg-secondary float-end">{{ all_articles[category_key]|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Articles Content -->
|
||||||
|
<div class="col-lg-9">
|
||||||
|
{% if category_name %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h2 mb-0" style="color: var(--primary-color);">
|
||||||
|
<i class="fas fa-{{ 'rocket' if current_category == 'getting-started' else 'users' if current_category == 'user-management' else 'folder' if current_category == 'file-management' else 'comments' if current_category == 'communication' else 'shield-alt' if current_category == 'security' else 'cog' }} me-2"></i>
|
||||||
|
{{ category_name }}
|
||||||
|
</h1>
|
||||||
|
<a href="{{ url_for('public.help_center') }}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>Back to Help Center
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h2 mb-0" style="color: var(--primary-color);">
|
||||||
|
<i class="fas fa-book me-2"></i>All Help Articles
|
||||||
|
</h1>
|
||||||
|
<a href="{{ url_for('public.help_center') }}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>Back to Help Center
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if articles %}
|
||||||
|
{% for article in articles %}
|
||||||
|
<div class="article-card">
|
||||||
|
<h2 class="article-title">{{ article.title }}</h2>
|
||||||
|
<div class="article-content">
|
||||||
|
{{ article.body|safe }}
|
||||||
|
</div>
|
||||||
|
<hr class="my-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fas fa-calendar me-1"></i>
|
||||||
|
Updated: {{ article.updated_at.strftime('%B %d, %Y') if article.updated_at else article.created_at.strftime('%B %d, %Y') }}
|
||||||
|
</small>
|
||||||
|
<span class="badge" style="background-color: var(--primary-color);">
|
||||||
|
{{ categories[article.category] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-file-alt"></i>
|
||||||
|
<h3 class="text-muted">No Articles Found</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% if current_category %}
|
||||||
|
No articles are available in the "{{ category_name }}" category yet.
|
||||||
|
{% else %}
|
||||||
|
No help articles are available yet.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('public.help_center') }}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>Back to Help Center
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
453
templates/public/press.html
Normal file
453
templates/public/press.html
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Press - DocuPulse</title>
|
||||||
|
<meta name="description" content="Press releases, media kit, and company information for journalists and media professionals covering DocuPulse.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.press-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.press-release {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 10px 25px var(--shadow-color);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.press-release:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.press-date {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.press-tag {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-kit-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-kit-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-kit-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 25px;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-info {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 60px 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-info::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
padding: 12px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 0.2rem var(--primary-opacity-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-showcase {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 15px 35px var(--shadow-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-placeholder {
|
||||||
|
width: 200px;
|
||||||
|
height: 100px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 25px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
{% with
|
||||||
|
title="Press & Media",
|
||||||
|
description="Press releases, media kit, and company information for journalists and media professionals.",
|
||||||
|
title_size="4",
|
||||||
|
description_size="5"
|
||||||
|
%}
|
||||||
|
{% include 'components/hero_section.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Company Stats -->
|
||||||
|
<section class="stats-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" data-value="75" data-suffix="+">75+</span>
|
||||||
|
<div class="stat-label">Enterprise Clients</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" data-value="1000" data-suffix="K+">1000+</span>
|
||||||
|
<div class="stat-label">Active Users</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" data-value="1.1" data-suffix="M" data-prefix="$">$1.1M</span>
|
||||||
|
<div class="stat-label">Annual Revenue</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" data-value="15" data-suffix="+">15+</span>
|
||||||
|
<div class="stat-label">Countries Served</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Media Kit -->
|
||||||
|
<section class="press-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Media Kit</h2>
|
||||||
|
<p class="lead text-muted">Download our official media assets and company information</p>
|
||||||
|
</div>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="media-kit-card">
|
||||||
|
<div class="media-kit-icon">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Logos & Brand Assets</h4>
|
||||||
|
<p class="text-muted mb-4">High-resolution logos, brand guidelines, and visual assets for media use.</p>
|
||||||
|
<a href="#" class="download-btn">
|
||||||
|
<i class="fas fa-download me-2"></i>Download Assets
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="media-kit-card">
|
||||||
|
<div class="media-kit-icon">
|
||||||
|
<i class="fas fa-file-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Company Fact Sheet</h4>
|
||||||
|
<p class="text-muted mb-4">Key facts, statistics, and company information for press coverage.</p>
|
||||||
|
<a href="#" class="download-btn">
|
||||||
|
<i class="fas fa-download me-2"></i>Download Fact Sheet
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="media-kit-card">
|
||||||
|
<div class="media-kit-icon">
|
||||||
|
<i class="fas fa-camera"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Product Screenshots</h4>
|
||||||
|
<p class="text-muted mb-4">High-quality screenshots and product images for media use.</p>
|
||||||
|
<a href="#" class="download-btn">
|
||||||
|
<i class="fas fa-download me-2"></i>Download Images
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Company Information -->
|
||||||
|
<section class="company-info">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Company Information</h2>
|
||||||
|
<p class="lead">Essential information for journalists and media professionals</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-icon">
|
||||||
|
<i class="fas fa-building"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold">Founded</h5>
|
||||||
|
<p class="opacity-75">1991</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-icon">
|
||||||
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold">Headquarters</h5>
|
||||||
|
<p class="opacity-75">Vilvoorde, Belgium</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-icon">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold">Employees</h5>
|
||||||
|
<p class="opacity-75">15+</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-icon">
|
||||||
|
<i class="fas fa-globe"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold">Global Presence</h5>
|
||||||
|
<p class="opacity-75">15+ Countries</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Logo Showcase -->
|
||||||
|
<section class="press-section" style="background-color: var(--bg-color);">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="logo-showcase">
|
||||||
|
<h3 class="fw-bold mb-4">Our Logo</h3>
|
||||||
|
<div class="logo-placeholder">
|
||||||
|
DocuPulse
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-4">Download our logo in various formats and sizes for media use. Please follow our brand guidelines when using the DocuPulse logo.</p>
|
||||||
|
<div class="d-flex justify-content-center gap-3 flex-wrap">
|
||||||
|
<a href="#" class="download-btn">
|
||||||
|
<i class="fas fa-download me-2"></i>PNG Format
|
||||||
|
</a>
|
||||||
|
<a href="#" class="download-btn">
|
||||||
|
<i class="fas fa-download me-2"></i>SVG Format
|
||||||
|
</a>
|
||||||
|
<a href="#" class="download-btn">
|
||||||
|
<i class="fas fa-download me-2"></i>Brand Guidelines
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Function to animate number counting
|
||||||
|
function animateNumber(element, endValue, suffix = '', prefix = '', duration = 2000) {
|
||||||
|
const start = performance.now();
|
||||||
|
const startValue = 0;
|
||||||
|
const difference = endValue - startValue;
|
||||||
|
|
||||||
|
function updateNumber(currentTime) {
|
||||||
|
const elapsed = currentTime - start;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
// Easing function for smooth animation
|
||||||
|
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||||
|
const currentValue = startValue + (difference * easeOutQuart);
|
||||||
|
|
||||||
|
// Format the number based on the suffix and prefix
|
||||||
|
let displayValue;
|
||||||
|
if (suffix === 'K+') {
|
||||||
|
displayValue = Math.round(currentValue) + suffix;
|
||||||
|
} else if (suffix === 'M') {
|
||||||
|
displayValue = prefix + Math.round(currentValue) + suffix;
|
||||||
|
} else if (suffix === '+') {
|
||||||
|
displayValue = Math.round(currentValue) + suffix;
|
||||||
|
} else {
|
||||||
|
displayValue = Math.round(currentValue) + (suffix || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
element.textContent = displayValue;
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(updateNumber);
|
||||||
|
} else {
|
||||||
|
// Ensure the final value is correct
|
||||||
|
if (suffix === 'M') {
|
||||||
|
element.textContent = prefix + element.getAttribute('data-value') + suffix;
|
||||||
|
} else {
|
||||||
|
element.textContent = element.getAttribute('data-value') + (suffix || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(updateNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize animated numbers when component is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const statsObserver = new IntersectionObserver(function(entries) {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const statNumbers = entry.target.querySelectorAll('.stat-number');
|
||||||
|
statNumbers.forEach((stat, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const value = parseFloat(stat.getAttribute('data-value'));
|
||||||
|
const suffix = stat.getAttribute('data-suffix') || '';
|
||||||
|
const prefix = stat.getAttribute('data-prefix') || '';
|
||||||
|
|
||||||
|
if (!isNaN(value)) {
|
||||||
|
animateNumber(stat, value, suffix, prefix, 2000);
|
||||||
|
}
|
||||||
|
}, index * 300); // Stagger the animations
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only trigger once
|
||||||
|
statsObserver.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.5 });
|
||||||
|
|
||||||
|
// Observe the stats section
|
||||||
|
const statsSection = document.querySelector('.stats-section');
|
||||||
|
if (statsSection) {
|
||||||
|
statsObserver.observe(statsSection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
<style>
|
<style>
|
||||||
.pricing-section {
|
.pricing-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
@@ -51,16 +52,29 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
padding: 12px 30px;
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
.btn-outline-primary {
|
.btn-outline-primary {
|
||||||
border: 2px solid var(--primary-color);
|
border: 2px solid var(--primary-color);
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
padding: 12px 30px;
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.btn-outline-primary:hover {
|
.btn-outline-primary:hover {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -68,12 +82,14 @@
|
|||||||
{% include 'components/header_nav.html' %}
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="hero-section">
|
{% with
|
||||||
<div class="container text-center">
|
title="Simple, Transparent Pricing",
|
||||||
<h1 class="display-4 fw-bold mb-4">Simple, Transparent Pricing</h1>
|
description="Choose the perfect plan for your enterprise. No hidden fees, no surprises.",
|
||||||
<p class="lead fs-5">Choose the perfect plan for your enterprise. No hidden fees, no surprises.</p>
|
title_size="4",
|
||||||
</div>
|
description_size="5"
|
||||||
</section>
|
%}
|
||||||
|
{% include 'components/hero_section.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<!-- Pricing Plans -->
|
<!-- Pricing Plans -->
|
||||||
{% with contact_url=url_for('public.contact') %}
|
{% with contact_url=url_for('public.contact') %}
|
||||||
|
|||||||
323
templates/public/privacy.html
Normal file
323
templates/public/privacy.html
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Privacy Policy - DocuPulse</title>
|
||||||
|
<meta name="description" content="Learn how DocuPulse collects, uses, and protects your personal information in accordance with privacy laws and regulations.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.legal-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 25px var(--shadow-color);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 80px 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header .container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 3px solid var(--primary-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box h5 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 5px 15px var(--shadow-color);
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-updated {
|
||||||
|
background: var(--light-bg);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.legal-content {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Header Section -->
|
||||||
|
<section class="legal-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="display-4 fw-bold mb-3">Privacy Policy</h1>
|
||||||
|
<p class="lead opacity-75">How we collect, use, and protect your information</p>
|
||||||
|
<p class="opacity-75">Last updated: December 2024</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Privacy Policy Content -->
|
||||||
|
<section class="legal-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="legal-content">
|
||||||
|
<h2 class="section-title">1. Introduction</h2>
|
||||||
|
<p>DocuPulse ("we," "our," or "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our document management platform and related services.</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<h5><i class="fas fa-info-circle me-2"></i>Important Notice</h5>
|
||||||
|
<p class="mb-0">By using DocuPulse, you agree to the collection and use of information in accordance with this policy. If you do not agree with our policies and practices, please do not use our service.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">2. Information We Collect</h2>
|
||||||
|
<h4>2.1 Personal Information</h4>
|
||||||
|
<p>We collect information you provide directly to us, including:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Name, email address, and contact information</li>
|
||||||
|
<li>Company and job title information</li>
|
||||||
|
<li>Account credentials and profile information</li>
|
||||||
|
<li>Payment and billing information</li>
|
||||||
|
<li>Communications with our support team</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>2.2 Usage Information</h4>
|
||||||
|
<p>We automatically collect certain information about your use of our services:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Log data (IP address, browser type, access times)</li>
|
||||||
|
<li>Device information (device type, operating system)</li>
|
||||||
|
<li>Usage patterns and feature interactions</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>2.3 Document and Content Data</h4>
|
||||||
|
<p>When you use our document management features, we may process:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Documents and files you upload</li>
|
||||||
|
<li>Metadata associated with your documents</li>
|
||||||
|
<li>Collaboration and sharing information</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">3. How We Use Your Information</h2>
|
||||||
|
<div class="data-table">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Purpose</th>
|
||||||
|
<th>Information Used</th>
|
||||||
|
<th>Legal Basis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Provide our services</td>
|
||||||
|
<td>Account info, documents, usage data</td>
|
||||||
|
<td>Contract performance</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Customer support</td>
|
||||||
|
<td>Contact info, communications</td>
|
||||||
|
<td>Legitimate interest</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Security and fraud prevention</td>
|
||||||
|
<td>Log data, device info</td>
|
||||||
|
<td>Legitimate interest</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Service improvement</td>
|
||||||
|
<td>Usage patterns, feedback</td>
|
||||||
|
<td>Legitimate interest</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">4. Information Sharing and Disclosure</h2>
|
||||||
|
<p>We do not sell, trade, or rent your personal information to third parties. We may share your information in the following circumstances:</p>
|
||||||
|
|
||||||
|
<h4>4.1 Service Providers</h4>
|
||||||
|
<p>We work with trusted third-party service providers who assist us in operating our platform, such as:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Cloud hosting and storage providers</li>
|
||||||
|
<li>Payment processors</li>
|
||||||
|
<li>Email and communication services</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>4.2 Legal Requirements</h4>
|
||||||
|
<p>We may disclose your information if required by law or in response to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Valid legal requests or court orders</li>
|
||||||
|
<li>Government investigations</li>
|
||||||
|
<li>Protection of our rights and safety</li>
|
||||||
|
<li>Prevention of fraud or security threats</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>4.3 Business Transfers</h4>
|
||||||
|
<p>In the event of a merger, acquisition, or sale of assets, your information may be transferred as part of the business transaction, subject to the same privacy protections.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">5. Data Security</h2>
|
||||||
|
<p>We implement comprehensive security measures to protect your information:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Encryption:</strong> All data is encrypted in transit and at rest using AES-256</li>
|
||||||
|
<li><strong>Access Controls:</strong> Role-based access controls and multi-factor authentication</li>
|
||||||
|
<li><strong>Monitoring:</strong> Continuous security monitoring and threat detection</li>
|
||||||
|
<li><strong>Backup:</strong> Regular encrypted backups with disaster recovery</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">6. Your Rights and Choices</h2>
|
||||||
|
<p>You have the right to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Access your personal information</li>
|
||||||
|
<li>Correct inaccurate information</li>
|
||||||
|
<li>Delete your account and data</li>
|
||||||
|
<li>Export your data</li>
|
||||||
|
<li>Object to processing</li>
|
||||||
|
<li>Withdraw consent</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">7. Data Retention</h2>
|
||||||
|
<p>We retain your information for as long as necessary to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Provide our services to you</li>
|
||||||
|
<li>Comply with legal obligations</li>
|
||||||
|
<li>Resolve disputes and enforce agreements</li>
|
||||||
|
</ul>
|
||||||
|
<p>When you delete your account, we will delete your personal information within 30 days, except where retention is required by law.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">8. International Data Transfers</h2>
|
||||||
|
<p>Your information may be transferred to and processed in countries other than your own when you provide access to your DocuPulse instance to clients and users. We ensure appropriate safeguards are in place for international transfers, including:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Standard contractual clauses</li>
|
||||||
|
<li>Adequacy decisions</li>
|
||||||
|
<li>Other appropriate safeguards</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">9. Cookies and Tracking</h2>
|
||||||
|
<p>We use cookies and similar technologies to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Remember your preferences and settings</li>
|
||||||
|
<li>Provide personalized content and features</li>
|
||||||
|
<li>Ensure security and prevent fraud</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">10. Children's Privacy</h2>
|
||||||
|
<p>Our services are not intended for children under 13 years of age. We do not knowingly collect personal information from children under 13. If you believe we have collected information from a child under 13, please contact us immediately.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">11. Changes to This Policy</h2>
|
||||||
|
<p>We may update this Privacy Policy from time to time. We will notify you of any material changes by:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Sending email notifications to registered users</li>
|
||||||
|
<li>Displaying prominent notices in our application</li>
|
||||||
|
</ul>
|
||||||
|
<p>Your continued use of our services after changes become effective constitutes acceptance of the updated policy.</p>
|
||||||
|
|
||||||
|
<div class="contact-info">
|
||||||
|
<h3><i class="fas fa-envelope me-2"></i>Contact Us</h3>
|
||||||
|
<p>If you have questions about this Privacy Policy or our privacy practices, please contact us:</p>
|
||||||
|
<p><strong>Email:</strong> <a href="mailto:privacy@docupulse.com">privacy@docupulse.com</a></p>
|
||||||
|
<p><strong>Address:</strong> DocuPulse Inc., 123 Business Ave, Suite 100, City, State 12345</p>
|
||||||
|
<p><strong>Phone:</strong> <a href="tel:+1-555-123-4567">+1 (555) 123-4567</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="last-updated">
|
||||||
|
<p class="mb-0"><strong>Last Updated:</strong> June 2025</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
369
templates/public/security.html
Normal file
369
templates/public/security.html
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Security - DocuPulse</title>
|
||||||
|
<meta name="description" content="Learn about DocuPulse's enterprise-grade security measures, compliance certifications, and data protection practices.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.security-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px 30px;
|
||||||
|
box-shadow: 0 10px 25px var(--shadow-color);
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 25px;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-badge {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 15px 25px;
|
||||||
|
border-radius: 15px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compliance-badge i {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li {
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: relative;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li:before {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make checkmarks white in security features section */
|
||||||
|
.security-section[style*="gradient"] .feature-list li:before {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-overview {
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--text-dark);
|
||||||
|
padding: 80px 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-overview::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-item {
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:nth-child(odd) .timeline-content {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 50%;
|
||||||
|
padding-right: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:nth-child(even) .timeline-content {
|
||||||
|
margin-left: 50%;
|
||||||
|
margin-right: 0;
|
||||||
|
padding-left: 40px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 25px;
|
||||||
|
box-shadow: 0 5px 15px var(--shadow-color);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles to match other pages */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px var(--primary-opacity-15);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
.btn-outline-primary {
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-outline-primary:hover {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(var(--primary-color-rgb), 0.1);
|
||||||
|
}
|
||||||
|
.btn-lg {
|
||||||
|
padding: 15px 40px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timeline::before {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:nth-child(odd) .timeline-content,
|
||||||
|
.timeline-item:nth-child(even) .timeline-content {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
padding-left: 50px;
|
||||||
|
padding-right: 20px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
{% with
|
||||||
|
title="Security & Compliance",
|
||||||
|
description="Enterprise-grade security measures to protect your data and ensure compliance with industry standards",
|
||||||
|
title_size="4",
|
||||||
|
description_size="5"
|
||||||
|
%}
|
||||||
|
{% include 'components/hero_section.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- Security Overview -->
|
||||||
|
<section class="security-overview" style="background-color: var(--white); color: var(--text-dark);">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Security at the Core</h2>
|
||||||
|
<p class="lead text-muted">We prioritize the security of your data with multiple layers of protection</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="overview-item">
|
||||||
|
<div class="overview-icon" style="background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);">
|
||||||
|
<i class="fas fa-lock"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold">End-to-End Encryption</h5>
|
||||||
|
<p class="text-muted">All data encrypted in transit and at rest</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="overview-item">
|
||||||
|
<div class="overview-icon" style="background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold">SOC 2 Type II</h5>
|
||||||
|
<p class="text-muted">Certified compliance with industry standards</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="overview-item">
|
||||||
|
<div class="overview-icon" style="background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);">
|
||||||
|
<i class="fas fa-user-shield"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-bold">Role-Based Access</h5>
|
||||||
|
<p class="text-muted">Granular permissions and access controls</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Security Features -->
|
||||||
|
<section class="security-section" style="background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); color: white;">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h2 class="display-5 fw-bold mb-3">Security Features</h2>
|
||||||
|
<p class="lead opacity-75">Comprehensive security measures to protect your organization</p>
|
||||||
|
</div>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="security-card text-center" style="background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2);">
|
||||||
|
<div class="security-icon" style="background: rgba(255, 255, 255, 0.2);">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Data Encryption</h4>
|
||||||
|
<p class="opacity-75 mb-4">All data is encrypted using AES-256 encryption both in transit and at rest.</p>
|
||||||
|
<ul class="feature-list text-start" style="color: rgba(255, 255, 255, 0.9);">
|
||||||
|
<li>AES-256 encryption at rest</li>
|
||||||
|
<li>TLS 1.3 for data in transit</li>
|
||||||
|
<li>Encrypted backups</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="security-card text-center" style="background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2);">
|
||||||
|
<div class="security-icon" style="background: rgba(255, 255, 255, 0.2);">
|
||||||
|
<i class="fas fa-user-lock"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Access Control</h4>
|
||||||
|
<p class="opacity-75 mb-4">Granular role-based access controls allow you to manage who can access what data.</p>
|
||||||
|
<ul class="feature-list text-start" style="color: rgba(255, 255, 255, 0.9);">
|
||||||
|
<li>Role-based permissions</li>
|
||||||
|
<li>Multi-factor authentication</li>
|
||||||
|
<li>Session management</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="security-card text-center" style="background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2);">
|
||||||
|
<div class="security-icon" style="background: rgba(255, 255, 255, 0.2);">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="fw-bold mb-3">Audit & Monitoring</h4>
|
||||||
|
<p class="opacity-75 mb-4">Comprehensive logging and monitoring to track all activities and detect threats.</p>
|
||||||
|
<ul class="feature-list text-start" style="color: rgba(255, 255, 255, 0.9);">
|
||||||
|
<li>Activity logging</li>
|
||||||
|
<li>Real-time monitoring</li>
|
||||||
|
<li>Security alerts</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<section class="security-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8 text-center">
|
||||||
|
<h2 class="display-5 fw-bold mb-4">Ready to Get Started?</h2>
|
||||||
|
<p class="lead text-muted mb-5">Experience enterprise-grade security with DocuPulse</p>
|
||||||
|
{% with primary_url=url_for('public.contact'), primary_icon='fas fa-envelope', primary_text='Contact Team', secondary_url=url_for('public.pricing'), secondary_icon='fas fa-rocket', secondary_text='Get Started' %}
|
||||||
|
{% include 'components/cta_buttons.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
366
templates/public/terms.html
Normal file
366
templates/public/terms.html
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Terms of Service - DocuPulse</title>
|
||||||
|
<meta name="description" content="Read DocuPulse's Terms of Service to understand your rights and obligations when using our document management platform.">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||||
|
<style>
|
||||||
|
.legal-section {
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 25px var(--shadow-color);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 80px 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header .container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 3px solid var(--primary-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box h5 {
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: rgba(var(--primary-color-rgb), 0.05);
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box h5 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-table {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 5px 15px var(--shadow-color);
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-table th {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-table td {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-updated {
|
||||||
|
background: var(--light-bg);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prohibited-list {
|
||||||
|
background: rgba(220, 53, 69, 0.1);
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prohibited-list h5 {
|
||||||
|
color: #721c24;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prohibited-list ul {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.legal-content {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-table {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'components/header_nav.html' %}
|
||||||
|
|
||||||
|
<!-- Header Section -->
|
||||||
|
<section class="legal-header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="display-4 fw-bold mb-3">Terms of Service</h1>
|
||||||
|
<p class="lead opacity-75">Your agreement with DocuPulse</p>
|
||||||
|
<p class="opacity-75">Last updated: December 2024</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Terms of Service Content -->
|
||||||
|
<section class="legal-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="legal-content">
|
||||||
|
<h2 class="section-title">1. Acceptance of Terms</h2>
|
||||||
|
<p>By accessing and using DocuPulse ("the Service"), you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by the above, please do not use this service.</p>
|
||||||
|
|
||||||
|
<div class="warning-box">
|
||||||
|
<h5><i class="fas fa-exclamation-triangle me-2"></i>Important Notice</h5>
|
||||||
|
<p class="mb-0">These Terms of Service constitute a legally binding agreement between you and DocuPulse Inc. Please read them carefully before using our services.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">2. Description of Service</h2>
|
||||||
|
<p>DocuPulse is a cloud-based document management platform that provides:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Document storage and organization</li>
|
||||||
|
<li>Security and access controls</li>
|
||||||
|
<li>Document management and sharing</li>
|
||||||
|
<li>Document requests</li>
|
||||||
|
<li>Secure conversations</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">3. User Accounts</h2>
|
||||||
|
<h4>3.1 Account Creation</h4>
|
||||||
|
<p>To use our Service, you must create an account by providing accurate, current, and complete information. You are responsible for maintaining the confidentiality of your account credentials.</p>
|
||||||
|
|
||||||
|
<h4>3.2 Account Responsibilities</h4>
|
||||||
|
<p>You are responsible for:</p>
|
||||||
|
<ul>
|
||||||
|
<li>All activities that occur under your account</li>
|
||||||
|
<li>Maintaining the security of your password</li>
|
||||||
|
<li>Notifying us immediately of any unauthorized use</li>
|
||||||
|
<li>Ensuring your account information remains accurate</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">4. Acceptable Use Policy</h2>
|
||||||
|
<p>You agree to use the Service only for lawful purposes and in accordance with these Terms. You agree not to use the Service:</p>
|
||||||
|
|
||||||
|
<div class="prohibited-list">
|
||||||
|
<h5><i class="fas fa-ban me-2"></i>Prohibited Activities</h5>
|
||||||
|
<ul>
|
||||||
|
<li>To violate any applicable laws or regulations</li>
|
||||||
|
<li>To infringe upon the rights of others</li>
|
||||||
|
<li>To upload malicious software or content</li>
|
||||||
|
<li>To attempt to gain unauthorized access to our systems</li>
|
||||||
|
<li>To interfere with or disrupt the Service</li>
|
||||||
|
<li>To use the Service for spam or unsolicited communications</li>
|
||||||
|
<li>To store or transmit illegal content</li>
|
||||||
|
<li>To reverse engineer or attempt to extract source code</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">5. Content and Data</h2>
|
||||||
|
<h4>5.1 Your Content</h4>
|
||||||
|
<p>You retain ownership of all content you upload to the Service. By uploading content, you grant us a limited license to store, process, and display your content as necessary to provide the Service.</p>
|
||||||
|
|
||||||
|
<h4>5.2 Content Standards</h4>
|
||||||
|
<p>You represent and warrant that all content you upload:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Does not violate any laws or regulations</li>
|
||||||
|
<li>Does not infringe on third-party rights</li>
|
||||||
|
<li>Is not harmful, offensive, or inappropriate</li>
|
||||||
|
<li>Complies with our content guidelines</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>5.3 Data Processing</h4>
|
||||||
|
<p>We process your data in accordance with our Privacy Policy and applicable data protection laws. You are responsible for ensuring you have the right to share any data you upload.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">6. Subscription and Payment</h2>
|
||||||
|
<h4>6.1 Subscription Plans</h4>
|
||||||
|
<p>We offer various subscription plans with different features and pricing. Current plans and pricing are available on our website and may be updated from time to time.</p>
|
||||||
|
|
||||||
|
<h4>6.2 Payment Terms</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Payments are billed in advance on a recurring basis</li>
|
||||||
|
<li>All fees are non-refundable except as required by law</li>
|
||||||
|
<li>We may change our pricing with 30 days' notice</li>
|
||||||
|
<li>Failed payments may result in service suspension</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>6.3 Cancellation</h4>
|
||||||
|
<p>You may cancel your subscription at any time through your account settings. Cancellation will take effect at the end of your current billing period.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">7. Service Availability</h2>
|
||||||
|
<div class="info-box">
|
||||||
|
<h5><i class="fas fa-info-circle me-2"></i>Service Level</h5>
|
||||||
|
<p class="mb-0">We strive to maintain 99.9% uptime but cannot guarantee uninterrupted service. We may perform maintenance that temporarily affects availability.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>We reserve the right to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Modify or discontinue features at any time</li>
|
||||||
|
<li>Perform maintenance that may affect service availability</li>
|
||||||
|
<li>Limit or suspend access for security or legal reasons</li>
|
||||||
|
<li>Update the Service to improve functionality</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">8. Intellectual Property</h2>
|
||||||
|
<h4>8.1 Our Rights</h4>
|
||||||
|
<p>DocuPulse and its original content, features, and functionality are owned by DocuPulse Inc. and are protected by international copyright, trademark, patent, trade secret, and other intellectual property laws.</p>
|
||||||
|
|
||||||
|
<h4>8.2 Your Rights</h4>
|
||||||
|
<p>You retain ownership of your content. You grant us a limited, non-exclusive license to use your content solely to provide the Service.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">9. Privacy and Data Protection</h2>
|
||||||
|
<p>Your privacy is important to us. Our collection and use of personal information is governed by our Privacy Policy, which is incorporated into these Terms by reference.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">10. Limitation of Liability</h2>
|
||||||
|
<div class="warning-box">
|
||||||
|
<h5><i class="fas fa-shield-alt me-2"></i>Liability Limitations</h5>
|
||||||
|
<p class="mb-0">To the maximum extent permitted by law, DocuPulse shall not be liable for any indirect, incidental, special, consequential, or punitive damages, including but not limited to loss of profits, data, or use.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Our total liability to you for any claims arising from these Terms or your use of the Service shall not exceed the amount you paid us in the 12 months preceding the claim.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">11. Indemnification</h2>
|
||||||
|
<p>You agree to indemnify and hold harmless DocuPulse and its officers, directors, employees, and agents from any claims, damages, losses, or expenses arising from:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Your use of the Service</li>
|
||||||
|
<li>Your violation of these Terms</li>
|
||||||
|
<li>Your content or data</li>
|
||||||
|
<li>Your violation of any third-party rights</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="section-title">12. Termination</h2>
|
||||||
|
<h4>12.1 Termination by You</h4>
|
||||||
|
<p>You may terminate your account at any time by contacting us or using the account deletion feature in your settings.</p>
|
||||||
|
|
||||||
|
<h4>12.2 Termination by Us</h4>
|
||||||
|
<p>We may terminate or suspend your account immediately if:</p>
|
||||||
|
<ul>
|
||||||
|
<li>You violate these Terms</li>
|
||||||
|
<li>You engage in fraudulent or illegal activities</li>
|
||||||
|
<li>We are required to do so by law</li>
|
||||||
|
<li>You fail to pay applicable fees</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>12.3 Effect of Termination</h4>
|
||||||
|
<p>Upon termination, your right to use the Service ceases immediately. We may delete your account and data in accordance with our data retention policies.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">13. Dispute Resolution</h2>
|
||||||
|
<h4>13.1 Governing Law</h4>
|
||||||
|
<p>These Terms are governed by the laws of [Jurisdiction], without regard to conflict of law principles.</p>
|
||||||
|
|
||||||
|
<h4>13.2 Dispute Resolution Process</h4>
|
||||||
|
<p>Before pursuing formal legal action, we encourage you to contact us directly to resolve any disputes. If we cannot resolve the dispute informally, you agree to submit to binding arbitration.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">14. Changes to Terms</h2>
|
||||||
|
<p>We may update these Terms from time to time. We will notify you of any material changes by:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Posting the updated Terms on our website</li>
|
||||||
|
<li>Sending email notifications to registered users</li>
|
||||||
|
<li>Displaying prominent notices in our application</li>
|
||||||
|
</ul>
|
||||||
|
<p>Your continued use of the Service after changes become effective constitutes acceptance of the updated Terms.</p>
|
||||||
|
|
||||||
|
<h2 class="section-title">15. Miscellaneous</h2>
|
||||||
|
<h4>15.1 Entire Agreement</h4>
|
||||||
|
<p>These Terms, together with our Privacy Policy, constitute the entire agreement between you and DocuPulse regarding the Service.</p>
|
||||||
|
|
||||||
|
<h4>15.2 Severability</h4>
|
||||||
|
<p>If any provision of these Terms is found to be unenforceable, the remaining provisions will continue in full force and effect.</p>
|
||||||
|
|
||||||
|
<h4>15.3 Waiver</h4>
|
||||||
|
<p>Our failure to enforce any right or provision of these Terms will not constitute a waiver of that right or provision.</p>
|
||||||
|
|
||||||
|
<div class="contact-info">
|
||||||
|
<h3><i class="fas fa-envelope me-2"></i>Contact Us</h3>
|
||||||
|
<p>If you have questions about these Terms of Service, please contact us:</p>
|
||||||
|
<p><strong>Email:</strong> <a href="mailto:legal@docupulse.com">legal@docupulse.com</a></p>
|
||||||
|
<p><strong>Address:</strong> DocuPulse Inc., 123 Business Ave, Suite 100, City, State 12345</p>
|
||||||
|
<p><strong>Phone:</strong> <a href="tel:+1-555-123-4567">+1 (555) 123-4567</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="last-updated">
|
||||||
|
<p class="mb-0"><strong>Last Updated:</strong> December 2024</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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>
|
||||||
@@ -134,7 +140,12 @@
|
|||||||
{% if is_master %}
|
{% if is_master %}
|
||||||
<!-- Connections Tab -->
|
<!-- Connections Tab -->
|
||||||
<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, stripe_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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</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 %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% from "settings/components/connection_modals.html" import connection_modals %}
|
{% from "settings/components/connection_modals.html" import connection_modals %}
|
||||||
|
|
||||||
{% macro connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings) %}
|
{% macro connections_tab(portainer_settings, nginx_settings, site_settings, git_settings, cloudflare_settings, stripe_settings) %}
|
||||||
<!-- Meta tags for JavaScript -->
|
<!-- Meta tags for JavaScript -->
|
||||||
<meta name="management-api-key" content="{{ site_settings.management_api_key }}">
|
<meta name="management-api-key" content="{{ site_settings.management_api_key }}">
|
||||||
<meta name="git-settings" content="{{ git_settings|tojson|safe }}">
|
<meta name="git-settings" content="{{ git_settings|tojson|safe }}">
|
||||||
@@ -212,6 +212,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Stripe Connection Card -->
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fab fa-stripe me-2"></i>Stripe Connection
|
||||||
|
</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="testStripeConnection()">
|
||||||
|
<i class="fas fa-plug me-1"></i>Test Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="stripeForm" onsubmit="saveStripeConnection(event)">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="stripePublishableKey" class="form-label">Publishable Key</label>
|
||||||
|
<input type="text" class="form-control" id="stripePublishableKey" name="stripePublishableKey"
|
||||||
|
placeholder="pk_test_..." required
|
||||||
|
value="{{ stripe_settings.publishable_key if stripe_settings and stripe_settings.publishable_key else '' }}">
|
||||||
|
<div class="form-text">Your Stripe publishable key (starts with pk_test_ or pk_live_)</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="stripeSecretKey" class="form-label">Secret Key</label>
|
||||||
|
<input type="password" class="form-control" id="stripeSecretKey" name="stripeSecretKey"
|
||||||
|
placeholder="sk_test_..." required
|
||||||
|
value="{{ stripe_settings.secret_key if stripe_settings and stripe_settings.secret_key else '' }}">
|
||||||
|
<div class="form-text">Your Stripe secret key (starts with sk_test_ or sk_live_)</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="stripeWebhookSecret" class="form-label">Webhook Secret (Optional)</label>
|
||||||
|
<input type="password" class="form-control" id="stripeWebhookSecret" name="stripeWebhookSecret"
|
||||||
|
placeholder="whsec_..."
|
||||||
|
value="{{ stripe_settings.webhook_secret if stripe_settings and stripe_settings.webhook_secret else '' }}">
|
||||||
|
<div class="form-text">Webhook endpoint secret for secure event handling</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="stripeCustomerPortalUrl" class="form-label">Customer Portal URL</label>
|
||||||
|
<input type="url" class="form-control" id="stripeCustomerPortalUrl" name="stripeCustomerPortalUrl"
|
||||||
|
placeholder="https://billing.stripe.com/p/login/..."
|
||||||
|
value="{{ stripe_settings.customer_portal_url if stripe_settings and stripe_settings.customer_portal_url else '' }}">
|
||||||
|
<div class="form-text">URL for customers to manage their subscriptions and billing</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="stripeTestMode" name="stripeTestMode"
|
||||||
|
{% if stripe_settings and stripe_settings.test_mode %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="stripeTestMode">
|
||||||
|
Test Mode (Use test keys)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Enable this to use Stripe test mode for development</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-1"></i>Save Stripe Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Connection Modal -->
|
<!-- Save Connection Modal -->
|
||||||
|
|||||||
493
templates/settings/tabs/pricing.html
Normal file
493
templates/settings/tabs/pricing.html
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
{% 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>
|
||||||
|
|
||||||
|
<!-- Stripe Integration Info -->
|
||||||
|
{% if plan.stripe_product_id or plan.stripe_monthly_price_id or plan.stripe_annual_price_id %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Stripe Integration:</strong>
|
||||||
|
<div class="mt-2">
|
||||||
|
{% if plan.stripe_product_id %}
|
||||||
|
<div class="mb-1">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fas fa-tag me-1"></i>Product ID:
|
||||||
|
<code class="text-primary">{{ plan.stripe_product_id }}</code>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if plan.stripe_monthly_price_id %}
|
||||||
|
<div class="mb-1">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fas fa-credit-card me-1"></i>Monthly Price ID:
|
||||||
|
<code class="text-primary">{{ plan.stripe_monthly_price_id }}</code>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if plan.stripe_annual_price_id %}
|
||||||
|
<div class="mb-1">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fas fa-credit-card me-1"></i>Annual Price ID:
|
||||||
|
<code class="text-primary">{{ plan.stripe_annual_price_id }}</code>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Stripe Product/Price IDs Section -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="stripeProductId" class="form-label">Stripe Product ID</label>
|
||||||
|
<input type="text" class="form-control" id="stripeProductId" name="stripe_product_id" placeholder="prod_xxx">
|
||||||
|
<small class="text-muted">The Stripe Product ID for this plan</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="stripeMonthlyPriceId" class="form-label">Stripe Monthly Price ID</label>
|
||||||
|
<input type="text" class="form-control" id="stripeMonthlyPriceId" name="stripe_monthly_price_id" placeholder="price_xxx">
|
||||||
|
<small class="text-muted">The Stripe Price ID for monthly billing</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="stripeAnnualPriceId" class="form-label">Stripe Annual Price ID</label>
|
||||||
|
<input type="text" class="form-control" id="stripeAnnualPriceId" name="stripe_annual_price_id" placeholder="price_xxx">
|
||||||
|
<small class="text-muted">The Stripe Price ID for annual billing</small>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Stripe Product/Price IDs Section -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="stripeProductId" class="form-label">Stripe Product ID</label>
|
||||||
|
<input type="text" class="form-control" id="stripeProductId" name="stripe_product_id" placeholder="prod_xxx">
|
||||||
|
<small class="text-muted">The Stripe Product ID for this plan</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="stripeMonthlyPriceId" class="form-label">Stripe Monthly Price ID</label>
|
||||||
|
<input type="text" class="form-control" id="stripeMonthlyPriceId" name="stripe_monthly_price_id" placeholder="price_xxx">
|
||||||
|
<small class="text-muted">The Stripe Price ID for monthly billing</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="stripeAnnualPriceId" class="form-label">Stripe Annual Price ID</label>
|
||||||
|
<input type="text" class="form-control" id="stripeAnnualPriceId" name="stripe_annual_price_id" placeholder="price_xxx">
|
||||||
|
<small class="text-muted">The Stripe Price ID for annual billing</small>
|
||||||
|
</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 %}
|
||||||
@@ -297,6 +297,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Version Management -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<h5 style="color: var(--primary-color);" class="mb-4">Version Management</h5>
|
||||||
|
|
||||||
|
<!-- Version Tracking -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h6 class="mb-0" style="color: var(--primary-color);">
|
||||||
|
<i class="fas fa-tags me-2"></i>Version Tracking
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted mb-2">Environment Variables</h6>
|
||||||
|
<ul class="list-unstyled small">
|
||||||
|
<li class="mb-1">• <code>APP_VERSION</code> - Application version/tag</li>
|
||||||
|
<li class="mb-1">• <code>GIT_COMMIT</code> - Git commit hash</li>
|
||||||
|
<li class="mb-1">• <code>GIT_BRANCH</code> - Git branch name</li>
|
||||||
|
<li class="mb-1">• <code>DEPLOYED_AT</code> - Deployment timestamp</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted mb-2">Database Storage</h6>
|
||||||
|
<ul class="list-unstyled small">
|
||||||
|
<li class="mb-1">• Instance version tracking</li>
|
||||||
|
<li class="mb-1">• Version comparison</li>
|
||||||
|
<li class="mb-1">• Update notifications</li>
|
||||||
|
<li class="mb-1">• Deployment history</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version API -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h6 class="mb-0" style="color: var(--primary-color);">
|
||||||
|
<i class="fas fa-code me-2"></i>Version API
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-muted mb-2">Endpoint</h6>
|
||||||
|
<code class="bg-light p-2 rounded d-block">GET /api/version</code>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-muted mb-2">Response</h6>
|
||||||
|
<pre class="bg-light p-2 rounded small"><code>{
|
||||||
|
"version": "v1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"commit": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
|
||||||
|
"branch": "main",
|
||||||
|
"deployed_at": "2024-01-15T10:30:00.000000"
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
|
|||||||
55
test_version_api.py
Normal file
55
test_version_api.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for the new version API endpoint.
|
||||||
|
This verifies that the database-only version tracking works correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def test_version_api():
|
||||||
|
"""Test the version API endpoint"""
|
||||||
|
|
||||||
|
# Set test environment variables
|
||||||
|
os.environ['APP_VERSION'] = 'v1.2.3'
|
||||||
|
os.environ['GIT_COMMIT'] = 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0'
|
||||||
|
os.environ['GIT_BRANCH'] = 'main'
|
||||||
|
os.environ['DEPLOYED_AT'] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
print("Testing version API endpoint...")
|
||||||
|
print(f"APP_VERSION: {os.environ['APP_VERSION']}")
|
||||||
|
print(f"GIT_COMMIT: {os.environ['GIT_COMMIT']}")
|
||||||
|
print(f"GIT_BRANCH: {os.environ['GIT_BRANCH']}")
|
||||||
|
print(f"DEPLOYED_AT: {os.environ['DEPLOYED_AT']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test the API endpoint (assuming it's running on localhost:5000)
|
||||||
|
response = requests.get('http://localhost:5000/api/version')
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print("\n✅ Version API test successful!")
|
||||||
|
print("Response:")
|
||||||
|
print(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
# Verify the response matches our environment variables
|
||||||
|
assert data['version'] == os.environ['APP_VERSION'], f"Version mismatch: {data['version']} != {os.environ['APP_VERSION']}"
|
||||||
|
assert data['commit'] == os.environ['GIT_COMMIT'], f"Commit mismatch: {data['commit']} != {os.environ['GIT_COMMIT']}"
|
||||||
|
assert data['branch'] == os.environ['GIT_BRANCH'], f"Branch mismatch: {data['branch']} != {os.environ['GIT_BRANCH']}"
|
||||||
|
assert data['deployed_at'] == os.environ['DEPLOYED_AT'], f"Deployed at mismatch: {data['deployed_at']} != {os.environ['DEPLOYED_AT']}"
|
||||||
|
|
||||||
|
print("\n✅ All version information matches environment variables!")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"\n❌ Version API test failed with status code: {response.status_code}")
|
||||||
|
print(f"Response: {response.text}")
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print("\n❌ Could not connect to the API. Make sure the application is running on localhost:5000")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Test failed with error: {str(e)}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
test_version_api()
|
||||||
53
timespent.py
Normal file
53
timespent.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Run git log command
|
||||||
|
log_output = subprocess.check_output(
|
||||||
|
['git', 'log', '--pretty=format:%h %an %ad', '--date=iso'],
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse commit dates
|
||||||
|
commit_times = []
|
||||||
|
for line in log_output.splitlines():
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
# Commit hash, author, datetime string
|
||||||
|
dt_str = " ".join(parts[2:4]) # "YYYY-MM-DD HH:MM:SS"
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
commit_times.append(dt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort commits chronologically
|
||||||
|
commit_times.sort()
|
||||||
|
|
||||||
|
# Session grouping (commits < 1 hour apart are same session)
|
||||||
|
SESSION_GAP = timedelta(hours=1)
|
||||||
|
sessions = []
|
||||||
|
if commit_times:
|
||||||
|
start = commit_times[0]
|
||||||
|
prev = commit_times[0]
|
||||||
|
|
||||||
|
for t in commit_times[1:]:
|
||||||
|
if t - prev > SESSION_GAP:
|
||||||
|
# Close previous session
|
||||||
|
sessions.append((start, prev))
|
||||||
|
start = t
|
||||||
|
prev = t
|
||||||
|
sessions.append((start, prev)) # last session
|
||||||
|
|
||||||
|
# Estimate durations
|
||||||
|
total_time = timedelta()
|
||||||
|
for start, end in sessions:
|
||||||
|
duration = end - start
|
||||||
|
# Add a minimum session length (e.g. 30 min) so single commits aren’t near-zero
|
||||||
|
if duration < timedelta(minutes=30):
|
||||||
|
duration = timedelta(minutes=30)
|
||||||
|
total_time += duration
|
||||||
|
|
||||||
|
print(f"Number of commits: {len(commit_times)}")
|
||||||
|
print(f"Number of sessions: {len(sessions)}")
|
||||||
|
print(f"Estimated total coding time: {total_time} (~{total_time.total_seconds()/3600:.1f} hours)")
|
||||||
Binary file not shown.
444
utils/stripe_utils.py
Normal file
444
utils/stripe_utils.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
"""
|
||||||
|
Stripe utility functions for managing products, prices, and checkout sessions.
|
||||||
|
"""
|
||||||
|
import stripe
|
||||||
|
import os
|
||||||
|
from models import KeyValueSettings, PricingPlan
|
||||||
|
from flask import current_app, url_for
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def get_stripe_settings():
|
||||||
|
"""Get Stripe settings from database"""
|
||||||
|
stripe_settings = KeyValueSettings.get_value('stripe_settings')
|
||||||
|
if not stripe_settings:
|
||||||
|
return None
|
||||||
|
return stripe_settings
|
||||||
|
|
||||||
|
def configure_stripe():
|
||||||
|
"""Configure Stripe with API key from settings"""
|
||||||
|
stripe_settings = get_stripe_settings()
|
||||||
|
if not stripe_settings or not stripe_settings.get('secret_key'):
|
||||||
|
raise ValueError("Stripe secret key not configured")
|
||||||
|
|
||||||
|
stripe.api_key = stripe_settings['secret_key']
|
||||||
|
return stripe_settings
|
||||||
|
|
||||||
|
def create_stripe_product(plan):
|
||||||
|
"""
|
||||||
|
Create a Stripe product and prices for a pricing plan
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plan: PricingPlan instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Contains product_id, monthly_price_id, annual_price_id
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
configure_stripe()
|
||||||
|
|
||||||
|
# Create product
|
||||||
|
product = stripe.Product.create(
|
||||||
|
name=plan.name,
|
||||||
|
description=plan.description or f"DocuPulse {plan.name} Plan",
|
||||||
|
metadata={
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'plan_name': plan.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create monthly price
|
||||||
|
monthly_price = stripe.Price.create(
|
||||||
|
product=product.id,
|
||||||
|
unit_amount=int(plan.monthly_price * 100), # Convert to cents
|
||||||
|
currency='eur',
|
||||||
|
recurring={'interval': 'month'},
|
||||||
|
metadata={
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'billing_cycle': 'monthly',
|
||||||
|
'plan_name': plan.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create annual price
|
||||||
|
annual_price = stripe.Price.create(
|
||||||
|
product=product.id,
|
||||||
|
unit_amount=int(plan.annual_price * 100), # Convert to cents
|
||||||
|
currency='eur',
|
||||||
|
recurring={'interval': 'year'},
|
||||||
|
metadata={
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'billing_cycle': 'annual',
|
||||||
|
'plan_name': plan.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'product_id': product.id,
|
||||||
|
'monthly_price_id': monthly_price.id,
|
||||||
|
'annual_price_id': annual_price.id
|
||||||
|
}
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error creating product for plan {plan.name}: {str(e)}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating Stripe product for plan {plan.name}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def update_stripe_product(plan):
|
||||||
|
"""
|
||||||
|
Update an existing Stripe product and prices for a pricing plan
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plan: PricingPlan instance with existing Stripe IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Updated product and price information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
configure_stripe()
|
||||||
|
|
||||||
|
if not plan.stripe_product_id:
|
||||||
|
# If no product ID exists, create new product
|
||||||
|
return create_stripe_product(plan)
|
||||||
|
|
||||||
|
# Update product
|
||||||
|
product = stripe.Product.modify(
|
||||||
|
plan.stripe_product_id,
|
||||||
|
name=plan.name,
|
||||||
|
description=plan.description or f"DocuPulse {plan.name} Plan",
|
||||||
|
metadata={
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'plan_name': plan.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Archive old prices and create new ones
|
||||||
|
new_prices = {}
|
||||||
|
|
||||||
|
# Handle monthly price
|
||||||
|
if plan.stripe_monthly_price_id:
|
||||||
|
try:
|
||||||
|
# Archive old monthly price
|
||||||
|
stripe.Price.modify(plan.stripe_monthly_price_id, active=False)
|
||||||
|
except stripe.error.StripeError:
|
||||||
|
pass # Price might not exist
|
||||||
|
|
||||||
|
# Create new monthly price
|
||||||
|
monthly_price = stripe.Price.create(
|
||||||
|
product=product.id,
|
||||||
|
unit_amount=int(plan.monthly_price * 100),
|
||||||
|
currency='eur',
|
||||||
|
recurring={'interval': 'month'},
|
||||||
|
metadata={
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'billing_cycle': 'monthly',
|
||||||
|
'plan_name': plan.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
new_prices['monthly_price_id'] = monthly_price.id
|
||||||
|
|
||||||
|
# Handle annual price
|
||||||
|
if plan.stripe_annual_price_id:
|
||||||
|
try:
|
||||||
|
# Archive old annual price
|
||||||
|
stripe.Price.modify(plan.stripe_annual_price_id, active=False)
|
||||||
|
except stripe.error.StripeError:
|
||||||
|
pass # Price might not exist
|
||||||
|
|
||||||
|
# Create new annual price
|
||||||
|
annual_price = stripe.Price.create(
|
||||||
|
product=product.id,
|
||||||
|
unit_amount=int(plan.annual_price * 100),
|
||||||
|
currency='eur',
|
||||||
|
recurring={'interval': 'year'},
|
||||||
|
metadata={
|
||||||
|
'plan_id': plan.id,
|
||||||
|
'billing_cycle': 'annual',
|
||||||
|
'plan_name': plan.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
new_prices['annual_price_id'] = annual_price.id
|
||||||
|
|
||||||
|
return {
|
||||||
|
'product_id': product.id,
|
||||||
|
'monthly_price_id': new_prices['monthly_price_id'],
|
||||||
|
'annual_price_id': new_prices['annual_price_id']
|
||||||
|
}
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error updating product for plan {plan.name}: {str(e)}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating Stripe product for plan {plan.name}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def create_checkout_session(plan_id, billing_cycle='monthly', success_url=None, cancel_url=None):
|
||||||
|
"""
|
||||||
|
Create a Stripe checkout session for a pricing plan
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plan_id: ID of the PricingPlan
|
||||||
|
billing_cycle: 'monthly' or 'annual'
|
||||||
|
success_url: URL to redirect to on successful payment
|
||||||
|
cancel_url: URL to redirect to on cancellation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Checkout session URL
|
||||||
|
"""
|
||||||
|
logger.info(f"=== CREATE CHECKOUT SESSION START ===")
|
||||||
|
logger.info(f"Plan ID: {plan_id}")
|
||||||
|
logger.info(f"Billing cycle: {billing_cycle}")
|
||||||
|
logger.info(f"Success URL: {success_url}")
|
||||||
|
logger.info(f"Cancel URL: {cancel_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
configure_stripe()
|
||||||
|
logger.info("Stripe configured successfully")
|
||||||
|
|
||||||
|
plan = PricingPlan.query.get(plan_id)
|
||||||
|
logger.info(f"Plan lookup result: {plan}")
|
||||||
|
|
||||||
|
if not plan:
|
||||||
|
logger.error(f"Pricing plan with ID {plan_id} not found")
|
||||||
|
raise ValueError(f"Pricing plan with ID {plan_id} not found")
|
||||||
|
|
||||||
|
logger.info(f"Plan found: {plan.name}")
|
||||||
|
logger.info(f"Plan stripe_monthly_price_id: {plan.stripe_monthly_price_id}")
|
||||||
|
logger.info(f"Plan stripe_annual_price_id: {plan.stripe_annual_price_id}")
|
||||||
|
|
||||||
|
# Determine which price ID to use
|
||||||
|
if billing_cycle == 'monthly':
|
||||||
|
price_id = plan.stripe_monthly_price_id
|
||||||
|
if not price_id:
|
||||||
|
logger.error("Monthly price not configured for this plan")
|
||||||
|
raise ValueError("Monthly price not configured for this plan")
|
||||||
|
elif billing_cycle == 'annual':
|
||||||
|
price_id = plan.stripe_annual_price_id
|
||||||
|
if not price_id:
|
||||||
|
logger.error("Annual price not configured for this plan")
|
||||||
|
raise ValueError("Annual price not configured for this plan")
|
||||||
|
else:
|
||||||
|
logger.error(f"Invalid billing cycle: {billing_cycle}")
|
||||||
|
raise ValueError("Invalid billing cycle. Must be 'monthly' or 'annual'")
|
||||||
|
|
||||||
|
logger.info(f"Using price ID: {price_id}")
|
||||||
|
|
||||||
|
# Set default URLs if not provided
|
||||||
|
if not success_url:
|
||||||
|
success_url = url_for('main.dashboard', _external=True)
|
||||||
|
if not cancel_url:
|
||||||
|
cancel_url = url_for('main.public_home', _external=True)
|
||||||
|
|
||||||
|
logger.info(f"Final success URL: {success_url}")
|
||||||
|
logger.info(f"Final cancel URL: {cancel_url}")
|
||||||
|
|
||||||
|
# Create checkout session
|
||||||
|
session_data = {
|
||||||
|
'payment_method_types': ['card'],
|
||||||
|
'line_items': [{
|
||||||
|
'price': price_id,
|
||||||
|
'quantity': 1,
|
||||||
|
}],
|
||||||
|
'mode': 'subscription',
|
||||||
|
'success_url': f"{success_url}?session_id={{CHECKOUT_SESSION_ID}}",
|
||||||
|
'cancel_url': cancel_url,
|
||||||
|
'metadata': {
|
||||||
|
'plan_id': plan_id,
|
||||||
|
'plan_name': plan.name,
|
||||||
|
'billing_cycle': billing_cycle
|
||||||
|
},
|
||||||
|
'customer_email': None, # Will be collected during checkout
|
||||||
|
'allow_promotion_codes': True,
|
||||||
|
'billing_address_collection': 'required',
|
||||||
|
'phone_number_collection': {
|
||||||
|
'enabled': True
|
||||||
|
},
|
||||||
|
'automatic_tax': {
|
||||||
|
'enabled': True
|
||||||
|
},
|
||||||
|
'tax_id_collection': {
|
||||||
|
'enabled': True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Creating Stripe session with data: {session_data}")
|
||||||
|
|
||||||
|
session = stripe.checkout.Session.create(**session_data)
|
||||||
|
|
||||||
|
logger.info(f"Stripe session created successfully: {session.id}")
|
||||||
|
logger.info(f"Session URL: {session.url}")
|
||||||
|
|
||||||
|
return session.url
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error creating checkout session: {str(e)}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating checkout session: {str(e)}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
logger.info("=== CREATE CHECKOUT SESSION END ===")
|
||||||
|
|
||||||
|
def get_subscription_info(session_id):
|
||||||
|
"""
|
||||||
|
Get subscription information from a checkout session
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Stripe checkout session ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Subscription information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
configure_stripe()
|
||||||
|
|
||||||
|
session = stripe.checkout.Session.retrieve(session_id)
|
||||||
|
|
||||||
|
if session.payment_status == 'paid':
|
||||||
|
subscription = stripe.Subscription.retrieve(session.subscription)
|
||||||
|
|
||||||
|
# Get customer details
|
||||||
|
customer_details = {}
|
||||||
|
if session.customer_details:
|
||||||
|
customer_details = {
|
||||||
|
'name': session.customer_details.name,
|
||||||
|
'email': session.customer_details.email,
|
||||||
|
'phone': session.customer_details.phone,
|
||||||
|
'address': {
|
||||||
|
'line1': session.customer_details.address.line1,
|
||||||
|
'line2': session.customer_details.address.line2,
|
||||||
|
'city': session.customer_details.address.city,
|
||||||
|
'state': session.customer_details.address.state,
|
||||||
|
'postal_code': session.customer_details.address.postal_code,
|
||||||
|
'country': session.customer_details.address.country
|
||||||
|
} if session.customer_details.address else None,
|
||||||
|
'shipping': {
|
||||||
|
'name': session.customer_details.shipping.name,
|
||||||
|
'address': {
|
||||||
|
'line1': session.customer_details.shipping.address.line1,
|
||||||
|
'line2': session.customer_details.shipping.address.line2,
|
||||||
|
'city': session.customer_details.shipping.address.city,
|
||||||
|
'state': session.customer_details.shipping.address.state,
|
||||||
|
'postal_code': session.customer_details.shipping.address.postal_code,
|
||||||
|
'country': session.customer_details.shipping.address.country
|
||||||
|
}
|
||||||
|
} if session.customer_details.shipping else None,
|
||||||
|
'tax_ids': [
|
||||||
|
{
|
||||||
|
'type': tax_id.type,
|
||||||
|
'value': tax_id.value
|
||||||
|
} for tax_id in session.customer_details.tax_ids
|
||||||
|
] if session.customer_details.tax_ids else []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'session_id': session_id,
|
||||||
|
'subscription_id': subscription.id,
|
||||||
|
'customer_id': subscription.customer,
|
||||||
|
'status': subscription.status,
|
||||||
|
'plan_id': session.metadata.get('plan_id'),
|
||||||
|
'plan_name': session.metadata.get('plan_name'),
|
||||||
|
'billing_cycle': session.metadata.get('billing_cycle'),
|
||||||
|
'current_period_start': subscription.current_period_start,
|
||||||
|
'current_period_end': subscription.current_period_end,
|
||||||
|
'amount': subscription.items.data[0].price.unit_amount / 100, # Convert from cents
|
||||||
|
'currency': subscription.currency,
|
||||||
|
'customer_details': customer_details
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'session_id': session_id,
|
||||||
|
'payment_status': session.payment_status,
|
||||||
|
'error': 'Payment not completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error getting subscription info: {str(e)}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting subscription info: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def cancel_subscription(subscription_id):
|
||||||
|
"""
|
||||||
|
Cancel a Stripe subscription
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subscription_id: Stripe subscription ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Cancellation information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
configure_stripe()
|
||||||
|
|
||||||
|
subscription = stripe.Subscription.modify(
|
||||||
|
subscription_id,
|
||||||
|
cancel_at_period_end=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'subscription_id': subscription_id,
|
||||||
|
'status': subscription.status,
|
||||||
|
'cancel_at_period_end': subscription.cancel_at_period_end,
|
||||||
|
'current_period_end': subscription.current_period_end
|
||||||
|
}
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error canceling subscription: {str(e)}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error canceling subscription: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def validate_stripe_keys():
|
||||||
|
"""
|
||||||
|
Validate that Stripe keys are properly configured
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Validation result with status and message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stripe_settings = get_stripe_settings()
|
||||||
|
if not stripe_settings:
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'message': 'Stripe settings not configured'
|
||||||
|
}
|
||||||
|
|
||||||
|
if not stripe_settings.get('secret_key'):
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'message': 'Stripe secret key not configured'
|
||||||
|
}
|
||||||
|
|
||||||
|
if not stripe_settings.get('publishable_key'):
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'message': 'Stripe publishable key not configured'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test the API key
|
||||||
|
configure_stripe()
|
||||||
|
account = stripe.Account.retrieve()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'valid': True,
|
||||||
|
'message': 'Stripe configuration is valid',
|
||||||
|
'account_id': account.id,
|
||||||
|
'test_mode': stripe_settings.get('test_mode', False)
|
||||||
|
}
|
||||||
|
|
||||||
|
except stripe.error.AuthenticationError:
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'message': 'Invalid Stripe API key'
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'message': f'Error validating Stripe configuration: {str(e)}'
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"tag": "v1.2.3",
|
|
||||||
"commit": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
|
|
||||||
"branch": "main",
|
|
||||||
"deployed_at": "2024-01-15T10:30:00.000000"
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user