Compare commits

..

1 commit

Author SHA1 Message Date
61cabd1b1e
implement plan pricing
Some checks failed
Django Tests / test (push) Failing after 1m3s
Django Tests / test (pull_request) Failing after 1m3s
2025-06-20 17:40:38 +02:00
103 changed files with 1655 additions and 12430 deletions

View file

@ -5,7 +5,6 @@ ODOO_USERNAME=CHANGEME
ODOO_PASSWORD=CHANGEME
BROKER_USERNAME=broker
BROKER_PASSWORD=CHANGEME
CSP_CALCULATOR_PASSWORD=servala2025
ALLOWED_HOSTS=localhost,127.0.0.1
SECRET_KEY="django-insecure-CHANGEME"
ODOO_LEAD_CAMPAIGN_ID=6

View file

@ -2,7 +2,7 @@ name: Django Tests
on:
push:
branches: [main]
branches: ["*"]
pull_request:
jobs:
@ -31,4 +31,4 @@ jobs:
-w /app \
-e SECRET_KEY=dummysecretkey \
website:test \
sh -c 'uv run --extra dev manage.py migrate --noinput && uv run --extra dev manage.py test hub.services.tests --verbosity=2'
sh -c 'uv run --extra dev manage.py migrate --noinput && uv run --extra dev manage.py test hub.services.tests --verbosity=2'

3
.gitignore vendored
View file

@ -14,5 +14,4 @@ wheels/
*.sqlite3
media/
deployment/secret.yaml
/*.json
/static/
*.json

113
CLAUDE.md
View file

@ -1,113 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Django website for servala.com, built with Python 3.13+ using uv for dependency management. The project structure follows Django conventions with a main `hub` application containing multiple services.
## Development Commands
### Local Development Setup
```bash
cp .env.example .env
source .env
uv run --extra dev manage.py migrate
uv run --extra dev manage.py runserver
```
### Database Operations
```bash
uv run --extra dev manage.py migrate
uv run --extra dev manage.py makemigrations
uv run --extra dev manage.py createsuperuser
```
### Testing
```bash
# Run all tests
uv run --extra dev manage.py test
# Run specific pricing tests (comprehensive suite available)
./run_pricing_tests.sh
# Run specific test modules
uv run --extra dev manage.py test hub.services.tests.test_pricing --verbosity=2
uv run --extra dev manage.py test hub.services.tests.test_pricing_edge_cases --verbosity=2
uv run --extra dev manage.py test hub.services.tests.test_pricing_integration --verbosity=2
```
### Asset Management
```bash
uv run --extra dev manage.py build_assets
uv run --extra dev manage.py collectstatic
```
## Architecture Overview
### Core Django App Structure
- `hub/` - Main Django application
- `services/` - Core business logic with multiple domains:
- `models/` - Database models organized by domain (articles, pricing, providers, services, etc.)
- `views/` - View logic organized by feature
- `forms/` - Form classes for user input
- `admin/` - Django admin customizations
- `tests/` - Comprehensive test suite, especially for pricing logic
- `broker/` - Separate app for broker-related functionality
- `middleware.py` - Custom middleware
- `settings.py` - Django configuration using environs for environment variables
### Key Features
- **Pricing Engine**: Complex pricing calculations with multiple models (ComputePlan, StoragePlan, etc.)
- **Content Management**: Articles, services, providers with image library support
- **Lead Generation**: Contact forms and lead management
- **Partner System**: Cloud providers and consulting partners
- **Price Calculator**: Interactive frontend calculator with ROI calculations
### Frontend Assets
- Static files in `hub/services/static/`
- JavaScript organized by feature:
- `price-calculator/` - Modular price calculator components
- `roi-calculator/` - Modular ROI calculator with separate concerns (core, UI, charts, exports)
- CSS using Bootstrap 5 with custom styling
- Chart.js for data visualization
### Database
- Uses Django ORM with extensive migrations in `hub/services/migrations/`
- SQLite for development and production (`db.sqlite3`)
- Media files stored in `media/` with organized subdirectories
### Deployment
- Docker specific code is in the folder docker/
- Kubernetes deployment specific files in deployment/
- GitLab CI is used as the main CI/CD system
- Forgejo Actions is the secondary CI/CD system
## Development Notes
### Environment Configuration
- Uses `environs` library for environment variable management
- Requires `.env` file for local development (copy from `.env.example`)
- Key settings: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`
### Testing Strategy
- Extensive pricing test suite with edge cases and integration tests
- Use `--keepdb` flag for faster test runs during development
- Dedicated test runner script for pricing functionality
### Asset Pipeline
- Django Compressor for CSS/JS optimization
- Static files collection required for production
- Custom management commands for asset building
### Image Management
- Comprehensive image library system with SVG support
- Organized media directories for different content types
- Custom template tags for image handling
### Django specifics
- Use function-based views and follow the Django conventions for naming and structuring views
- Templates use the Django template language
### Claude Code specific
- Use context7 to get up-to-date documentation about Bootstrap, Python and Django

View file

@ -35,6 +35,6 @@ RUN uv sync --frozen \
&& chgrp -R 0 /app \
&& chmod -R g=u /app \
&& chmod g+w /app/config/caddy/Caddyfile \
&& SECRET_KEY=dummy python -m hub build_assets --force
&& SECRET_KEY= python -m hub collectstatic --noinput
CMD ["/usr/local/bin/runhub.sh"]

View file

@ -1,81 +0,0 @@
# Image Library Migration Status
## ✅ COMPLETED (First Production Rollout) - UPDATED
### Models Updated
- **Article**: Now inherits from `ImageReference`, with `image_library` field for new images and original `image` field temporarily
- **CloudProvider**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
- **ConsultingPartner**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
- **Service**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
### New Properties Added
- `Article.get_image()` - Returns image from library or falls back to original field
- `CloudProvider.get_logo()` - Returns logo from library or falls back to original field
- `ConsultingPartner.get_logo()` - Returns logo from library or falls back to original field
- `Service.get_logo()` - Returns logo from library or falls back to original field
### Templates Updated
- ✅ `pages/homepage.html` - Updated service, provider, and partner image references
- ✅ `services/article_list.html` - Updated article image references
- ✅ `services/article_detail.html` - Updated related service/provider/partner logos
- ✅ `services/offering_list.html` - Updated service and provider logos
- ✅ `services/offering_detail.html` - Updated service and provider logos
- ✅ `services/lead_form.html` - Updated service logo
- ✅ `services/partner_detail.html` - Updated partner and service logos
- ✅ `services/partner_list.html` - Updated partner logos
- ✅ `services/provider_list.html` - Updated provider logos
- ✅ `services/provider_detail.html` - Updated provider and service logos
- ✅ `services/service_detail.html` - Updated service and provider logos
### Admin Interface Updated
- ✅ `ArticleAdmin` - Updated image_preview to use get_image property
- ✅ `ServiceAdmin` - Updated logo_preview to use get_logo property
- ✅ `CloudProviderAdmin` - Updated logo_preview to use get_logo property
- ✅ `ConsultingPartnerAdmin` - Updated logo_preview to use get_logo property
### JSON-LD Template Tags Updated
- ✅ Updated structured data generation to use new image properties
- ✅ Updated logo references for services, providers, and partners
### Database Migration
- ✅ Migration `0041_add_image_library_references` successfully applied
- ✅ Migration `0042_fix_image_library_field_name` successfully applied
- ✅ All models now have `image_library` foreign key fields to ImageLibrary
- ✅ Original image fields preserved for backward compatibility
- ✅ Fixed field name conflicts using `%(class)s_references` related_name pattern
### Admin Interface Enhanced
- ✅ **ArticleAdmin**: Added fieldsets with `image_library` field visible in "Images" section
- ✅ **ServiceAdmin**: Added fieldsets with `image_library` field visible in "Images" section
- ✅ **CloudProviderAdmin**: Added fieldsets with `image_library` field visible in "Images" section
- ✅ **ConsultingPartnerAdmin**: Added fieldsets with `image_library` field visible in "Images" section
- ✅ All admin interfaces show both new and legacy fields during transition
- ✅ Clear descriptions guide users to use Image Library for new images
## Current Status
The system is now ready for production with dual image support:
- **New images**: Can be added through the Image Library
- **Legacy images**: Still work through the original fields
- **Templates**: Use the new `get_image/get_logo` properties that automatically fall back
## Next Steps (Future Cleanup)
1. **Data Migration**: Create script to migrate existing images to ImageLibrary
2. **Admin Updates**: Update admin interfaces to use ImageLibrary selection
3. **Template Validation**: Add null checks to remaining templates
4. **Field Removal**: Remove legacy image fields after migration is complete
5. **Storage Cleanup**: Remove old image files from media directories
## Benefits Achieved
- ✅ Centralized image management through ImageLibrary
- ✅ Usage tracking for images
- ✅ Backward compatibility maintained
- ✅ Enhanced admin experience ready
- ✅ Consistent image handling across all models
- ✅ Proper fallback mechanisms in place
## Safety Measures
- ✅ Original image fields preserved
- ✅ Gradual migration approach
- ✅ Fallback properties ensure no broken images
- ✅ Database migration tested and applied
- ✅ Admin interface maintains functionality

View file

@ -1,342 +0,0 @@
# Servala Investment Models: Comprehensive Guide
This document provides detailed information about Servala's two investment models for Cloud Service Providers (CSPs) looking to expand their service portfolio through partnership with Servala's managed services platform.
## Executive Summary
Servala offers two distinct investment approaches for CSPs:
1. **Loan Model**: Low-risk, predictable returns through lending to Servala
2. **Direct Investment Model**: Higher potential returns through performance-based revenue sharing
Both models enable CSPs to capitalize on the growing demand for managed cloud services while supporting Servala's platform expansion.
---
## Investment Model #1: Loan Model
### Overview
The Loan Model provides CSPs with a traditional lending arrangement where they provide capital to Servala at a fixed interest rate, receiving guaranteed monthly payments regardless of business performance.
### Key Characteristics
#### Financial Structure
- **Investment Range**: CHF 100,000 - CHF 2,000,000
- **Interest Rates**: Typically 3-7% annually (negotiable)
- **Payment Schedule**: Fixed monthly payments using standard amortization
- **Term Length**: 1-5 years (customizable)
#### Payment Calculation
Monthly payments are calculated using the standard amortization formula:
```
Monthly Payment = P × [r(1+r)^n] / [(1+r)^n - 1]
Where:
- P = Principal loan amount
- r = Monthly interest rate (annual rate ÷ 12)
- n = Total number of payments (years × 12)
```
#### Revenue Distribution
- **CSP**: 100% of monthly loan payments
- **Servala**: 0% (receives loan capital for operations)
#### Break-Even Analysis
- **Break-even occurs**: When cumulative payments received equal the original loan amount
- **Typical timeline**: 12-18 months depending on interest rate and term
- **Risk level**: Very low - payments are contractually guaranteed
### Advantages for CSPs
- **Predictable cash flow**: Fixed monthly income regardless of market conditions
- **Low risk**: Not dependent on Servala's business performance
- **Simple structure**: Traditional lending arrangement with clear terms
- **Principal protection**: Loan principal is protected by Servala's assets and business
### Considerations
- **Limited upside**: Returns are capped at the agreed interest rate
- **No performance benefits**: CSP doesn't benefit from exceptional business growth
- **Market rate dependency**: Returns may lag behind high-performing investment alternatives
---
## Investment Model #2: Direct Investment Model
### Overview
The Direct Investment Model allows CSPs to invest directly in Servala's platform operations, earning returns through revenue sharing that scales with business performance and their active involvement in sales.
### Key Characteristics
#### Financial Structure
- **Investment Range**: CHF 100,000 - CHF 2,000,000
- **Revenue Sharing**: 15-35% to Servala (remainder to CSP)
- **Grace Period**: 6+ months of 100% revenue retention for CSP
- **Performance Bonuses**: Up to 15% additional revenue share for high performers
#### Investment Scaling Benefits
Direct investments unlock operational advantages through progressive scaling:
| Investment Amount | Scaling Factor | Customer Acquisition | Churn Reduction |
|------------------|----------------|----------------------|-----------------|
| CHF 500,000 | 1.0x | Baseline | 0% |
| CHF 750,000 | 1.25x | +25% vs baseline | 10% |
| CHF 1,000,000 | 1.5x | +50% vs baseline | 20% |
| CHF 1,500,000 | 1.75x | +75% vs baseline | 30% |
| CHF 2,000,000 | 2.0x | +100% vs baseline | 40% |
#### Dynamic Grace Period
Grace periods extend with larger investments, providing longer periods of 100% revenue retention:
- **Base Grace Period**: 6 months
- **Extension Formula**: +1 month per CHF 250,000 invested above CHF 500,000
- **Maximum Grace Period**: 50% of total investment timeframe
**Examples:**
- CHF 500,000 investment → 6 months grace period
- CHF 1,000,000 investment → 8 months grace period
- CHF 2,000,000 investment → 12 months grace period
#### Performance-Based Revenue Split
CSPs who exceed baseline performance expectations receive enhanced revenue sharing:
- **Performance Threshold**: 110% of baseline instance growth
- **Bonus Calculation**: Up to 15% reduction in Servala's revenue share
- **Bonus Formula**: `Bonus = min(15%, (Performance Ratio - 1.1) × 30%)`
- **Minimum Servala Share**: 10% (maximum bonus protection)
### Revenue Distribution Phases
#### Phase 1: Grace Period (Months 1-N)
- **CSP**: 100% of monthly revenue
- **Servala**: 0%
- **Duration**: Based on investment amount (6-12 months)
#### Phase 2: Standard Revenue Sharing (Post-Grace Period)
- **CSP**: 65-90% of monthly revenue (depending on negotiated split)
- **Servala**: 10-35% of monthly revenue
- **Performance bonuses**: Applied based on CSP sales results
#### Phase 3: Performance Enhancement (Ongoing)
- **Additional bonuses**: Available for CSPs exceeding 110% of baseline performance
- **Reduced Servala share**: Performance bonuses reduce Servala's percentage
- **Incentive alignment**: Rewards active sales participation by CSP
### Break-Even Analysis
Break-even for direct investment depends on multiple factors:
#### Conservative Scenario (2% monthly churn)
- **Typical break-even**: 18-24 months
- **Factors**: Lower customer acquisition, baseline performance
- **Risk level**: Moderate
#### Moderate Scenario (3% monthly churn)
- **Typical break-even**: 15-20 months
- **Factors**: Balanced growth and churn rates
- **Risk level**: Moderate-High
#### Aggressive Scenario (5% monthly churn)
- **Typical break-even**: 12-18 months (if performance targets met)
- **Factors**: High growth potential, requires active CSP involvement
- **Risk level**: Higher, but potentially higher returns
### Advantages for CSPs
- **Unlimited upside potential**: Returns scale with business success
- **Performance incentives**: Bonuses for exceeding expectations
- **Extended grace periods**: Larger investments get longer 100% revenue periods
- **Operational scaling**: Investment size directly improves business outcomes
- **Market expansion**: Direct involvement in growing managed services market
### Considerations
- **Performance dependency**: Returns vary based on business performance
- **Active participation beneficial**: Better results when CSP actively promotes services
- **Market risk**: Subject to cloud services market fluctuations
- **Longer break-even periods**: Typically requires 15-24 months to break even
---
## Comparative Analysis
### When to Choose Loan Model
**Ideal for CSPs who:**
- Prioritize predictable, guaranteed returns
- Have limited capacity for active sales involvement
- Prefer traditional lending relationships
- Want to minimize investment risk
- Need steady cash flow for other operations
**Financial Profile:**
- Risk tolerance: Low
- Return expectations: 3-7% annually
- Time horizon: 1-3 years
- Involvement level: Passive
### When to Choose Direct Investment Model
**Ideal for CSPs who:**
- Want to maximize return potential
- Can actively promote and sell managed services
- Are comfortable with performance-based returns
- Seek strategic partnership opportunities
- Have longer investment horizons
**Financial Profile:**
- Risk tolerance: Moderate to High
- Return expectations: 15-40% annually (performance dependent)
- Time horizon: 2-5 years
- Involvement level: Active
### ROI Comparison Examples
#### CHF 1,000,000 Investment Over 3 Years
**Loan Model (5% annual rate):**
- Monthly payment: ~CHF 30,000
- Total return: CHF 1,080,000
- Net profit: CHF 80,000
- ROI: 8% over 3 years
**Direct Investment Model (Moderate Scenario):**
- Year 1: CHF 120,000 (with 8-month grace period)
- Year 2: CHF 180,000 (with performance bonuses)
- Year 3: CHF 240,000 (mature customer base)
- Total return: CHF 540,000
- Net profit: CHF 540,000 - CHF 1,000,000 = break-even in month 22
- ROI: 35% over 3 years (if targets met)
---
## Risk Assessment
### Loan Model Risks
- **Interest rate risk**: Fixed rates may underperform market
- **Opportunity cost**: Missing higher-return investment opportunities
- **Inflation risk**: Fixed payments lose value over time
- **Credit risk**: Minimal, backed by Servala's business assets
**Risk Mitigation:**
- Due diligence on Servala's financial stability
- Legal documentation ensuring payment priority
- Regular financial monitoring and reporting
### Direct Investment Model Risks
- **Performance risk**: Returns depend on business success
- **Market risk**: Cloud services market volatility
- **Execution risk**: Depends on CSP's sales capabilities
- **Competition risk**: New competitors may impact growth
**Risk Mitigation:**
- Diversification across multiple growth scenarios
- Performance bonuses aligned with CSP incentives
- Regular monitoring and adjustment capabilities
- Grace periods provide initial return protection
---
## Investment Process
### Step 1: Initial Assessment
1. **Investment capacity evaluation**: Determine available capital
2. **Risk tolerance assessment**: Choose appropriate model
3. **Strategic alignment review**: Evaluate fit with CSP goals
4. **Market analysis**: Understand local demand for managed services
### Step 2: Due Diligence
1. **Financial review**: Examine Servala's financial statements
2. **Technical assessment**: Understand platform capabilities
3. **Market validation**: Verify demand in target regions
4. **Reference checks**: Speak with existing partners
### Step 3: Terms Negotiation
1. **Investment amount**: Determine capital commitment
2. **Model selection**: Choose loan vs. direct investment
3. **Terms customization**: Negotiate rates, periods, splits
4. **Performance metrics**: Define success criteria and bonuses
### Step 4: Legal Documentation
1. **Investment agreement**: Comprehensive terms and conditions
2. **Operational guidelines**: Define roles and responsibilities
3. **Reporting requirements**: Establish monitoring and reporting
4. **Exit clauses**: Plan for investment conclusion
### Step 5: Implementation
1. **Capital deployment**: Transfer investment funds
2. **Systems integration**: Implement monitoring and reporting
3. **Sales enablement**: Train CSP teams (for direct investment)
4. **Performance tracking**: Begin monitoring key metrics
---
## Key Performance Indicators (KPIs)
### Financial Metrics
- **Monthly Recurring Revenue (MRR)**: Total monthly income from services
- **Customer Acquisition Cost (CAC)**: Cost to acquire new customers
- **Customer Lifetime Value (CLV)**: Total revenue per customer
- **Monthly churn rate**: Percentage of customers lost per month
- **Net Revenue Retention**: Revenue growth from existing customers
### Investment-Specific Metrics
- **Break-even timeline**: Months to recover initial investment
- **Return on Investment (ROI)**: Total return percentage
- **Performance bonus earnings**: Additional income from exceeding targets
- **Grace period utilization**: Revenue during 100% retention period
### Operational Metrics
- **Instance deployment rate**: New services launched monthly
- **Customer satisfaction scores**: Service quality indicators
- **Support ticket resolution time**: Operational efficiency
- **Platform uptime**: Technical reliability metrics
---
## Frequently Asked Questions
### General Questions
**Q: Can I switch between investment models during the term?**
A: Model changes require mutual agreement and may involve restructuring fees. Generally, changes are evaluated at renewal periods.
**Q: What happens if Servala's business underperforms?**
A: Loan Model investors are protected through contractual guarantees. Direct Investment returns will reflect actual performance, but grace periods provide initial protection.
**Q: How are disputes resolved?**
A: Investment agreements include arbitration clauses and dispute resolution procedures. Regular communication helps prevent conflicts.
### Loan Model Questions
**Q: Are loan payments guaranteed?**
A: Yes, loan payments are contractually guaranteed and backed by Servala's business assets and cash flow.
**Q: Can I prepay or extend the loan term?**
A: Terms can be modified by mutual agreement. Prepayment may involve early payment discounts or penalties as specified in the agreement.
### Direct Investment Questions
**Q: How is performance measured for bonus calculations?**
A: Performance is measured against baseline growth scenarios using actual vs. projected customer instance growth over rolling 6-month periods.
**Q: What constitutes "active sales participation"?**
A: This includes promoting Servala services to existing customers, participating in joint sales activities, and meeting agreed-upon referral targets.
**Q: How often are performance bonuses calculated and paid?**
A: Performance bonuses are calculated monthly and applied to that month's revenue sharing. Payments follow the standard monthly distribution schedule.
---
## Contact and Next Steps
For detailed discussions about investment opportunities:
1. **Schedule consultation**: Review your specific requirements and objectives
2. **Receive custom proposal**: Get tailored investment terms and projections
3. **Complete due diligence**: Access detailed financial and operational data
4. **Finalize agreement**: Execute legal documentation and begin partnership
This investment represents an opportunity to participate in the rapidly growing managed cloud services market while supporting innovation in cloud infrastructure automation and management.
---
*This document is for informational purposes only and does not constitute financial advice. All investment decisions should be made after careful consideration of your individual circumstances and consultation with appropriate financial and legal advisors.*
**Document Version**: 1.0
**Last Updated**: December 2024
**Document Owner**: Servala Investment Relations Team

View file

@ -1,176 +0,0 @@
# SVG Support in Image Library
## Overview
The Image Library now supports SVG (Scalable Vector Graphics) files alongside traditional raster image formats. This enhancement allows you to upload, manage, and display SVG images with the same ease as JPEG, PNG, and other image formats.
## Supported Formats
The Image Library now supports:
- **Raster Images**: JPEG, PNG, GIF, WebP, BMP, TIFF
- **Vector Images**: SVG
## Features
### 1. SVG File Validation
- SVG files are validated to ensure they contain valid XML structure
- File size limits apply (max 1MB by default)
- MIME type detection for proper handling
### 2. Automatic Dimension Detection
- SVG dimensions are extracted from `width` and `height` attributes
- Falls back to `viewBox` if width/height attributes are missing
- Provides sensible defaults (100x100) if dimensions cannot be determined
### 3. Proper Rendering
- **Template Tags**: SVG images use `<object>` tags for optimal rendering
- **Admin Interface**: SVG thumbnails display correctly in the admin
- **Form Widgets**: SVG previews work in form interfaces
### 4. Template Tag Support
The `image_library_img` template tag automatically handles SVG files:
```html
<!-- This will render an SVG using <object> tag or <img> tag for raster images -->
{% image_library_img "my-svg-logo" css_class="logo" %}
<!-- Output for SVG files -->
<object data="/media/image_library/my-svg-logo.svg" type="image/svg+xml" alt="My SVG Logo" class="logo">
<img src="/media/image_library/my-svg-logo.svg" alt="My SVG Logo" class="logo"/>
</object>
<!-- Output for raster images -->
<img src="/media/image_library/my-raster-image.jpg" alt="My Raster Image" class="logo"/>
```
## Usage
### 1. Uploading SVG Images
1. Go to the Django admin interface
2. Navigate to **Services > Image Library**
3. Click **Add Image**
4. Choose your SVG file in the image field
5. Fill in the required fields (name, alt text, etc.)
6. Save the image
### 2. Using SVG Images in Templates
```html
<!-- Load the template tag -->
{% load image_library %}
<!-- Use SVG images just like any other image -->
{% image_library_img "my-logo" css_class="img-fluid" %}
{% image_library_img "my-icon" css_class="icon" width="24" height="24" %}
<!-- Get SVG URL -->
{% image_library_url "my-logo" %}
```
### 3. Checking if an Image is SVG
In Python code:
```python
from hub.services.models.images import ImageLibrary
image = ImageLibrary.objects.get(slug="my-image")
if image.is_svg():
print("This is an SVG image")
print(f"MIME type: {image.get_mime_type()}")
```
## Migration
### Existing Images
All existing raster images continue to work without any changes. The migration is backward compatible.
### Updating Image Properties
If you want to update image properties for all existing images:
```bash
uv run --extra dev manage.py update_image_properties
```
## Benefits
1. **Scalability**: SVG images scale perfectly at any size
2. **Performance**: SVG files are typically smaller than high-resolution raster images
3. **Flexibility**: SVG images can be styled with CSS
4. **Accessibility**: SVG images provide better accessibility options
5. **Consistency**: All images are managed through the same unified interface
## Technical Details
### Model Changes
- Changed `ImageField` to `FileField` with custom validation
- Added `is_svg()` method to check if file is SVG
- Added `get_mime_type()` method to determine file type
- Enhanced `_update_image_properties()` to handle SVG dimensions
### Validation
- SVG files are validated as valid XML
- File size limits are enforced
- MIME type checking ensures only valid image/SVG files are accepted
### Rendering
- SVG files use `<object>` tags for optimal rendering
- Fallback `<img>` tags are provided for compatibility
- Admin interface properly displays SVG thumbnails
## File Size Considerations
SVG files are typically much smaller than equivalent raster images:
- **Simple logos**: 1-5KB
- **Complex illustrations**: 10-50KB
- **Very complex graphics**: 100KB+
This makes SVG files ideal for:
- Logos and brand assets
- Icons and symbols
- Simple illustrations
- Graphics that need to scale
## Browser Support
SVG support is excellent across all modern browsers:
- Chrome: Full support
- Firefox: Full support
- Safari: Full support
- Edge: Full support
- Internet Explorer 9+: Full support
## Best Practices
1. **Use SVG for**:
- Logos and brand assets
- Icons and symbols
- Simple illustrations
- Graphics that need to scale
2. **Use raster images for**:
- Photographs
- Complex graphics with many colors
- Images from external sources
3. **Optimize SVG files**:
- Remove unnecessary metadata
- Use simple shapes when possible
- Consider using SVG optimization tools
## Troubleshooting
### SVG Not Displaying
- Check that the SVG file is valid XML
- Ensure the file has proper width/height or viewBox attributes
- Verify the file size is under the limit (1MB)
### Wrong Dimensions
- SVG dimensions are extracted from width/height attributes or viewBox
- If dimensions appear wrong, check the SVG source
- Use the `update_image_properties` command to refresh dimensions
### Upload Errors
- Ensure the file is valid SVG format
- Check file size limits
- Verify the file contains valid XML structure

View file

@ -6,11 +6,12 @@ from urllib.parse import urlparse
class PrimaryDomainRedirectMiddleware:
def __init__(self, get_response):
self.get_response = get_response
# Parse the primary hostname from WEBSITE_URL
self.primary_host = urlparse(settings.WEBSITE_URL).netloc
self.disable_redirect = settings.DISABLE_REDIRECT
def __call__(self, request):
if settings.DEBUG or self.disable_redirect:
# Skip redirects in DEBUG mode
if settings.DEBUG:
return self.get_response(request)
# Check if the host is different from the primary host

View file

@ -4,9 +4,7 @@
from .articles import *
from .base import *
from .content import *
from .images import *
from .leads import *
from .pricing import *
from .providers import *
from .widgets import *
from .services import *

View file

@ -7,8 +7,8 @@ from django.utils.html import format_html
from django import forms
from django.core.exceptions import ValidationError
from ..models import Article
from .widgets import ImageLibraryWidget
class ArticleAdminForm(forms.ModelForm):
@ -17,9 +17,6 @@ class ArticleAdminForm(forms.ModelForm):
class Meta:
model = Article
fields = "__all__"
widgets = {
"image_library": ImageLibraryWidget(),
}
def clean_title(self):
"""Validate title length"""
@ -48,7 +45,7 @@ class ArticleAdmin(admin.ModelAdmin):
"image_preview",
"is_published",
"is_featured",
"article_date",
"created_at",
)
list_filter = (
"is_published",
@ -57,51 +54,18 @@ class ArticleAdmin(admin.ModelAdmin):
"related_service",
"related_consulting_partner",
"related_cloud_provider",
"article_date",
"created_at",
)
search_fields = ("title", "excerpt", "content", "meta_keywords")
prepopulated_fields = {"slug": ("title",)}
readonly_fields = ("created_at", "updated_at")
ordering = ("-article_date",)
fieldsets = (
(None, {"fields": ("title", "slug", "excerpt", "content", "meta_keywords")}),
(
"Images",
{
"fields": ("image_library", "og_image"),
"description": "Select an image from the Image Library and optionally upload a specific Open Graph image for social sharing.",
},
),
(
"Publishing",
{"fields": ("author", "article_date", "is_published", "is_featured")},
),
(
"Relations",
{
"fields": (
"related_service",
"related_consulting_partner",
"related_cloud_provider",
),
"classes": ("collapse",),
},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
def image_preview(self, obj):
"""Display image preview in admin list view"""
image = obj.get_image
if image:
return format_html('<img src="{}" style="max-height: 50px;"/>', image.url)
if obj.image:
return format_html(
'<img src="{}" style="max-height: 50px;"/>', obj.image.url
)
return "No image"
image_preview.short_description = "Image"

View file

@ -13,7 +13,7 @@ class PlanInline(admin.StackedInline):
model = Plan
extra = 1
fieldsets = (
(None, {"fields": ("name", "description", "pricing", "plan_description")}),
(None, {"fields": ("name", "description", "plan_description")}),
)

View file

@ -1,130 +0,0 @@
from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from django.utils.safestring import mark_safe
from ..models.images import ImageLibrary
@admin.register(ImageLibrary)
class ImageLibraryAdmin(admin.ModelAdmin):
"""
Admin interface for the Image Library.
"""
list_display = [
"image_thumbnail",
"name",
"category",
"get_dimensions",
"get_file_size_display",
"usage_count",
"uploaded_by",
"uploaded_at",
]
list_filter = [
"category",
"uploaded_at",
"uploaded_by",
]
search_fields = [
"name",
"description",
"alt_text",
"tags",
]
readonly_fields = [
"width",
"height",
"file_size",
"usage_count",
"uploaded_at",
"updated_at",
"image_preview",
]
prepopulated_fields = {"slug": ("name",)}
fieldsets = (
("Image Information", {"fields": ("name", "slug", "description", "alt_text")}),
("Image File", {"fields": ("image", "image_preview")}),
("Categorization", {"fields": ("category", "tags")}),
(
"Metadata",
{
"fields": ("width", "height", "file_size", "usage_count"),
"classes": ("collapse",),
},
),
(
"Timestamps",
{
"fields": ("uploaded_by", "uploaded_at", "updated_at"),
"classes": ("collapse",),
},
),
)
def image_thumbnail(self, obj):
"""
Display small thumbnail in list view.
"""
if obj.image:
# Use img tag for all images in list view to maintain clickability
# SVG files will still display correctly with img tag
return format_html(
'<img src="{}" width="50" height="50" style="object-fit: cover; border-radius: 4px;" />',
obj.image.url,
)
return "No Image"
image_thumbnail.short_description = "Thumbnail"
def image_preview(self, obj):
"""
Display larger preview in detail view.
"""
if obj.image:
if obj.is_svg():
# For SVG files in detail view, use object tag for better rendering
# This is only for display, not for clickable elements
return format_html(
'<div style="pointer-events: none;">'
'<object data="{}" type="image/svg+xml" style="max-width: 300px; max-height: 300px; border-radius: 4px; background: #f5f5f5;">'
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />'
"</object>"
"</div>",
obj.image.url,
obj.image.url,
)
else:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />',
obj.image.url,
)
return "No Image"
image_preview.short_description = "Preview"
def get_dimensions(self, obj):
"""
Display image dimensions.
"""
if obj.width and obj.height:
return f"{obj.width} × {obj.height}"
return "Unknown"
get_dimensions.short_description = "Dimensions"
def save_model(self, request, obj, form, change):
"""
Set uploaded_by field to current user if not already set.
"""
if not change: # Only set on creation
obj.uploaded_by = request.user
super().save_model(request, obj, form, change)
class Media:
css = {"all": ("admin/css/image_library.css",)}

View file

@ -322,26 +322,10 @@ class DiscountTierInline(admin.TabularInline):
class ProgressiveDiscountModelAdmin(admin.ModelAdmin):
"""Admin configuration for ProgressiveDiscountModel"""
list_display = ("name", "description", "active", "admin_display_discount_tiers")
list_display = ("name", "description", "active")
search_fields = ("name", "description")
inlines = [DiscountTierInline]
def admin_display_discount_tiers(self, obj):
"""Display discount tiers in admin list view"""
tiers = obj.tiers.all().order_by("min_units")
if not tiers:
return "No discount tiers"
return format_html(
"<br>".join(
[
f"{tier.min_units}-{tier.max_units if tier.max_units else ''} units: {tier.discount_percent}%"
for tier in tiers
]
)
)
admin_display_discount_tiers.short_description = "Discount Tiers"
@admin.register(VSHNAppCatPrice)
class VSHNAppCatPriceAdmin(admin.ModelAdmin):
@ -366,12 +350,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
if not fees:
return "No base fees"
return format_html(
"<br>".join(
[
f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})"
for fee in fees
]
)
"<br>".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees])
)
admin_display_base_fees.short_description = "Base Fees"
@ -639,12 +618,7 @@ class VSHNAppCatAddonAdmin(admin.ModelAdmin):
if not fees:
return "No base fees set"
return format_html(
"<br>".join(
[
f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})"
for fee in fees
]
)
"<br>".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees])
)
elif obj.addon_type == "UR": # Unit Rate
rates = obj.unit_rates.all()

View file

@ -4,33 +4,9 @@ Admin classes for cloud providers and consulting partners
from django.contrib import admin
from django.utils.html import format_html
from django import forms
from adminsortable2.admin import SortableAdminMixin
from ..models import CloudProvider, ConsultingPartner, ServiceOffering
from .widgets import ImageLibraryWidget
class CloudProviderAdminForm(forms.ModelForm):
"""Custom form for CloudProvider admin with image widget"""
class Meta:
model = CloudProvider
fields = "__all__"
widgets = {
"image_library": ImageLibraryWidget(),
}
class ConsultingPartnerAdminForm(forms.ModelForm):
"""Custom form for ConsultingPartner admin with image widget"""
class Meta:
model = ConsultingPartner
fields = "__all__"
widgets = {
"image_library": ImageLibraryWidget(),
}
class OfferingInline(admin.StackedInline):
@ -58,8 +34,6 @@ class OfferingInline(admin.StackedInline):
class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
"""Admin configuration for CloudProvider model"""
form = CloudProviderAdminForm
list_display = (
"name",
"slug",
@ -73,27 +47,12 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
inlines = [OfferingInline]
ordering = ("order",)
fieldsets = (
(None, {"fields": ("name", "slug", "description", "order")}),
(
"Images",
{
"fields": ("image_library",),
"description": "Select an image from the Image Library.",
},
),
(
"Contact Information",
{"fields": ("website", "linkedin", "phone", "email", "address")},
),
("Settings", {"fields": ("is_featured", "disable_listing")}),
)
def logo_preview(self, obj):
"""Display logo preview in admin list view"""
logo = obj.get_logo
if logo:
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
if obj.logo:
return format_html(
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
)
return "No logo"
logo_preview.short_description = "Logo"
@ -103,11 +62,8 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin):
"""Admin configuration for ConsultingPartner model"""
form = ConsultingPartnerAdminForm
list_display = (
"name",
"category",
"website",
"logo_preview",
"disable_listing",
@ -115,36 +71,16 @@ class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin):
"order",
)
search_fields = ("name", "description")
list_filter = ("category", "is_featured", "disable_listing")
prepopulated_fields = {"slug": ("name",)}
filter_horizontal = ("services", "cloud_providers")
ordering = ("order",)
fieldsets = (
(None, {"fields": ("name", "slug", "description", "category", "order")}),
(
"Images",
{
"fields": ("image_library",),
"description": "Select an image from the Image Library.",
},
),
(
"Contact Information",
{"fields": ("website", "linkedin", "phone", "email", "address")},
),
(
"Relations",
{"fields": ("services", "cloud_providers"), "classes": ("collapse",)},
),
("Settings", {"fields": ("is_featured", "disable_listing")}),
)
def logo_preview(self, obj):
"""Display logo preview in admin list view"""
logo = obj.get_logo
if logo:
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
if obj.logo:
return format_html(
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
)
return "No logo"
logo_preview.short_description = "Logo"

View file

@ -4,28 +4,8 @@ Admin classes for services and service offerings
from django.contrib import admin
from django.utils.html import format_html
from django import forms
from ..models import (
Service,
ServiceOffering,
ExternalLink,
ExternalLinkOffering,
Plan,
PlanPrice,
)
from .widgets import ImageLibraryWidget
class ServiceAdminForm(forms.ModelForm):
"""Custom form for Service admin with image widget"""
class Meta:
model = Service
fields = "__all__"
widgets = {
"image_library": ImageLibraryWidget(),
}
from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan, PlanPrice
class ExternalLinkInline(admin.TabularInline):
@ -46,25 +26,14 @@ class ExternalLinkOfferingInline(admin.TabularInline):
ordering = ("order", "description")
class PlanPriceInline(admin.TabularInline):
"""Inline admin for PlanPrice model"""
model = PlanPrice
extra = 1
fields = ("currency", "amount")
ordering = ("currency",)
class PlanInline(admin.StackedInline):
"""Inline admin for Plan model with sortable ordering"""
"""Inline admin for Plan model"""
model = Plan
extra = 1
fieldsets = (
(None, {"fields": ("name", "description", "plan_description")}),
("Display Options", {"fields": ("is_best", "order")}),
)
show_change_link = True
class OfferingInline(admin.StackedInline):
@ -88,12 +57,22 @@ class OfferingInline(admin.StackedInline):
show_change_link = True
class PlanPriceInline(admin.TabularInline):
model = PlanPrice
extra = 1
class PlanAdmin(admin.ModelAdmin):
inlines = [PlanPriceInline]
list_display = ("name", "offering")
search_fields = ("name",)
list_filter = ("offering",)
@admin.register(Service)
class ServiceAdmin(admin.ModelAdmin):
"""Admin configuration for Service model"""
form = ServiceAdminForm
list_display = (
"name",
"logo_preview",
@ -108,34 +87,12 @@ class ServiceAdmin(admin.ModelAdmin):
filter_horizontal = ("categories",)
inlines = [ExternalLinkInline, OfferingInline]
fieldsets = (
(None, {"fields": ("name", "slug", "description", "tagline")}),
(
"Images",
{
"fields": ("image_library",),
"description": "Select an image from the Image Library.",
},
),
(
"Configuration",
{
"fields": (
"categories",
"features",
"is_featured",
"is_coming_soon",
"disable_listing",
)
},
),
)
def logo_preview(self, obj):
"""Display logo preview in admin list view"""
logo = obj.get_logo
if logo:
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
if obj.logo:
return format_html(
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
)
return "No logo"
logo_preview.short_description = "Logo"
@ -157,59 +114,11 @@ class ServiceAdmin(admin.ModelAdmin):
class ServiceOfferingAdmin(admin.ModelAdmin):
"""Admin configuration for ServiceOffering model"""
list_display = ("service", "cloud_provider", "plan_count", "total_prices")
list_display = ("service", "cloud_provider")
list_filter = ("service", "cloud_provider")
search_fields = ("service__name", "cloud_provider__name", "description")
inlines = [ExternalLinkOfferingInline, PlanInline]
def plan_count(self, obj):
"""Display number of plans for this offering"""
return obj.plans.count()
plan_count.short_description = "Plans"
def total_prices(self, obj):
"""Display total number of plan prices for this offering"""
total = sum(plan.plan_prices.count() for plan in obj.plans.all())
return f"{total} prices"
total_prices.short_description = "Total Prices"
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
"""Admin configuration for Plan model with sortable ordering"""
list_display = ("name", "offering", "is_best", "price_summary", "order")
list_filter = ("offering__service", "offering__cloud_provider", "is_best")
search_fields = ("name", "description", "offering__service__name")
list_editable = ("is_best",)
inlines = [PlanPriceInline]
fieldsets = (
(None, {"fields": ("name", "offering", "description", "plan_description")}),
("Display Options", {"fields": ("is_best", "order")}),
)
def price_summary(self, obj):
"""Display a summary of prices for this plan"""
prices = obj.plan_prices.all()
if prices:
price_strs = [f"{price.amount} {price.currency}" for price in prices]
return ", ".join(price_strs)
return "No prices set"
price_summary.short_description = "Prices"
@admin.register(PlanPrice)
class PlanPriceAdmin(admin.ModelAdmin):
"""Admin configuration for PlanPrice model"""
list_display = ("plan", "currency", "amount")
list_filter = (
"currency",
"plan__offering__service",
"plan__offering__cloud_provider",
)
search_fields = ("plan__name", "plan__offering__service__name")
ordering = ("plan__offering__service__name", "plan__name", "currency")
admin.site.register(Plan, PlanAdmin)
admin.site.register(PlanPrice)

View file

@ -1,232 +0,0 @@
"""
Custom widgets for Django admin interface
"""
from django import forms
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.urls import reverse
from django.conf import settings
from ..models import ImageLibrary
class ImageLibraryWidget(forms.Select):
"""Custom widget for selecting images from the library with visual preview"""
def __init__(self, attrs=None):
super().__init__(attrs)
self.attrs.update(
{
"class": "image-library-select",
"style": "display: none;", # Hide the original select
}
)
def render(self, name, value, attrs=None, renderer=None):
"""Render the widget with image previews"""
# Get the original select element
original_select = super().render(name, value, attrs, renderer)
# Get all images from the library
images = ImageLibrary.objects.all().order_by("-uploaded_at")
# Create the visual interface
html_parts = [
'<div class="image-library-widget">',
original_select, # Keep the original select for form submission
'<div class="image-library-grid">',
]
# Add "No image" option
no_image_selected = "selected" if not value else ""
html_parts.append(
f"""
<div class="image-option {no_image_selected}" data-value="">
<div class="image-preview no-image">
<i class="fas fa-ban"></i>
<span>No image</span>
</div>
<div class="image-info">
<span class="image-name">No image selected</span>
</div>
</div>
"""
)
# Add each image as an option
for image in images:
selected = "selected" if str(image.pk) == str(value) else ""
image_url = image.image.url if image.image else ""
# Use img tag for all images in widget to maintain clickability
# SVG files will still display correctly with img tag
preview_html = (
f'<img src="{image_url}" alt="{image.alt_text}" loading="lazy">'
)
html_parts.append(
f"""
<div class="image-option {selected}" data-value="{image.pk}">
<div class="image-preview">
{preview_html}
</div>
<div class="image-info">
<span class="image-name">{image.name}</span>
<span class="image-category">{image.get_category_display()}</span>
<span class="image-size">{image.width}x{image.height}</span>
</div>
</div>
"""
)
html_parts.extend(
[
"</div>",
"</div>",
self._get_styles(),
self._get_javascript(),
]
)
return mark_safe("".join(html_parts))
def _get_styles(self):
"""Return CSS styles for the widget"""
return """
<style>
.image-library-widget {
margin: 10px 0;
}
.image-library-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
max-height: 800px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.image-option {
background: white;
border: 2px solid #ddd;
border-radius: 5px;
padding: 10px;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.image-option:hover {
border-color: #007cba;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.image-option.selected {
border-color: #007cba;
background: #e3f2fd;
box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.2);
}
.image-preview {
width: 100%;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
border-radius: 3px;
overflow: hidden;
background: #f5f5f5;
}
.image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: cover;
border-radius: 3px;
}
.image-preview.no-image {
background: #f0f0f0;
color: #666;
flex-direction: column;
}
.image-preview.no-image i {
font-size: 24px;
margin-bottom: 5px;
}
.image-info {
text-align: left;
}
.image-name {
display: block;
font-weight: 600;
font-size: 12px;
color: #333;
margin-bottom: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-category {
display: inline-block;
background: #e0e0e0;
color: #666;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
margin-right: 5px;
}
.image-size {
font-size: 10px;
color: #666;
}
</style>
"""
def _get_javascript(self):
"""Return JavaScript for the widget functionality"""
return """
<script>
document.addEventListener('DOMContentLoaded', function() {
// Handle image selection
document.querySelectorAll('.image-option').forEach(function(option) {
option.addEventListener('click', function() {
const widget = this.closest('.image-library-widget');
const select = widget.querySelector('.image-library-select');
const value = this.dataset.value;
// Update the hidden select
select.value = value;
// Update visual selection
widget.querySelectorAll('.image-option').forEach(function(opt) {
opt.classList.remove('selected');
});
this.classList.add('selected');
// Trigger change event
select.dispatchEvent(new Event('change'));
});
});
});
</script>
"""
class Media:
css = {
"all": (
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css",
)
}

View file

@ -1,80 +0,0 @@
"""
RSS Feeds for the Servala website
"""
from django.contrib.syndication.views import Feed
from django.urls import reverse
from django.utils.feedgenerator import Rss201rev2Feed
from django.utils.html import strip_tags
from django.conf import settings
from .models import Article
class ArticleRSSFeed(Feed):
"""RSS feed for published articles"""
title = "Servala Articles"
link = "/articles/"
description = "Latest articles about cloud services, consulting partners, and technology insights from Servala"
feed_type = Rss201rev2Feed
def items(self):
"""Return the latest 20 published articles"""
return Article.objects.filter(is_published=True).order_by("-article_date")[:20]
def item_title(self, item):
"""Return the article title"""
return item.title
def item_description(self, item):
"""Return the article excerpt with 'Read more' link"""
base_url = "https://servala.com"
# Use the excerpt and add a proper HTML read more link
excerpt = strip_tags(item.excerpt)
article_url = f"{base_url}{item.get_absolute_url()}"
# Return HTML content for the RSS description
return f'{excerpt} <a href="{article_url}">Read more...</a>'
def item_link(self, item):
"""Return the link to the article detail page"""
return item.get_absolute_url()
def item_guid(self, item):
"""Return a unique identifier for the item"""
return f"article-{item.id}"
def item_pubdate(self, item):
"""Return the publication date"""
# Convert date to datetime for RSS compatibility
from datetime import datetime, time
from django.utils import timezone
# Combine the date with midnight time and make it timezone-aware
dt = datetime.combine(item.article_date, time.min)
return timezone.make_aware(dt, timezone.get_current_timezone())
def item_author_name(self, item):
"""Return the author name"""
return item.author.get_full_name() or item.author.username
def item_categories(self, item):
"""Return categories for the article"""
categories = []
# Add related entity as category
if item.related_service:
categories.append(f"Service: {item.related_service.name}")
if item.related_consulting_partner:
categories.append(f"Partner: {item.related_consulting_partner.name}")
if item.related_cloud_provider:
categories.append(f"Provider: {item.related_cloud_provider.name}")
# Add meta keywords as categories if available
if item.meta_keywords:
keywords = [keyword.strip() for keyword in item.meta_keywords.split(",")]
categories.extend(keywords)
return categories

View file

@ -1,5 +1,5 @@
from django import forms
from ..models import Lead
from .models import Lead, Plan
class LeadForm(forms.ModelForm):

View file

@ -1,2 +0,0 @@
from .lead import LeadForm
from .image_library import ImageLibraryField, ImageLibraryWidget

View file

@ -1,156 +0,0 @@
from django import forms
from django.utils.safestring import mark_safe
from django.utils.html import format_html
from django.urls import reverse
from ..models.images import ImageLibrary
class ImageLibraryWidget(forms.Select):
"""
Custom widget for selecting images from the library with thumbnails.
"""
def __init__(self, attrs=None, choices=(), show_thumbnails=True):
self.show_thumbnails = show_thumbnails
super().__init__(attrs, choices)
def format_value(self, value):
"""
Format the selected value for display.
"""
if value is None:
return ""
return str(value)
def render(self, name, value, attrs=None, renderer=None):
"""
Render the widget with thumbnails.
"""
if attrs is None:
attrs = {}
# Add CSS class for styling
attrs["class"] = attrs.get("class", "") + " image-library-select"
# Get all images for the select options
images = ImageLibrary.objects.all().order_by("name")
# Build choices with thumbnails
choices = [("", "--- Select an image ---")]
for image in images:
thumbnail_html = ""
if self.show_thumbnails and image.image:
# Use img tag for all images in dropdowns to maintain functionality
# SVG files will still display correctly with img tag
thumbnail_html = format_html(
' <img src="{}" style="width: 20px; height: 20px; object-fit: cover; margin-left: 5px; vertical-align: middle;" />',
image.image.url,
)
choice_text = (
f"{image.name} ({image.get_category_display()}){thumbnail_html}"
)
choices.append((image.pk, choice_text))
# Build the select element
select_html = format_html(
'<select name="{}" id="{}"{}>{}</select>',
name,
attrs.get("id", ""),
self._build_attrs_string(attrs),
self._build_options(choices, value),
)
# Add preview area
preview_html = ""
if value:
try:
image = ImageLibrary.objects.get(pk=value)
# Use img tag for all images in preview for consistency
# Add SVG indicator in the text if it's an SVG file
svg_indicator = " (SVG)" if image.is_svg() else ""
preview_html = format_html(
'<div class="image-preview" style="margin-top: 10px;">'
'<img src="{}" style="max-width: 200px; max-height: 200px; border: 1px solid #ddd; border-radius: 4px;" />'
'<p style="margin-top: 5px; font-size: 12px; color: #666;">{} - {}x{} - {}{}</p>'
"</div>",
image.image.url,
image.name,
image.width or "?",
image.height or "?",
image.get_file_size_display(),
svg_indicator,
)
except ImageLibrary.DoesNotExist:
pass
# Add JavaScript for preview updates
js_html = format_html(
"<script>"
'document.addEventListener("DOMContentLoaded", function() {{'
' const select = document.getElementById("{}");\n'
' const previewDiv = select.parentNode.querySelector(".image-preview");\n'
' select.addEventListener("change", function() {{'
" const imageId = this.value;\n"
" if (imageId) {{"
' fetch("/admin/services/imagelibrary/" + imageId + "/preview/")'
" .then(response => response.json())"
" .then(data => {{"
" if (previewDiv) {{"
" previewDiv.innerHTML = data.html;\n"
" }}"
" }});\n"
" }} else {{"
" if (previewDiv) {{"
' previewDiv.innerHTML = "";\n'
" }}"
" }}"
" }});\n"
"}});\n"
"</script>",
attrs.get("id", ""),
)
return mark_safe(select_html + preview_html + js_html)
def _build_attrs_string(self, attrs):
"""
Build HTML attributes string.
"""
attr_parts = []
for key, value in attrs.items():
if key != "id": # id is handled separately
attr_parts.append(f'{key}="{value}"')
return " " + " ".join(attr_parts) if attr_parts else ""
def _build_options(self, choices, selected_value):
"""
Build option elements for the select.
"""
options = []
for value, text in choices:
selected = "selected" if str(value) == str(selected_value) else ""
options.append(f'<option value="{value}" {selected}>{text}</option>')
return "".join(options)
class ImageLibraryField(forms.ModelChoiceField):
"""
Custom form field for selecting images from the library.
"""
def __init__(self, queryset=None, widget=None, show_thumbnails=True, **kwargs):
if queryset is None:
queryset = ImageLibrary.objects.all()
if widget is None:
widget = ImageLibraryWidget(show_thumbnails=show_thumbnails)
super().__init__(queryset=queryset, widget=widget, **kwargs)
def label_from_instance(self, obj):
"""
Return the label for an image instance.
"""
return f"{obj.name} ({obj.get_category_display()})"

View file

@ -1,32 +0,0 @@
from django.core.management.base import BaseCommand
from django.core.management import call_command
class Command(BaseCommand):
help = "Build and compress static assets for production"
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Force compression even if files exist",
)
def handle(self, *args, **options):
self.stdout.write("Building static assets...")
# Compress CSS and JS files
self.stdout.write("Compressing CSS and JavaScript...")
call_command(
"compress",
force=options.get("force", False),
verbosity=options.get("verbosity", 1),
)
# Collect all static files
self.stdout.write("Collecting static files...")
call_command(
"collectstatic", interactive=False, verbosity=options.get("verbosity", 1)
)
self.stdout.write(self.style.SUCCESS("Successfully built static assets"))

View file

@ -1,293 +0,0 @@
from django.core.management.base import BaseCommand
from django.core.files.base import ContentFile
from django.utils.text import slugify
from hub.services.models import (
ImageLibrary,
Service,
CloudProvider,
ConsultingPartner,
Article,
)
import os
import shutil
class Command(BaseCommand):
help = "Migrate existing images to the Image Library"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be migrated without actually doing it",
)
parser.add_argument(
"--force",
action="store_true",
help="Force migration even if images already exist in library",
)
def handle(self, *args, **options):
"""
Main command handler to migrate existing images to the library.
"""
dry_run = options["dry_run"]
force = options["force"]
self.stdout.write(
self.style.SUCCESS(
f'Starting image migration {"(DRY RUN)" if dry_run else ""}'
)
)
# Migrate different types of images
self.migrate_service_logos(dry_run, force)
self.migrate_cloud_provider_logos(dry_run, force)
self.migrate_partner_logos(dry_run, force)
self.migrate_article_images(dry_run, force)
self.stdout.write(
self.style.SUCCESS(
f'Image migration completed {"(DRY RUN)" if dry_run else ""}'
)
)
def migrate_service_logos(self, dry_run, force):
"""
Migrate service logos to the image library.
"""
self.stdout.write("Migrating service logos...")
services = Service.objects.filter(logo__isnull=False).exclude(logo="")
for service in services:
if not service.logo:
continue
# Check if image already exists in library
existing_image = ImageLibrary.objects.filter(
name=f"{service.name} Logo"
).first()
if existing_image and not force:
self.stdout.write(
self.style.WARNING(
f" - Skipping {service.name} logo (already exists)"
)
)
continue
if dry_run:
self.stdout.write(
self.style.SUCCESS(f" - Would migrate: {service.name} logo")
)
continue
# Create image library entry
image_lib = ImageLibrary(
name=f"{service.name} Logo",
slug=slugify(f"{service.name}-logo"),
description=f"Logo for {service.name} service",
alt_text=f"{service.name} logo",
category="logo",
tags=f"service, logo, {service.name.lower()}",
)
# Copy the image file
if service.logo and os.path.exists(service.logo.path):
with open(service.logo.path, "rb") as f:
image_lib.image.save(
os.path.basename(service.logo.name),
ContentFile(f.read()),
save=True,
)
self.stdout.write(
self.style.SUCCESS(f" - Migrated: {service.name} logo")
)
else:
self.stdout.write(
self.style.ERROR(
f" - Failed to migrate: {service.name} logo (file not found)"
)
)
def migrate_cloud_provider_logos(self, dry_run, force):
"""
Migrate cloud provider logos to the image library.
"""
self.stdout.write("Migrating cloud provider logos...")
providers = CloudProvider.objects.filter(logo__isnull=False).exclude(logo="")
for provider in providers:
if not provider.logo:
continue
# Check if image already exists in library
existing_image = ImageLibrary.objects.filter(
name=f"{provider.name} Logo"
).first()
if existing_image and not force:
self.stdout.write(
self.style.WARNING(
f" - Skipping {provider.name} logo (already exists)"
)
)
continue
if dry_run:
self.stdout.write(
self.style.SUCCESS(f" - Would migrate: {provider.name} logo")
)
continue
# Create image library entry
image_lib = ImageLibrary(
name=f"{provider.name} Logo",
slug=slugify(f"{provider.name}-logo"),
description=f"Logo for {provider.name} cloud provider",
alt_text=f"{provider.name} logo",
category="logo",
tags=f"cloud, provider, logo, {provider.name.lower()}",
)
# Copy the image file
if provider.logo and os.path.exists(provider.logo.path):
with open(provider.logo.path, "rb") as f:
image_lib.image.save(
os.path.basename(provider.logo.name),
ContentFile(f.read()),
save=True,
)
self.stdout.write(
self.style.SUCCESS(f" - Migrated: {provider.name} logo")
)
else:
self.stdout.write(
self.style.ERROR(
f" - Failed to migrate: {provider.name} logo (file not found)"
)
)
def migrate_partner_logos(self, dry_run, force):
"""
Migrate consulting partner logos to the image library.
"""
self.stdout.write("Migrating consulting partner logos...")
partners = ConsultingPartner.objects.filter(logo__isnull=False).exclude(logo="")
for partner in partners:
if not partner.logo:
continue
# Check if image already exists in library
existing_image = ImageLibrary.objects.filter(
name=f"{partner.name} Logo"
).first()
if existing_image and not force:
self.stdout.write(
self.style.WARNING(
f" - Skipping {partner.name} logo (already exists)"
)
)
continue
if dry_run:
self.stdout.write(
self.style.SUCCESS(f" - Would migrate: {partner.name} logo")
)
continue
# Create image library entry
image_lib = ImageLibrary(
name=f"{partner.name} Logo",
slug=slugify(f"{partner.name}-logo"),
description=f"Logo for {partner.name} consulting partner",
alt_text=f"{partner.name} logo",
category="logo",
tags=f"consulting, partner, logo, {partner.name.lower()}",
)
# Copy the image file
if partner.logo and os.path.exists(partner.logo.path):
with open(partner.logo.path, "rb") as f:
image_lib.image.save(
os.path.basename(partner.logo.name),
ContentFile(f.read()),
save=True,
)
self.stdout.write(
self.style.SUCCESS(f" - Migrated: {partner.name} logo")
)
else:
self.stdout.write(
self.style.ERROR(
f" - Failed to migrate: {partner.name} logo (file not found)"
)
)
def migrate_article_images(self, dry_run, force):
"""
Migrate article images to the image library.
"""
self.stdout.write("Migrating article images...")
articles = Article.objects.filter(image__isnull=False).exclude(image="")
for article in articles:
if not article.image:
continue
# Check if image already exists in library
existing_image = ImageLibrary.objects.filter(
name=f"{article.title} Image"
).first()
if existing_image and not force:
self.stdout.write(
self.style.WARNING(
f" - Skipping {article.title} image (already exists)"
)
)
continue
if dry_run:
self.stdout.write(
self.style.SUCCESS(f" - Would migrate: {article.title} image")
)
continue
# Create image library entry
image_lib = ImageLibrary(
name=f"{article.title} Image",
slug=slugify(f"{article.title}-image"),
description=f"Feature image for article: {article.title}",
alt_text=f"{article.title} feature image",
category="article",
tags=f"article, {article.title.lower()}",
)
# Copy the image file
if article.image and os.path.exists(article.image.path):
with open(article.image.path, "rb") as f:
image_lib.image.save(
os.path.basename(article.image.name),
ContentFile(f.read()),
save=True,
)
self.stdout.write(
self.style.SUCCESS(f" - Migrated: {article.title} image")
)
else:
self.stdout.write(
self.style.ERROR(
f" - Failed to migrate: {article.title} image (file not found)"
)
)

View file

@ -1,40 +0,0 @@
from django.core.management.base import BaseCommand
from hub.services.models.images import ImageLibrary
class Command(BaseCommand):
help = "Update image properties for existing images in the library"
def handle(self, *args, **options):
"""
Update image properties for all images in the library.
This is especially useful after adding SVG support.
"""
images = ImageLibrary.objects.all()
updated_count = 0
error_count = 0
self.stdout.write(f"Updating properties for {images.count()} images...")
for image in images:
try:
# Force update of image properties
image._update_image_properties()
updated_count += 1
# Show progress
if updated_count % 10 == 0:
self.stdout.write(f"Updated {updated_count} images...")
except Exception as e:
error_count += 1
self.stdout.write(
self.style.ERROR(f"Error updating {image.name}: {str(e)}")
)
self.stdout.write(
self.style.SUCCESS(
f"Successfully updated {updated_count} images. "
f"Errors: {error_count}"
)
)

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2 on 2025-06-23 07:58
# Generated by Django 5.2 on 2025-06-20 15:28
import django.db.models.deletion
from django.db import migrations, models

View file

@ -1,32 +0,0 @@
# Generated by Django 5.2 on 2025-06-23 10:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0037_remove_plan_pricing_planprice"),
]
operations = [
migrations.AlterModelOptions(
name="plan",
options={"ordering": ["order", "name"]},
),
migrations.AddField(
model_name="plan",
name="is_best",
field=models.BooleanField(
default=False, help_text="Mark this plan as the best/recommended option"
),
),
migrations.AddField(
model_name="plan",
name="order",
field=models.PositiveIntegerField(
default=0,
help_text="Order of this plan in the offering (lower numbers appear first)",
),
),
]

View file

@ -1,22 +0,0 @@
# Generated by Django 5.2 on 2025-07-04 13:48
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0038_add_plan_ordering_and_best"),
]
operations = [
migrations.AddField(
model_name="article",
name="article_date",
field=models.DateField(
default=django.utils.timezone.now,
help_text="Date of the article publishing",
),
),
]

View file

@ -1,144 +0,0 @@
# Generated by Django 5.2 on 2025-07-04 14:19
import django.db.models.deletion
import hub.services.models.base
import hub.services.models.images
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0039_article_article_date"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ImageLibrary",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(
help_text="Descriptive name for the image", max_length=200
),
),
(
"slug",
models.SlugField(
help_text="URL-friendly version of the name",
max_length=250,
unique=True,
),
),
(
"description",
models.TextField(
blank=True, help_text="Optional description of the image"
),
),
(
"alt_text",
models.CharField(
help_text="Alternative text for accessibility", max_length=255
),
),
(
"image",
models.ImageField(
help_text="Upload image file (max 1MB)",
upload_to=hub.services.models.images.get_image_upload_path,
validators=[hub.services.models.base.validate_image_size],
),
),
(
"width",
models.PositiveIntegerField(
blank=True, help_text="Image width in pixels", null=True
),
),
(
"height",
models.PositiveIntegerField(
blank=True, help_text="Image height in pixels", null=True
),
),
(
"file_size",
models.PositiveIntegerField(
blank=True, help_text="File size in bytes", null=True
),
),
(
"category",
models.CharField(
choices=[
("logo", "Logo"),
("article", "Article Image"),
("banner", "Banner"),
("icon", "Icon"),
("screenshot", "Screenshot"),
("photo", "Photo"),
("other", "Other"),
],
default="other",
help_text="Category of the image",
max_length=20,
),
),
(
"tags",
models.CharField(
blank=True,
help_text="Comma-separated tags for searching",
max_length=500,
),
),
(
"uploaded_at",
models.DateTimeField(
auto_now_add=True,
help_text="Date and time when image was uploaded",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="Date and time when image was last updated",
),
),
(
"usage_count",
models.PositiveIntegerField(
default=0, help_text="Number of times this image is referenced"
),
),
(
"uploaded_by",
models.ForeignKey(
blank=True,
help_text="User who uploaded the image",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Image",
"verbose_name_plural": "Image Library",
"ordering": ["-uploaded_at"],
},
),
]

View file

@ -1,57 +0,0 @@
# Generated by Django 5.2 on 2025-07-04 15:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0040_add_image_library"),
]
operations = [
migrations.AddField(
model_name="cloudprovider",
name="image",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="services.imagelibrary",
),
),
migrations.AddField(
model_name="consultingpartner",
name="image",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="services.imagelibrary",
),
),
migrations.AddField(
model_name="service",
name="image",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="services.imagelibrary",
),
),
migrations.AlterField(
model_name="article",
name="image",
field=models.ImageField(
blank=True,
help_text="Title picture for the article",
null=True,
upload_to="article_images/",
),
),
]

View file

@ -1,74 +0,0 @@
# Generated by Django 5.2 on 2025-07-04 15:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0041_add_image_library_references"),
]
operations = [
migrations.RemoveField(
model_name="cloudprovider",
name="image",
),
migrations.RemoveField(
model_name="consultingpartner",
name="image",
),
migrations.RemoveField(
model_name="service",
name="image",
),
migrations.AddField(
model_name="article",
name="image_library",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_references",
to="services.imagelibrary",
),
),
migrations.AddField(
model_name="cloudprovider",
name="image_library",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_references",
to="services.imagelibrary",
),
),
migrations.AddField(
model_name="consultingpartner",
name="image_library",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_references",
to="services.imagelibrary",
),
),
migrations.AddField(
model_name="service",
name="image_library",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_references",
to="services.imagelibrary",
),
),
]

View file

@ -1,29 +0,0 @@
# Generated by Django 5.2 on 2025-07-08 09:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("services", "0042_fix_image_library_field_name"),
]
operations = [
migrations.RemoveField(
model_name="article",
name="image",
),
migrations.RemoveField(
model_name="cloudprovider",
name="logo",
),
migrations.RemoveField(
model_name="consultingpartner",
name="logo",
),
migrations.RemoveField(
model_name="service",
name="logo",
),
]

View file

@ -1,24 +0,0 @@
# Generated by Django 5.2 on 2025-07-08 10:51
import hub.services.models.base
import hub.services.models.images
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0043_remove_article_image_remove_cloudprovider_logo_and_more"),
]
operations = [
migrations.AlterField(
model_name="imagelibrary",
name="image",
field=models.FileField(
help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG",
upload_to=hub.services.models.images.get_image_upload_path,
validators=[hub.services.models.base.validate_image_or_svg],
),
),
]

View file

@ -1,25 +0,0 @@
# Generated by Django 5.2 on 2025-07-08 13:53
import hub.services.models.base
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0044_add_svg_support"),
]
operations = [
migrations.AddField(
model_name="article",
name="og_image",
field=models.ImageField(
blank=True,
help_text="Optional Open Graph image for social sharing (max 1MB). If not provided, the article's main image will be used.",
null=True,
upload_to="article_og_images/",
validators=[hub.services.models.base.validate_image_size],
),
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 5.2 on 2025-07-11 08:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0045_add_og_image_to_article"),
]
operations = [
migrations.AddField(
model_name="consultingpartner",
name="category",
field=models.CharField(
choices=[("CONSULTING", "Consulting"), ("TRAINING", "Training")],
default="CONSULTING",
help_text="Category of the consulting partner",
max_length=20,
),
),
]

View file

@ -1,7 +1,6 @@
from .articles import *
from .base import *
from .content import *
from .images import *
from .leads import *
from .pricing import *
from .providers import *

View file

@ -2,27 +2,27 @@ from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.contrib.auth.models import User
from django.utils import timezone
from .base import validate_image_size, get_prose_editor_field
from django_prose_editor.fields import ProseEditorField
from .base import validate_image_size
from .services import Service
from .providers import CloudProvider, ConsultingPartner
from .images import ImageReference
class Article(ImageReference):
class Article(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=250, unique=True)
excerpt = models.TextField(
max_length=500, help_text="Brief description of the article"
)
content = get_prose_editor_field()
content = ProseEditorField()
meta_keywords = models.CharField(
max_length=255, blank=True, help_text="SEO keywords separated by commas"
)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")
article_date = models.DateField(
default=timezone.now, help_text="Date of the article publishing"
image = models.ImageField(
upload_to="article_images/",
help_text="Title picture for the article",
)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")
# Relations to other models
related_service = models.ForeignKey(
@ -50,15 +50,6 @@ class Article(ImageReference):
help_text="Link this article to a cloud provider",
)
# Open Graph image for social sharing
og_image = models.ImageField(
upload_to="article_og_images/",
blank=True,
null=True,
validators=[validate_image_size],
help_text="Optional Open Graph image for social sharing (max 1MB). If not provided, the article's main image will be used.",
)
# Publishing controls
is_published = models.BooleanField(
default=False, help_text="Only published articles are visible to users"
@ -91,22 +82,6 @@ class Article(ImageReference):
def get_absolute_url(self):
return reverse("services:article_detail", kwargs={"slug": self.slug})
@property
def get_image(self):
"""Returns the image from the library"""
if self.image_library and self.image_library.image:
return self.image_library.image
return None
@property
def get_og_image(self):
"""Returns the Open Graph image for social sharing"""
# Use specific OG image if available
if self.og_image:
return self.og_image
# Fall back to main article image
return self.get_image
@property
def related_to(self):
"""Returns a string describing what this article is related to"""

View file

@ -2,89 +2,12 @@ from django.db import models
from django.core.exceptions import ValidationError
from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
import mimetypes
import xml.etree.ElementTree as ET
# Centralized ProseEditorField configuration
PROSE_EDITOR_CONFIG = {
"extensions": {
"Bold": True,
"Italic": True,
"Strike": True,
"Underline": True,
"HardBreak": True,
"Heading": {"levels": [1, 2, 3, 4, 5, 6]},
"BulletList": True,
"OrderedList": True,
"Blockquote": True,
"Link": True,
"Table": True,
"History": True,
"HTML": True,
"Typographic": True,
},
"sanitize": True,
}
def get_prose_editor_field(**kwargs):
"""
Returns a ProseEditorField with the standard configuration.
Additional kwargs can be passed to override or add field options.
"""
config = PROSE_EDITOR_CONFIG.copy()
config.update(kwargs)
return ProseEditorField(**config)
def validate_image_size(value, mb=1):
def validate_image_size(value):
filesize = value.size
if filesize > mb * 1024 * 1024:
raise ValidationError(f"Maximum file size is {mb} MB")
def validate_image_or_svg(value):
"""
Validate that the uploaded file is either a valid image or SVG file.
"""
# Check file size first
validate_image_size(value)
# Get the file extension and MIME type
filename = value.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
# List of allowed image formats
allowed_image_types = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/svg+xml",
]
# Check if it's an SVG file
if filename.endswith(".svg") or mime_type == "image/svg+xml":
try:
# Reset file pointer and read content
value.seek(0)
content = value.read()
value.seek(0) # Reset for later use
# Try to parse as XML to ensure it's valid SVG
ET.fromstring(content)
return # Valid SVG
except ET.ParseError:
raise ValidationError("Invalid SVG file format")
# For non-SVG files, check MIME type
if mime_type not in allowed_image_types:
raise ValidationError(
f"Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, BMP, TIFF, SVG"
)
if filesize > 1 * 1024 * 1024: # 1MB
raise ValidationError("Maximum file size is 1MB")
class Currency(models.TextChoices):
@ -106,11 +29,6 @@ class Unit(models.TextChoices):
CPU = "CPU", "vCPU"
class PartnerCategory(models.TextChoices):
CONSULTING = "CONSULTING", "Consulting"
TRAINING = "TRAINING", "Training"
# This should be a relation, but for now this is good enough :TM:
class ManagedServiceProvider(models.TextChoices):
VS = "VS", "VSHN"
@ -125,7 +43,7 @@ class ReusableText(models.Model):
blank=True,
related_name="children",
)
text = get_prose_editor_field()
text = ProseEditorField()
class Meta:
ordering = ["name"]

View file

@ -1,10 +1,10 @@
from django.db import models
from .base import get_prose_editor_field
from django_prose_editor.fields import ProseEditorField
class WebsiteFaq(models.Model):
question = models.CharField(max_length=200)
answer = get_prose_editor_field()
answer = ProseEditorField()
order = models.IntegerField(default=0)
class Meta:

View file

@ -1,320 +0,0 @@
import os
import mimetypes
import xml.etree.ElementTree as ET
from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.utils.text import slugify
from PIL import Image as PILImage
from .base import validate_image_or_svg
def get_image_upload_path(instance, filename):
"""
Generate upload path for images based on the image library structure.
"""
return f"image_library/{filename}"
class ImageLibrary(models.Model):
"""
Generic image library model that can be referenced by other models
to avoid duplicate uploads and provide centralized image management.
"""
# Image metadata
name = models.CharField(max_length=200, help_text="Descriptive name for the image")
slug = models.SlugField(
max_length=250, unique=True, help_text="URL-friendly version of the name"
)
description = models.TextField(
blank=True, help_text="Optional description of the image"
)
alt_text = models.CharField(
max_length=255, help_text="Alternative text for accessibility"
)
# Image file
image = models.FileField(
upload_to=get_image_upload_path,
validators=[validate_image_or_svg],
help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG",
)
# Image properties (automatically populated)
width = models.PositiveIntegerField(
null=True, blank=True, help_text="Image width in pixels"
)
height = models.PositiveIntegerField(
null=True, blank=True, help_text="Image height in pixels"
)
file_size = models.PositiveIntegerField(
null=True, blank=True, help_text="File size in bytes"
)
# Categorization
CATEGORY_CHOICES = [
("logo", "Logo"),
("article", "Article Image"),
("banner", "Banner"),
("icon", "Icon"),
("screenshot", "Screenshot"),
("photo", "Photo"),
("other", "Other"),
]
category = models.CharField(
max_length=20,
choices=CATEGORY_CHOICES,
default="other",
help_text="Category of the image",
)
# Tags for easier searching
tags = models.CharField(
max_length=500, blank=True, help_text="Comma-separated tags for searching"
)
# Metadata
uploaded_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="User who uploaded the image",
)
uploaded_at = models.DateTimeField(
auto_now_add=True, help_text="Date and time when image was uploaded"
)
updated_at = models.DateTimeField(
auto_now=True, help_text="Date and time when image was last updated"
)
# Usage tracking
usage_count = models.PositiveIntegerField(
default=0, help_text="Number of times this image is referenced"
)
class Meta:
ordering = ["-uploaded_at"]
verbose_name = "Image"
verbose_name_plural = "Image Library"
def __str__(self):
return self.name
def save(self, *args, **kwargs):
"""
Override save to automatically populate image properties and slug.
"""
# Generate slug if not provided
if not self.slug:
self.slug = slugify(self.name)
# Save the model first to get the image file
super().save(*args, **kwargs)
# Update image properties if image exists
if self.image:
self._update_image_properties()
def _update_image_properties(self):
"""
Update image properties like width, height, and file size.
"""
try:
# Get file size
self.file_size = self.image.size
# Check if it's an SVG file
filename = self.image.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
if filename.endswith(".svg") or mime_type == "image/svg+xml":
# For SVG files, try to extract dimensions from the SVG content
try:
with open(self.image.path, "r", encoding="utf-8") as f:
content = f.read()
# Parse the SVG to extract width and height
root = ET.fromstring(content)
# Get width and height attributes
width = root.get("width")
height = root.get("height")
# Extract numeric values if they exist
if width and height:
# Remove units like 'px', 'em', etc. and convert to int
try:
width_val = int(
float(
width.replace("px", "")
.replace("em", "")
.replace("pt", "")
)
)
height_val = int(
float(
height.replace("px", "")
.replace("em", "")
.replace("pt", "")
)
)
self.width = width_val
self.height = height_val
except (ValueError, TypeError):
# If we can't parse dimensions, try viewBox
viewbox = root.get("viewBox")
if viewbox:
try:
viewbox_parts = viewbox.split()
if len(viewbox_parts) >= 4:
self.width = int(float(viewbox_parts[2]))
self.height = int(float(viewbox_parts[3]))
except (ValueError, TypeError):
# Default SVG dimensions if we can't parse
self.width = 100
self.height = 100
else:
# Check for viewBox if width/height attributes don't exist
viewbox = root.get("viewBox")
if viewbox:
try:
viewbox_parts = viewbox.split()
if len(viewbox_parts) >= 4:
self.width = int(float(viewbox_parts[2]))
self.height = int(float(viewbox_parts[3]))
except (ValueError, TypeError):
self.width = 100
self.height = 100
else:
# Default SVG dimensions
self.width = 100
self.height = 100
except (ET.ParseError, FileNotFoundError, UnicodeDecodeError):
# If SVG parsing fails, set default dimensions
self.width = 100
self.height = 100
else:
# For raster images, use PIL
with PILImage.open(self.image.path) as img:
self.width = img.width
self.height = img.height
# Save without calling the full save method to avoid recursion
ImageLibrary.objects.filter(pk=self.pk).update(
width=self.width, height=self.height, file_size=self.file_size
)
except Exception as e:
# Log error but don't fail the save
print(f"Error updating image properties: {e}")
def get_file_size_display(self):
"""
Return human-readable file size.
"""
if not self.file_size:
return "Unknown"
size = self.file_size
for unit in ["B", "KB", "MB", "GB"]:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TB"
def get_tags_list(self):
"""
Return tags as a list.
"""
if not self.tags:
return []
return [tag.strip() for tag in self.tags.split(",") if tag.strip()]
def increment_usage(self):
"""
Increment usage count when image is referenced.
"""
self.usage_count += 1
self.save(update_fields=["usage_count"])
def decrement_usage(self):
"""
Decrement usage count when reference is removed.
"""
if self.usage_count > 0:
self.usage_count -= 1
self.save(update_fields=["usage_count"])
def is_svg(self):
"""
Check if the uploaded file is an SVG.
"""
if not self.image:
return False
filename = self.image.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
return filename.endswith(".svg") or mime_type == "image/svg+xml"
def get_mime_type(self):
"""
Return the MIME type of the image file.
"""
if not self.image:
return None
mime_type, _ = mimetypes.guess_type(self.image.name)
return mime_type
class ImageReference(models.Model):
"""
Abstract base class for models that want to reference images from the library.
This helps track usage and provides a consistent interface.
"""
image_library = models.ForeignKey(
ImageLibrary,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Select an image from the library",
related_name="%(class)s_references",
)
class Meta:
abstract = True
def save(self, *args, **kwargs):
"""
Override save to update usage count.
"""
# Track if image changed
old_image = None
if self.pk:
try:
old_instance = self.__class__.objects.get(pk=self.pk)
old_image = old_instance.image_library
except self.__class__.DoesNotExist:
pass
super().save(*args, **kwargs)
# Update usage counts
if old_image and old_image != self.image_library:
old_image.decrement_usage()
if self.image_library and self.image_library != old_image:
self.image_library.increment_usage()
def delete(self, *args, **kwargs):
"""
Override delete to update usage count.
"""
if self.image_library:
self.image_library.decrement_usage()
super().delete(*args, **kwargs)

View file

@ -1,20 +1,26 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
from .base import validate_image_size, get_prose_editor_field, PartnerCategory
from .images import ImageReference
from .base import validate_image_size
class CloudProvider(ImageReference):
class CloudProvider(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = get_prose_editor_field()
description = ProseEditorField()
website = models.URLField()
linkedin = models.URLField(blank=True)
phone = models.CharField(max_length=25, blank=True, null=True)
email = models.EmailField(max_length=254, blank=True, null=True)
address = models.TextField(max_length=250, blank=True, null=True)
logo = models.ImageField(
upload_to="cloud_provider_logos/",
validators=[validate_image_size],
null=True,
blank=True,
)
order = models.IntegerField(default=0)
is_featured = models.BooleanField(default=False)
disable_listing = models.BooleanField(default=False)
@ -33,32 +39,23 @@ class CloudProvider(ImageReference):
def get_absolute_url(self):
return reverse("services:provider_detail", kwargs={"slug": self.slug})
@property
def get_logo(self):
"""Returns the logo from the library"""
if self.image_library and self.image_library.image:
return self.image_library.image
return None
class ConsultingPartner(ImageReference):
class ConsultingPartner(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
description = get_prose_editor_field()
description = ProseEditorField()
logo = models.ImageField(
upload_to="partner_logos/",
validators=[validate_image_size],
null=True,
blank=True,
)
website = models.URLField(blank=True)
linkedin = models.URLField(blank=True)
phone = models.CharField(max_length=25, blank=True, null=True)
email = models.EmailField(max_length=254, blank=True, null=True)
address = models.TextField(max_length=250, blank=True, null=True)
# Partner category (hardcoded choices as requested)
category = models.CharField(
max_length=20,
choices=PartnerCategory.choices,
default=PartnerCategory.CONSULTING,
help_text="Category of the partner",
)
services = models.ManyToManyField(
"services.Service", related_name="consulting_partners", blank=True
)
@ -77,10 +74,7 @@ class ConsultingPartner(ImageReference):
ordering = ["order"]
def __str__(self):
return f"{self.name} ({self.get_category_display()})"
def get_category_display_badge(self):
return f"Servala {self.get_category_display()} Partner"
return self.name
def save(self, *args, **kwargs):
if not self.slug:
@ -89,10 +83,3 @@ class ConsultingPartner(ImageReference):
def get_absolute_url(self):
return reverse("services:partner_detail", kwargs={"slug": self.slug})
@property
def get_logo(self):
"""Returns the logo from the library"""
if self.image_library and self.image_library.image:
return self.image_library.image
return None

View file

@ -4,26 +4,28 @@ from django.core.validators import URLValidator
from django.urls import reverse
from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
from typing import TYPE_CHECKING, Optional
from .base import (
Category,
ReusableText,
ManagedServiceProvider,
validate_image_size,
Currency,
get_prose_editor_field,
)
from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size, Currency
from .providers import CloudProvider
from .images import ImageReference
if TYPE_CHECKING:
from .services import PlanPrice
class Service(ImageReference):
class Service(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=250, unique=True)
description = get_prose_editor_field()
description = ProseEditorField()
tagline = models.TextField(max_length=500, blank=True, null=True)
logo = models.ImageField(
upload_to="service_logos/",
validators=[validate_image_size],
null=True,
blank=True,
)
categories = models.ManyToManyField(Category, related_name="services")
features = get_prose_editor_field()
features = ProseEditorField()
is_featured = models.BooleanField(default=False)
is_coming_soon = models.BooleanField(default=False)
disable_listing = models.BooleanField(default=False)
@ -54,13 +56,6 @@ class Service(ImageReference):
def get_absolute_url(self):
return reverse("services:service_detail", kwargs={"slug": self.slug})
@property
def get_logo(self):
"""Returns the logo from the library"""
if self.image_library and self.image_library.image:
return self.image_library.image
return None
class ServiceOffering(models.Model):
service = models.ForeignKey(
@ -75,7 +70,7 @@ class ServiceOffering(models.Model):
cloud_provider = models.ForeignKey(
CloudProvider, on_delete=models.CASCADE, related_name="offerings"
)
description = get_prose_editor_field(blank=True, null=True)
description = ProseEditorField(blank=True, null=True)
offer_description = models.ForeignKey(
ReusableText,
on_delete=models.PROTECT,
@ -108,7 +103,7 @@ class ServiceOffering(models.Model):
class PlanPrice(models.Model):
plan = models.ForeignKey(
"Plan", on_delete=models.CASCADE, related_name="plan_prices"
'Plan', on_delete=models.CASCADE, related_name='plan_prices'
)
currency = models.CharField(
max_length=3,
@ -130,7 +125,7 @@ class PlanPrice(models.Model):
class Plan(models.Model):
name = models.CharField(max_length=100)
description = get_prose_editor_field(blank=True, null=True)
description = ProseEditorField(blank=True, null=True)
plan_description = models.ForeignKey(
ReusableText,
on_delete=models.PROTECT,
@ -142,43 +137,18 @@ class Plan(models.Model):
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
)
# Ordering and highlighting fields
order = models.PositiveIntegerField(
default=0,
help_text="Order of this plan in the offering (lower numbers appear first)",
)
is_best = models.BooleanField(
default=False, help_text="Mark this plan as the best/recommended option"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["order", "name"]
ordering = ["name"]
unique_together = [["offering", "name"]]
def __str__(self):
return f"{self.offering} - {self.name}"
def clean(self):
# Ensure only one plan per offering can be marked as "best"
if self.is_best:
existing_best = Plan.objects.filter(
offering=self.offering, is_best=True
).exclude(pk=self.pk)
if existing_best.exists():
from django.core.exceptions import ValidationError
raise ValidationError(
"Only one plan per offering can be marked as the best option."
)
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
def get_price(self, currency_code: str):
def get_price(self, currency_code: str) -> Optional[float]:
from hub.services.models.services import PlanPrice
price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
if price_obj:
return price_obj.amount

View file

@ -1,109 +0,0 @@
/* CSS for Image Library Admin */
/* Thumbnail styling in list view */
.image-thumbnail {
border-radius: 4px;
object-fit: cover;
}
/* Preview styling in detail view */
.image-preview {
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Form styling */
.image-library-form .form-row {
margin-bottom: 15px;
}
.image-library-form .help {
font-size: 11px;
color: #666;
margin-top: 5px;
}
/* Usage count styling */
.usage-count {
font-weight: bold;
color: #0066cc;
}
.usage-count.high {
color: #cc0000;
}
/* Category badges */
.category-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
}
.category-badge.logo {
background-color: #e8f4f8;
color: #2c6e92;
}
.category-badge.article {
background-color: #f0f8e8;
color: #5a7c3a;
}
.category-badge.banner {
background-color: #fef4e8;
color: #d2691e;
}
.category-badge.icon {
background-color: #f8e8f8;
color: #8b4c8b;
}
.category-badge.screenshot {
background-color: #e8f8f4;
color: #3a7c5a;
}
.category-badge.photo {
background-color: #f4e8f8;
color: #923c92;
}
.category-badge.other {
background-color: #f0f0f0;
color: #666;
}
/* SVG support */
.svg-preview {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 5px;
}
.svg-preview object {
width: 100%;
height: 100%;
}
/* SVG thumbnails in admin */
.image-thumbnail object {
background: #f5f5f5;
border-radius: 4px;
}
.image-preview object {
background: #f5f5f5;
border-radius: 4px;
}
/* Category badges */
.category-badge.svg {
background-color: #f3e8ff;
color: #7c3aed;
}

View file

@ -33,46 +33,4 @@
to {
opacity: 1;
}
}
/* Subtle styling for the best plan */
.card.border-success.border-2 {
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.1) !important;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.card.border-success.border-2:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(25, 135, 84, 0.15) !important;
}
/* Best choice badge styling */
.badge.bg-success {
border: 2px solid white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.151);
color: rgb(255, 255, 255);
white-space: nowrap;
font-size: 0.75rem;
padding: 0.5rem 0.75rem;
min-width: max-content;
}
/* Subtle enhancement for best plan button */
.btn-success.shadow {
transition: all 0.2s ease-in-out;
}
.btn-success.shadow:hover {
transform: translateY(-1px);
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.2) !important;
}
/* Ensure collapse starts properly hidden */
#managedServiceIncludes {
transition: all 0.35s ease;
}
#managedServiceIncludes:not(.show) {
display: none;
}

View file

@ -1,949 +0,0 @@
.calculator-section {
background: #f8f9fa;
border-radius: 10px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.input-group-custom {
margin-bottom: 1rem;
position: relative;
/* Ensure proper stacking context */
}
.input-group-custom label {
font-weight: 600;
margin-bottom: 0.5rem;
display: block;
}
/* Ensure input groups don't interfere with tooltips */
.input-group {
position: relative;
z-index: 1;
}
.slider-container {
position: relative;
margin: 10px 0;
}
.slider {
width: 100%;
height: 8px;
border-radius: 5px;
background: #ddd;
outline: none;
-webkit-appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #007bff;
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #007bff;
cursor: pointer;
border: none;
}
.scenario-card {
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.scenario-card.active {
border-color: #007bff;
background-color: #f8f9ff;
}
.scenario-card.disabled {
opacity: 0.6;
background-color: #f8f9fa;
}
.metric-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
.metric-value {
font-size: 1.6rem;
font-weight: bold;
color: #007bff;
line-height: 1.2;
word-break: break-word;
overflow-wrap: break-word;
}
.metric-label {
color: #6c757d;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.5rem;
}
.chart-container {
position: relative;
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
}
/* Enhanced chart sizing for new layout */
.chart-container canvas {
max-height: 400px;
}
/* Full-width chart containers */
.card-body canvas {
width: 100% !important;
}
/* Primary chart gets extra height */
#instanceGrowthChart {
height: 500px !important;
}
/* Secondary charts get good height */
#revenueChart, #cashFlowChart, #cspRevenueChart {
height: 400px !important;
}
/* Enhanced layout styles for new design */
.sticky-top {
z-index: 1020;
}
.card {
transition: box-shadow 0.15s ease-in-out;
}
.card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
/* Compact header controls */
.form-range {
height: 4px;
}
.input-group-sm .form-control,
.form-select-sm,
.btn-sm {
font-size: 0.825rem;
}
/* Clean chart headers */
.card-header {
background: white !important;
border: none !important;
padding-bottom: 0.5rem;
}
.card-body {
padding: 1.5rem;
}
/* Responsive chart heights */
@media (max-width: 768px) {
#instanceGrowthChart {
height: 350px !important;
}
#revenueChart, #cashFlowChart, #cspRevenueChart {
height: 300px !important;
}
.card-body {
padding: 1rem;
}
}
@media (max-width: 576px) {
#instanceGrowthChart {
height: 250px !important;
}
#revenueChart, #cashFlowChart, #cspRevenueChart {
height: 200px !important;
}
}
/* Manual collapse functionality */
.collapse {
display: none;
}
.collapse.show {
display: block;
}
.collapsing {
position: relative;
height: 0;
overflow: hidden;
transition: height 0.35s ease;
}
.export-buttons {
position: sticky;
top: 20px;
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
.collapsible-section {
border: 1px solid #e9ecef;
border-radius: 8px;
margin-bottom: 1rem;
}
.collapsible-header {
background: #f8f9fa;
padding: 1rem;
cursor: pointer;
border-radius: 8px 8px 0 0;
transition: background-color 0.3s ease;
}
.collapsible-header:hover {
background: #e9ecef;
}
.collapsible-content {
padding: 1rem;
display: none;
}
.collapsible-content.show {
display: block;
}
.phase-settings {
background: #f8f9fa;
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
}
.currency-symbol {
font-weight: bold;
color: #28a745;
}
.loading-spinner {
display: none;
text-align: center;
padding: 2rem;
}
.help-content {
background: #f8f9fa;
border-radius: 6px;
padding: 1rem;
}
.help-content h6 {
margin-bottom: 0.5rem;
font-weight: 600;
}
.help-content p {
margin-bottom: 0.75rem;
line-height: 1.4;
}
.help-content p:last-child {
margin-bottom: 0;
}
/* Bootstrap tooltip styling improvements */
.tooltip {
font-size: 0.875rem;
font-family: inherit;
z-index: 9999 !important;
/* Ensure tooltips appear above all other elements */
pointer-events: none;
/* Prevent tooltip from interfering with mouse events */
}
.tooltip .tooltip-inner {
max-width: 250px;
padding: 0.5rem 0.75rem;
color: #fff;
background-color: #212529;
border-radius: 6px;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tooltip .tooltip-arrow {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
}
.tooltip.bs-tooltip-top .tooltip-arrow::before {
border-top-color: #212529;
}
.tooltip.bs-tooltip-bottom .tooltip-arrow::before {
border-bottom-color: #212529;
}
.tooltip.bs-tooltip-start .tooltip-arrow::before {
border-left-color: #212529;
}
.tooltip.bs-tooltip-end .tooltip-arrow::before {
border-right-color: #212529;
}
/* Enhanced cursor for tooltip elements */
[data-bs-toggle="tooltip"] {
cursor: help;
position: relative;
}
[data-bs-toggle="tooltip"]:hover {
opacity: 0.8;
}
@media (max-width: 768px) {
.chart-container {
height: 300px;
}
.metric-value {
font-size: 1.4rem;
}
.metric-card {
padding: 1rem;
}
}
@media (max-width: 576px) {
.metric-value {
font-size: 1.2rem;
}
.metric-card {
padding: 0.75rem;
margin-bottom: 0.75rem;
}
}
/* Clean Dual Model Layout */
.model-comparison-indicator {
font-size: 1.2rem;
color: #6c757d;
}
/* Scenario selection enhancements */
.form-check {
margin-bottom: 0.25rem;
}
.form-check-label {
cursor: pointer;
user-select: none;
}
.form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
/* Model result boxes */
.model-result-box {
transition: all 0.2s ease;
background: #fafafa;
}
.model-result-box:hover {
background: #f0f0f0;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Enhanced main configuration styling */
.main-config-section {
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.main-config-fields {
padding: 1.5rem;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
border: 1px solid #e9ecef;
}
.main-config-fields .form-label {
color: #2c3e50;
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.75rem;
}
.main-config-fields .input-group-text {
background-color: #f8f9fa;
border-color: #dee2e6;
font-weight: 600;
color: #495057;
}
.main-config-fields .form-control {
border-color: #dee2e6;
font-size: 1.1rem;
}
.main-config-fields .form-control:focus {
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.15);
}
/* Investment amount input enhancements */
#investment-amount {
font-family: 'Courier New', monospace;
font-weight: 600;
text-align: right;
padding-right: 1rem;
}
#investment-amount:focus {
text-align: left;
padding-left: 1rem;
padding-right: 0.75rem;
}
#investment-amount::placeholder {
font-family: system-ui, -apple-system, sans-serif;
font-weight: normal;
text-align: left;
opacity: 0.6;
}
.main-config-fields .form-select {
border-color: #dee2e6;
font-size: 1.1rem;
}
.main-config-fields .form-select:focus {
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.15);
}
/* Results Panel Styling */
.results-panel {
position: sticky;
top: 1rem;
}
.results-panel .card {
border-radius: 12px;
overflow: hidden;
background: #ffffff;
}
.results-panel .card-header {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
border-bottom: none;
padding: 1.25rem;
}
.results-panel .card-body {
padding: 2rem 1.5rem;
min-height: 400px;
}
.result-item {
transition: all 0.3s ease;
padding: 0.5rem;
border-radius: 8px;
}
.result-item:hover {
background: rgba(0, 123, 255, 0.02);
transform: translateX(5px);
}
.result-icon {
flex-shrink: 0;
font-size: 1.2rem;
transition: all 0.3s ease;
}
.result-item:hover .result-icon {
transform: scale(1.1);
}
.result-metrics {
border: 1px solid #dee2e6;
transition: all 0.3s ease;
}
.result-item:hover .result-metrics {
border-color: #007bff;
background: rgba(248, 249, 250, 0.8) !important;
}
/* Enhanced form styling */
.form-check-lg .form-check-input {
width: 1.5rem;
height: 1.5rem;
margin-top: 0.125rem;
}
.form-check-lg .form-check-label {
font-size: 1.1rem;
margin-left: 0.5rem;
}
/* Enhanced range sliders */
.form-range {
height: 8px;
background: linear-gradient(to right, #e9ecef 0%, #dee2e6 100%);
border-radius: 4px;
margin: 1rem 0 0.5rem 0;
}
.form-range::-webkit-slider-thumb {
width: 24px;
height: 24px;
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
border: 3px solid #ffffff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
cursor: pointer;
transition: all 0.2s ease;
}
.form-range::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
}
.form-range::-moz-range-thumb {
width: 24px;
height: 24px;
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
border: 3px solid #ffffff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
cursor: pointer;
transition: all 0.2s ease;
}
.form-range::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
}
/* Button styling improvements */
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1.1rem;
font-weight: 600;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-outline-info:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
}
.btn-outline-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
}
/* Advanced controls styling */
.advanced-controls-section {
background: #f8f9fa;
border-top: 3px solid #007bff;
}
.advanced-controls-section .card {
transition: all 0.2s ease;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.advanced-controls-section .card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.advanced-controls-section .card-header {
font-weight: 600;
border-bottom: 1px solid rgba(255,255,255,0.2);
}
/* Slider styling improvements */
.form-range {
height: 6px;
background: #e9ecef;
border-radius: 3px;
}
.form-range::-webkit-slider-thumb {
width: 18px;
height: 18px;
background: #007bff;
border: 3px solid #fff;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
cursor: pointer;
}
.form-range::-moz-range-thumb {
width: 18px;
height: 18px;
background: #007bff;
border: 3px solid #fff;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
cursor: pointer;
}
/* Real-time results cards */
.results-card {
transition: all 0.2s ease;
border: 2px solid transparent;
}
.results-card:hover {
border-color: #007bff;
transform: translateY(-1px);
}
/* Growth scenario checkboxes */
.form-check-input:checked {
background-color: #007bff;
border-color: #007bff;
}
.form-check-label {
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.form-check-label:hover {
color: #007bff;
}
/* Responsive improvements */
@media (max-width: 991px) {
.results-panel {
position: static;
margin-top: 2rem;
}
.results-panel .card-body {
min-height: auto;
padding: 1.5rem;
}
.result-item {
margin-bottom: 1.5rem;
}
.result-item:last-child {
margin-bottom: 0;
}
}
@media (max-width: 768px) {
.main-config-fields {
padding: 1rem;
margin: 0 -15px;
border-radius: 0;
border-left: none;
border-right: none;
}
.main-config-fields .form-label {
font-size: 1rem;
}
.main-config-fields .form-control,
.main-config-fields .form-select {
font-size: 1rem;
}
.btn-lg {
font-size: 1rem;
padding: 0.6rem 1.2rem;
}
.form-check-lg .form-check-input {
width: 1.25rem;
height: 1.25rem;
}
.form-check-lg .form-check-label {
font-size: 1rem;
}
.results-panel .card-header {
padding: 1rem;
}
.results-panel .card-body {
padding: 1rem;
}
.result-icon {
width: 35px !important;
height: 35px !important;
font-size: 1rem;
}
.advanced-controls-section .card {
margin-bottom: 1rem;
}
.advanced-controls-section .card-body {
padding: 1rem;
}
}
@media (max-width: 576px) {
.d-flex.gap-3.flex-wrap {
flex-direction: column;
gap: 0.75rem !important;
}
.btn-lg {
width: 100%;
justify-content: center;
}
.form-check-lg {
margin-bottom: 0.75rem;
}
.results-panel .card-body {
padding: 0.75rem;
}
.result-metrics .row {
text-align: center;
}
.result-metrics .col-6 {
margin-bottom: 0.5rem;
}
}
/* Model comparison box */
.model-comparison-box {
border: 1px solid #dee2e6;
transition: all 0.2s ease;
}
.model-comparison-box:hover {
border-color: #adb5bd;
background-color: #f8f9fa !important;
}
/* Control section styling */
.controls-section {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 8px;
padding: 1rem;
}
/* Enhanced form controls */
.form-range {
height: 4px;
margin-top: 0.5rem;
}
.form-range::-webkit-slider-thumb {
background: #0d6efd;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.form-range::-moz-range-thumb {
background: #0d6efd;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
/* Button group styling */
.btn-group-enhanced .btn {
border-radius: 4px;
margin-right: 0.25rem;
}
.btn-group-enhanced .btn:last-child {
margin-right: 0;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.controls-section {
padding: 0.75rem;
}
.model-result-box {
margin-bottom: 1rem;
}
}
/* Enhanced table styling for financial analysis */
.table-hover tbody tr:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.table-success {
--bs-table-bg: rgba(40, 167, 69, 0.1);
}
.table-warning {
--bs-table-bg: rgba(255, 193, 7, 0.1);
}
/* Tab styling improvements */
.nav-tabs .nav-link {
border: 1px solid transparent;
border-top-left-radius: 0.375rem;
border-top-right-radius: 0.375rem;
}
.nav-tabs .nav-link.active {
color: #495057;
background-color: #fff;
border-color: #dee2e6 #dee2e6 #fff;
}
.nav-tabs .nav-link:hover:not(.active) {
border-color: #e9ecef #e9ecef #dee2e6;
isolation: isolate;
}
/* Table cell improvements */
.table td {
vertical-align: middle;
}
.table th {
border-top: none;
font-weight: 600;
font-size: 0.875rem;
}
/* Better responsive tables */
@media (max-width: 768px) {
.table-responsive {
font-size: 0.8rem;
}
.table th, .table td {
padding: 0.5rem 0.25rem;
}
}
/* Monthly Breakdown Filter Controls */
.breakdown-filters {
background: #f8f9fa;
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
}
.breakdown-filters .form-check {
margin-bottom: 0.25rem;
}
.breakdown-filters .form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
.breakdown-filters .form-check-label {
cursor: pointer;
user-select: none;
font-size: 0.875rem;
}
/* Responsive breakdown filters */
@media (max-width: 768px) {
.breakdown-filters {
padding: 0.75rem;
}
.breakdown-filters .d-flex {
flex-direction: column;
gap: 0.5rem !important;
}
}
/* Enhanced sticky header for monthly breakdown table */
.table-responsive {
position: relative;
}
.table-responsive .sticky-top {
position: sticky;
top: 0;
z-index: 10;
background-color: #212529 !important; /* Ensure dark background stays */
}
.table-responsive .sticky-top th {
background-color: #212529 !important;
border-color: #454d55 !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Ensure sticky header works in scrollable containers */
.table-responsive[style*="max-height"] .sticky-top {
position: sticky;
top: 0;
z-index: 20;
}
/* Additional styling for better visibility */
.table-dark th {
font-weight: 600;
font-size: 0.875rem;
white-space: nowrap;
text-align: center;
}
.table-dark th:first-child {
text-align: center;
}
.table-dark th:nth-child(n+4) {
text-align: right;
}

View file

@ -351,8 +351,7 @@ dl {
ol ol,
ul ul,
ol ul,
ul ol,
li p {
ul ol {
margin-bottom: 0
}
@ -12535,8 +12534,4 @@ a.btn:focus {
.article-content a {
font-weight: bold;
text-decoration: underline;
}
.article-content h2 {
margin-top: 3rem;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,176 +0,0 @@
/**
* Addon Manager - Handles addon functionality
*/
class AddonManager {
constructor(pricingDataManager) {
this.pricingDataManager = pricingDataManager;
}
// Update addons based on current configuration
updateAddons(domManager) {
const addonsContainer = domManager.get('addonsContainer');
const addonsData = this.pricingDataManager.getAddonsData();
if (!addonsContainer || !addonsData) {
// Hide addons section if no container or data
const addonsSection = document.getElementById('addonsSection');
if (addonsSection) addonsSection.style.display = 'none';
return;
}
const serviceLevel = domManager.getSelectedServiceLevel();
if (!serviceLevel || !addonsData[serviceLevel]) {
// Hide addons section if no service level or no addons for this level
const addonsSection = document.getElementById('addonsSection');
if (addonsSection) addonsSection.style.display = 'none';
return;
}
const addons = addonsData[serviceLevel];
// Clear existing addons
addonsContainer.innerHTML = '';
// Show or hide addons section based on availability
const addonsSection = document.getElementById('addonsSection');
if (addons && addons.length > 0) {
if (addonsSection) addonsSection.style.display = 'block';
} else {
if (addonsSection) addonsSection.style.display = 'none';
return;
}
// Add each addon
addons.forEach(addon => {
const addonElement = document.createElement('div');
addonElement.className = `addon-item mb-2 p-2 border rounded ${addon.is_mandatory ? 'bg-light' : ''}`;
addonElement.innerHTML = `
<div class="form-check">
<input class="form-check-input addon-checkbox"
type="checkbox"
id="addon-${addon.id}"
value="${addon.id}"
data-addon='${JSON.stringify(addon)}'
${addon.is_mandatory ? 'checked disabled' : ''}>
<label class="form-check-label" for="addon-${addon.id}">
<strong>${addon.name}</strong>
<div class="text-muted small">${addon.commercial_description || ''}</div>
<div class="text-primary addon-price-display">
${addon.is_mandatory ? 'Required - ' : ''}CHF <span class="addon-price-value">0.00</span>
</div>
</label>
</div>
`;
addonsContainer.appendChild(addonElement);
// Add event listener for optional addons
if (!addon.is_mandatory) {
const checkbox = addonElement.querySelector('.addon-checkbox');
checkbox.addEventListener('change', () => {
// Update addon prices and recalculate total
this.updateAddonPrices(domManager);
// Trigger pricing update through custom event
window.dispatchEvent(new CustomEvent('addon-changed'));
});
}
});
// Update addon prices
this.updateAddonPrices(domManager);
}
// Update addon prices based on current configuration
updateAddonPrices(domManager, planManager) {
const addonsContainer = domManager.get('addonsContainer');
if (!addonsContainer) return;
const config = domManager.getCurrentConfiguration();
// Find the current plan data to get variable_unit for addon calculations
const matchedPlan = planManager ? planManager.getCurrentPlan(domManager) : null;
const variableUnit = matchedPlan?.variable_unit || 'CPU';
const units = variableUnit === 'CPU' ? config.cpus : config.memory;
const totalUnits = units * config.instances;
const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox');
addonCheckboxes.forEach(checkbox => {
const addon = JSON.parse(checkbox.dataset.addon);
const priceElement = checkbox.parentElement.querySelector('.addon-price-value');
let calculatedPrice = 0;
// Calculate addon price based on type
if (addon.addon_type === 'BASE_FEE') {
// Base fee: price per instance
calculatedPrice = parseFloat(addon.price || 0) * config.instances;
} else if (addon.addon_type === 'UNIT_RATE') {
// Unit rate: price per unit (CPU or memory) across all instances
calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits;
}
// Update the display price
if (priceElement) {
priceElement.textContent = calculatedPrice.toFixed(2);
}
// Store the calculated price for later use in total calculations
checkbox.dataset.calculatedPrice = calculatedPrice.toString();
});
}
// Get selected addons with their calculated prices
getSelectedAddons(domManager) {
const addonsContainer = domManager.get('addonsContainer');
if (!addonsContainer) return { mandatory: [], optional: [] };
const mandatoryAddons = [];
const selectedOptionalAddons = [];
const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox');
addonCheckboxes.forEach(checkbox => {
const addon = JSON.parse(checkbox.dataset.addon);
const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0);
if (addon.is_mandatory) {
mandatoryAddons.push({
name: addon.name,
price: calculatedPrice.toFixed(2)
});
} else if (checkbox.checked) {
selectedOptionalAddons.push({
name: addon.name,
price: calculatedPrice.toFixed(2)
});
}
});
return {
mandatory: mandatoryAddons,
optional: selectedOptionalAddons
};
}
// Calculate total optional addon price
calculateOptionalAddonTotal(domManager) {
const addonsContainer = domManager.get('addonsContainer');
if (!addonsContainer) return 0;
let total = 0;
const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox');
addonCheckboxes.forEach(checkbox => {
const addon = JSON.parse(checkbox.dataset.addon);
if (!addon.is_mandatory && checkbox.checked) {
const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0);
total += calculatedPrice;
}
});
return total;
}
}
// Export for use in other modules
window.AddonManager = AddonManager;

View file

@ -1,196 +0,0 @@
/**
* DOM Manager - Handles DOM element references and basic manipulation
*/
class DOMManager {
constructor() {
this.elements = {};
this.initElements();
}
// Initialize DOM element references
initElements() {
// Calculator controls
this.elements.cpuRange = document.getElementById('cpuRange');
this.elements.memoryRange = document.getElementById('memoryRange');
this.elements.storageRange = document.getElementById('storageRange');
this.elements.instancesRange = document.getElementById('instancesRange');
this.elements.cpuValue = document.getElementById('cpuValue');
this.elements.memoryValue = document.getElementById('memoryValue');
this.elements.storageValue = document.getElementById('storageValue');
this.elements.instancesValue = document.getElementById('instancesValue');
this.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
this.elements.planSelect = document.getElementById('planSelect');
// Addon elements
this.elements.addonsContainer = document.getElementById('addonsContainer');
this.elements.addonPricingContainer = document.getElementById('addonPricingContainer');
this.elements.managedServiceIncludesContainer = document.getElementById('managedServiceIncludesContainer');
this.elements.managedServiceIncludes = document.getElementById('managedServiceIncludes');
this.elements.managedServiceToggleButton = document.querySelector('button[data-bs-target="#managedServiceIncludes"]');
// Result display elements
this.elements.planMatchStatus = document.getElementById('planMatchStatus');
this.elements.selectedPlanDetails = document.getElementById('selectedPlanDetails');
this.elements.noMatchFound = document.getElementById('noMatchFound');
// Plan detail elements
this.elements.planGroup = document.getElementById('planGroup');
this.elements.planName = document.getElementById('planName');
this.elements.planDescription = document.getElementById('planDescription');
this.elements.planCpus = document.getElementById('planCpus');
this.elements.planMemory = document.getElementById('planMemory');
this.elements.planInstances = document.getElementById('planInstances');
this.elements.planServiceLevel = document.getElementById('planServiceLevel');
this.elements.managedServicePrice = document.getElementById('managedServicePrice');
this.elements.storagePriceEl = document.getElementById('storagePrice');
this.elements.storageAmount = document.getElementById('storageAmount');
this.elements.totalPrice = document.getElementById('totalPrice');
// Order button
this.elements.orderButton = document.querySelector('a[href="#order-form"]');
// Service level group
this.elements.serviceLevelGroup = document.getElementById('serviceLevelGroup');
}
// Get element by key with error handling
get(key) {
const element = this.elements[key];
if (!element && key !== 'addonsContainer') {
console.warn(`DOM element '${key}' not found`);
}
return element;
}
// Check if element exists and is valid
has(key) {
return this.elements[key] && this.elements[key] !== null;
}
// Update slider display values (min/max text below sliders)
updateSliderDisplayValues() {
// Update CPU slider display
if (this.elements.cpuRange) {
const cpuMinDisplay = document.getElementById('cpuMinDisplay');
const cpuMaxDisplay = document.getElementById('cpuMaxDisplay');
if (cpuMinDisplay) cpuMinDisplay.textContent = this.elements.cpuRange.min;
if (cpuMaxDisplay) cpuMaxDisplay.textContent = this.elements.cpuRange.max;
}
// Update Memory slider display
if (this.elements.memoryRange) {
const memoryMinDisplay = document.getElementById('memoryMinDisplay');
const memoryMaxDisplay = document.getElementById('memoryMaxDisplay');
if (memoryMinDisplay) memoryMinDisplay.textContent = this.elements.memoryRange.min;
if (memoryMaxDisplay) memoryMaxDisplay.textContent = this.elements.memoryRange.max;
}
// Update Storage slider display
if (this.elements.storageRange) {
const storageMinDisplay = document.getElementById('storageMinDisplay');
const storageMaxDisplay = document.getElementById('storageMaxDisplay');
if (storageMinDisplay) storageMinDisplay.textContent = this.elements.storageRange.min;
if (storageMaxDisplay) storageMaxDisplay.textContent = this.elements.storageRange.max;
}
// Update Instances slider display
if (this.elements.instancesRange) {
const instancesMinDisplay = document.getElementById('instancesMinDisplay');
const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
if (instancesMinDisplay) instancesMinDisplay.textContent = this.elements.instancesRange.min;
if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.elements.instancesRange.max;
}
}
// Get slider container element by type
getSliderContainer(type) {
switch (type) {
case 'cpu':
return this.elements.cpuRange?.closest('.mb-4');
case 'memory':
return this.elements.memoryRange?.closest('.mb-4');
case 'storage':
return this.elements.storageRange?.closest('.mb-4');
case 'instances':
return this.elements.instancesRange?.closest('.mb-4');
default:
return null;
}
}
// Reset sliders to their default values
resetSlidersToDefaults() {
// Reset CPU slider to default value (0.5 vCPUs)
if (this.elements.cpuRange) {
this.elements.cpuRange.value = '0.5';
if (this.elements.cpuValue) this.elements.cpuValue.textContent = '0.5';
}
// Reset Memory slider to default value (1 GB)
if (this.elements.memoryRange) {
this.elements.memoryRange.value = '1';
if (this.elements.memoryValue) this.elements.memoryValue.textContent = '1';
}
// Reset Storage slider to default value (20 GB)
if (this.elements.storageRange) {
this.elements.storageRange.value = '20';
if (this.elements.storageValue) this.elements.storageValue.textContent = '20';
}
// Reset Instances slider to default value (1)
if (this.elements.instancesRange) {
this.elements.instancesRange.value = '1';
if (this.elements.instancesValue) this.elements.instancesValue.textContent = '1';
}
}
// Set smart default values based on available plans
setSmartDefaults(pricingDataManager) {
const { cpuValues, memoryValues } = pricingDataManager.getAvailableSliderValues();
// Use the smallest available CPU value as default
if (cpuValues.length > 0 && this.elements.cpuRange) {
const defaultCpu = Math.min(...cpuValues);
this.elements.cpuRange.value = defaultCpu;
if (this.elements.cpuValue) this.elements.cpuValue.textContent = defaultCpu;
}
// Use the smallest available memory value as default
if (memoryValues.length > 0 && this.elements.memoryRange) {
const defaultMemory = Math.min(...memoryValues);
this.elements.memoryRange.value = defaultMemory;
if (this.elements.memoryValue) this.elements.memoryValue.textContent = defaultMemory;
}
// Keep existing defaults for storage and instances
if (this.elements.storageRange) {
this.elements.storageRange.value = '20';
if (this.elements.storageValue) this.elements.storageValue.textContent = '20';
}
if (this.elements.instancesRange) {
this.elements.instancesRange.value = '1';
if (this.elements.instancesValue) this.elements.instancesValue.textContent = '1';
}
}
// Get current selected service level
getSelectedServiceLevel() {
return document.querySelector('input[name="serviceLevel"]:checked')?.value;
}
// Get current configuration values
getCurrentConfiguration() {
return {
cpus: parseFloat(this.elements.cpuRange?.value || 0.5),
memory: parseFloat(this.elements.memoryRange?.value || 1),
storage: parseInt(this.elements.storageRange?.value || 20),
instances: parseInt(this.elements.instancesRange?.value || 1),
serviceLevel: this.getSelectedServiceLevel()
};
}
}
// Export for use in other modules
window.DOMManager = DOMManager;

View file

@ -1,121 +0,0 @@
/**
* Order Manager - Handles order form functionality
*/
class OrderManager {
constructor() {
this.selectedConfiguration = null;
}
// Setup order button click handler
setupOrderButton(domManager) {
const orderButton = domManager.get('orderButton');
if (orderButton) {
orderButton.addEventListener('click', (e) => {
e.preventDefault();
this.handleOrderClick();
});
}
}
// Handle order button click
handleOrderClick() {
if (this.selectedConfiguration) {
// Pre-fill the contact form with configuration details
this.prefillContactForm();
// Scroll to the contact form
const contactForm = document.getElementById('order-form');
if (contactForm) {
contactForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}
// Pre-fill contact form with selected configuration
prefillContactForm() {
if (!this.selectedConfiguration) return;
const config = this.selectedConfiguration;
// Create configuration summary message
const configMessage = this.generateConfigurationMessage(config);
// Find and fill the message textarea in the contact form
const messageField = document.querySelector('#order-form textarea[name="message"]');
if (messageField) {
messageField.value = configMessage;
}
// Find and fill alternative message field if the first one doesn't exist
if (!messageField) {
const altMessageField = document.querySelector('textarea[name="message"]');
if (altMessageField) {
altMessageField.value = configMessage;
}
}
// Store configuration details in hidden field if it exists
const detailsField = document.querySelector('#order-form input[name="details"]');
if (detailsField) {
detailsField.value = JSON.stringify({
plan: config.planName,
vcpus: config.vcpus,
memory: config.memory,
storage: config.storage,
instances: config.instances,
serviceLevel: config.serviceLevel,
totalPrice: config.totalPrice,
addons: config.addons || []
});
}
}
// Generate human-readable configuration message
generateConfigurationMessage(config) {
let message = `I would like to order the following configuration:
Plan: ${config.planName} (${config.planGroup})
vCPUs: ${config.vcpus}
Memory: ${config.memory} GB
Storage: ${config.storage} GB
Instances: ${config.instances}
Service Level: ${config.serviceLevel}`;
// Add addons to the message if any are selected
if (config.addons && config.addons.length > 0) {
message += '\n\nSelected Add-ons:';
config.addons.forEach(addon => {
message += `\n- ${addon.name}: CHF ${addon.price}`;
});
}
message += `\n\nTotal Monthly Price: CHF ${config.totalPrice}
Please contact me with next steps for ordering this configuration.`;
return message;
}
// Store current configuration for order button
storeConfiguration(plan, config, serviceLevel, totalPrice, addons) {
this.selectedConfiguration = {
planName: plan.compute_plan,
planGroup: plan.groupName,
vcpus: plan.vcpus,
memory: plan.ram,
storage: config.storage,
instances: config.instances,
serviceLevel: serviceLevel,
totalPrice: totalPrice,
addons: addons
};
}
// Get stored configuration
getStoredConfiguration() {
return this.selectedConfiguration;
}
}
// Export for use in other modules
window.OrderManager = OrderManager;

View file

@ -1,113 +0,0 @@
/**
* Plan Manager - Handles plan selection and matching logic
*/
class PlanManager {
constructor(pricingDataManager) {
this.pricingDataManager = pricingDataManager;
}
// Find best matching plan based on requirements
findBestMatchingPlan(cpus, memory, serviceLevel) {
const pricingData = this.pricingDataManager.getPricingData();
if (!pricingData) return null;
let bestMatch = null;
let bestScore = Infinity;
// Iterate through all groups and service levels
Object.keys(pricingData).forEach(groupName => {
const group = pricingData[groupName];
if (group[serviceLevel]) {
group[serviceLevel].forEach(plan => {
const planCpus = parseFloat(plan.vcpus);
const planMemory = parseFloat(plan.ram);
// Check if plan meets minimum requirements
if (planCpus >= cpus && planMemory >= memory) {
// Calculate efficiency score (lower is better)
const cpuOverhead = planCpus - cpus;
const memoryOverhead = planMemory - memory;
const score = cpuOverhead + memoryOverhead + plan.final_price * 0.1;
if (score < bestScore) {
bestScore = score;
bestMatch = {
...plan,
groupName: groupName
};
}
}
});
}
});
return bestMatch;
}
// Get current plan based on configuration
getCurrentPlan(domManager) {
const config = domManager.getCurrentConfiguration();
const planSelect = domManager.get('planSelect');
if (planSelect?.value) {
return JSON.parse(planSelect.value);
}
return this.findBestMatchingPlan(config.cpus, config.memory, config.serviceLevel);
}
// Populate plan dropdown based on selected service level
populatePlanDropdown(domManager) {
const planSelect = domManager.get('planSelect');
if (!planSelect) return;
const serviceLevel = domManager.getSelectedServiceLevel();
if (!serviceLevel) {
// Clear dropdown if no service level is selected
planSelect.innerHTML = '<option value="">Select a service level first</option>';
return;
}
// Clear existing options
planSelect.innerHTML = '<option value="">Auto-select best matching plan</option>';
// Get plans for the selected service level
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
if (!availablePlans || availablePlans.length === 0) {
planSelect.innerHTML = '<option value="">No plans available for this service level</option>';
return;
}
// Add plans to dropdown
availablePlans.forEach(plan => {
const option = document.createElement('option');
option.value = JSON.stringify(plan);
option.textContent = `${plan.compute_plan} - ${plan.vcpus} vCPUs, ${plan.ram} GB RAM`;
planSelect.appendChild(option);
});
}
// Update sliders to match selected plan
updateSlidersForPlan(plan, domManager) {
const cpuRange = domManager.get('cpuRange');
const memoryRange = domManager.get('memoryRange');
const cpuValue = domManager.get('cpuValue');
const memoryValue = domManager.get('memoryValue');
if (cpuRange && cpuValue) {
cpuRange.value = plan.vcpus;
cpuValue.textContent = plan.vcpus;
}
if (memoryRange && memoryValue) {
memoryRange.value = plan.ram;
memoryValue.textContent = plan.ram;
}
}
}
// Export for use in other modules
window.PlanManager = PlanManager;

View file

@ -1,502 +0,0 @@
/**
* Price Calculator - Main orchestrator class
* Coordinates all the different managers to provide pricing calculation functionality
*/
class PriceCalculator {
constructor() {
try {
// Initialize managers
this.domManager = new DOMManager();
this.currentOffering = this.extractOfferingFromURL();
if (!this.currentOffering) {
throw new Error('Unable to extract offering information from URL');
}
this.pricingDataManager = new PricingDataManager(this.currentOffering);
this.planManager = new PlanManager(this.pricingDataManager);
this.addonManager = new AddonManager(this.pricingDataManager);
this.uiManager = new UIManager();
this.orderManager = new OrderManager();
// Initialize the calculator
this.init();
} catch (error) {
console.error('Error initializing PriceCalculator:', error);
this.showInitializationError(error.message);
}
}
// Extract offering info from URL
extractOfferingFromURL() {
const pathParts = window.location.pathname.split('/');
if (pathParts.length >= 4 && pathParts[1] === 'offering') {
return {
provider_slug: pathParts[2],
service_slug: pathParts[3]
};
}
return null;
}
// Initialize calculator
async init() {
try {
// Load pricing data and setup calculator
if (this.currentOffering) {
await this.pricingDataManager.loadPricingData();
this.setupEventListeners();
this.setupUI();
this.orderManager.setupOrderButton(this.domManager);
this.updateCalculator();
} else {
throw new Error('No current offering found');
}
} catch (error) {
console.error('Error initializing price calculator:', error);
this.showInitializationError(error.message);
}
}
// Show initialization error to user
showInitializationError(message) {
const planMatchStatus = this.domManager?.get('planMatchStatus');
if (planMatchStatus) {
planMatchStatus.innerHTML = `
<i class="bi bi-exclamation-triangle me-2 text-danger"></i>
<span class="text-danger">Failed to load pricing calculator: ${message}</span>
`;
planMatchStatus.className = 'alert alert-danger mb-3';
planMatchStatus.style.display = 'block';
}
}
// Setup initial UI components
setupUI() {
// Setup service levels based on available data
this.uiManager.setupServiceLevels(this.domManager, this.pricingDataManager);
// Calculate and set slider maximums and ranges
this.uiManager.updateSliderMaximums(this.domManager, this.pricingDataManager);
// Set smart default values based on available plans
this.domManager.setSmartDefaults(this.pricingDataManager);
// Populate plan dropdown
this.planManager.populatePlanDropdown(this.domManager);
// Initialize instances slider
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
// Setup service level event listeners after UI is created
this.setupServiceLevelEventListeners();
}
// Setup event listeners for calculator controls
setupEventListeners() {
const cpuRange = this.domManager.get('cpuRange');
const memoryRange = this.domManager.get('memoryRange');
const storageRange = this.domManager.get('storageRange');
const instancesRange = this.domManager.get('instancesRange');
if (!cpuRange || !memoryRange || !storageRange || !instancesRange) return;
// Slider event listeners
cpuRange.addEventListener('input', () => {
this.domManager.get('cpuValue').textContent = cpuRange.value;
// Only synchronize if in auto-select mode (no manual plan selection)
const planSelect = this.domManager.get('planSelect');
if (!planSelect?.value) {
this.synchronizeMemoryToMatchingPlan(parseFloat(cpuRange.value));
}
this.updatePricing();
});
memoryRange.addEventListener('input', () => {
this.domManager.get('memoryValue').textContent = memoryRange.value;
// Only synchronize if in auto-select mode (no manual plan selection)
const planSelect = this.domManager.get('planSelect');
if (!planSelect?.value) {
this.synchronizeCpuToMatchingPlan(parseFloat(memoryRange.value));
}
this.updatePricing();
});
storageRange.addEventListener('input', () => {
this.domManager.get('storageValue').textContent = storageRange.value;
this.updatePricing();
});
instancesRange.addEventListener('input', () => {
this.domManager.get('instancesValue').textContent = instancesRange.value;
this.updatePricing();
});
// Plan selection listener
const planSelect = this.domManager.get('planSelect');
if (planSelect) {
planSelect.addEventListener('change', () => {
if (planSelect.value) {
const selectedPlan = JSON.parse(planSelect.value);
// Update sliders to match selected plan
this.planManager.updateSlidersForPlan(selectedPlan, this.domManager);
// Fade out CPU and Memory sliders since plan is manually selected
this.uiManager.fadeOutSliders(this.domManager, ['cpu', 'memory']);
// Update addons for the new configuration
this.addonManager.updateAddons(this.domManager);
// Update pricing with the selected plan
this.updatePricingWithPlan(selectedPlan);
} else {
// Auto-select mode - reset sliders to smart default values
this.domManager.setSmartDefaults(this.pricingDataManager);
// Auto-select mode - fade sliders back in
this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']);
// Auto-select mode - update addons and recalculate
this.addonManager.updateAddons(this.domManager);
this.updatePricing();
}
});
}
// Listen for addon changes
window.addEventListener('addon-changed', () => {
this.updatePricing();
});
}
// Setup service level event listeners (called after UI setup)
setupServiceLevelEventListeners() {
// Service level change listener
const serviceLevelInputs = this.domManager.get('serviceLevelInputs');
if (serviceLevelInputs) {
serviceLevelInputs.forEach((input, index) => {
input.addEventListener('change', () => {
try {
// Check if a plan is currently selected before updating
const planSelect = this.domManager.get('planSelect');
const currentlySelectedPlan = planSelect?.value ? JSON.parse(planSelect.value) : null;
// Update instances slider for the new service level (functionality from UIManager)
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
// Update plan dropdown for new service level
this.planManager.populatePlanDropdown(this.domManager);
// Update addons for new service level first
this.addonManager.updateAddons(this.domManager);
// If a plan was previously selected, try to maintain selection
if (currentlySelectedPlan && planSelect) {
// Find the same plan in the new dropdown options
const options = planSelect.querySelectorAll('option');
let planFound = false;
let matchingPlan = null;
for (const option of options) {
if (option.value) {
try {
const optionPlan = JSON.parse(option.value);
// First, try to match by exact plan name
if (optionPlan.compute_plan === currentlySelectedPlan.compute_plan) {
matchingPlan = optionPlan;
planFound = true;
break;
}
} catch (e) {
console.warn('Error parsing plan option:', e);
}
}
}
if (planFound && matchingPlan) {
// Set the plan selection
planSelect.value = JSON.stringify(matchingPlan);
// Maintain the UI state for manually selected plan
this.planManager.updateSlidersForPlan(matchingPlan, this.domManager);
this.uiManager.fadeOutSliders(this.domManager, ['cpu', 'memory']);
// Update pricing with the selected plan
this.updatePricingWithPlan(matchingPlan);
} else {
planSelect.value = '';
// Reset sliders to smart defaults and fade them back in
this.domManager.setSmartDefaults(this.pricingDataManager);
this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']);
// Update pricing in auto-select mode
this.updatePricing();
}
} else {
// No plan was previously selected, just update pricing
this.updatePricing();
}
} catch (error) {
console.error('Error in service level change handler:', error);
// Fallback to basic functionality if there's an error
this.updatePricing();
}
});
});
}
}
// Update calculator (initial setup)
updateCalculator() {
this.addonManager.updateAddons(this.domManager);
this.updatePricing();
}
// Update pricing with specific plan
updatePricingWithPlan(selectedPlan) {
const config = this.domManager.getCurrentConfiguration();
// Update addon prices first to ensure calculated prices are current
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
this.showPlanDetails(selectedPlan, config.storage, config.instances);
this.uiManager.updateStatusMessage(this.domManager, 'Plan selected directly!', 'success');
}
// Main pricing update function
updatePricing() {
// Update addon prices first to ensure they're current
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
const planSelect = this.domManager.get('planSelect');
// Reset plan selection if in auto-select mode
if (!planSelect?.value) {
const config = this.domManager.getCurrentConfiguration();
if (!config.serviceLevel) {
return;
}
// Find best matching plan
const matchedPlan = this.planManager.findBestMatchingPlan(config.cpus, config.memory, config.serviceLevel);
if (matchedPlan) {
this.showPlanDetails(matchedPlan, config.storage, config.instances);
this.uiManager.updateStatusMessage(this.domManager, 'Perfect match found!', 'success');
} else {
this.uiManager.showNoMatch(this.domManager);
}
} else {
// Plan is directly selected, update storage pricing
const selectedPlan = JSON.parse(planSelect.value);
const config = this.domManager.getCurrentConfiguration();
// Update addon prices for current configuration
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
this.showPlanDetails(selectedPlan, config.storage, config.instances);
this.uiManager.updateStatusMessage(this.domManager, 'Plan selected directly!', 'success');
}
}
// Show plan details in the UI
showPlanDetails(plan, storage, instances) {
// Get current service level
const serviceLevel = this.domManager.getSelectedServiceLevel() || 'Best Effort';
// Ensure addon prices are calculated with current configuration
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
// Calculate pricing using final price from plan data (which already includes mandatory addons)
const managedServicePricePerInstance = parseFloat(plan.final_price);
// Collect addon information for display and calculation
const addons = this.addonManager.getSelectedAddons(this.domManager);
const optionalAddonTotal = this.addonManager.calculateOptionalAddonTotal(this.domManager);
const managedServicePrice = managedServicePricePerInstance * instances;
// Use storage price from plan data or fallback to instance variable
const storageUnitPrice = plan.storage_price !== undefined ?
parseFloat(plan.storage_price) :
this.pricingDataManager.getStoragePrice();
const storagePriceValue = storage * storageUnitPrice * instances;
// Total price = managed service price (includes mandatory addons) + storage + optional addons
const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal;
// Show plan details in UI
this.uiManager.showPlanDetails(
this.domManager,
plan,
storage,
instances,
serviceLevel,
managedServicePrice,
storagePriceValue,
totalPriceValue
);
// Update addon pricing display
this.uiManager.updateAddonPricingDisplay(this.domManager, addons.mandatory, addons.optional);
// Store current configuration for order button
this.orderManager.storeConfiguration(
plan,
{ storage, instances },
serviceLevel,
totalPriceValue.toFixed(2),
[...addons.mandatory, ...addons.optional]
);
}
// Synchronize memory slider to match CPU value with best matching plan
synchronizeMemoryToMatchingPlan(targetCpu) {
const serviceLevel = this.domManager.getSelectedServiceLevel();
if (!serviceLevel) return;
// Get all available plans for the current service level
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
if (!availablePlans || availablePlans.length === 0) return;
// Snap CPU to nearest available value first
const { cpuValues } = this.pricingDataManager.getAvailableSliderValues();
const snappedCpu = this.findNearestValue(targetCpu, cpuValues);
// Update CPU slider to snapped value if different
if (snappedCpu !== targetCpu) {
const cpuRange = this.domManager.get('cpuRange');
const cpuValue = this.domManager.get('cpuValue');
if (cpuRange && cpuValue) {
cpuRange.value = snappedCpu;
cpuValue.textContent = snappedCpu;
}
}
// Find the plan that best matches the snapped CPU requirement
let bestPlan = null;
let minDifference = Infinity;
availablePlans.forEach(plan => {
const planCpu = parseFloat(plan.vcpus);
// Look for plans that meet or exceed the CPU requirement
if (planCpu >= snappedCpu) {
const difference = planCpu - snappedCpu;
if (difference < minDifference) {
minDifference = difference;
bestPlan = plan;
}
}
});
// If no plan meets the CPU requirement, find the closest one below it
if (!bestPlan) {
availablePlans.forEach(plan => {
const planCpu = parseFloat(plan.vcpus);
const difference = Math.abs(planCpu - snappedCpu);
if (difference < minDifference) {
minDifference = difference;
bestPlan = plan;
}
});
}
// Update memory slider to match the found plan
if (bestPlan) {
const memoryRange = this.domManager.get('memoryRange');
const memoryValue = this.domManager.get('memoryValue');
if (memoryRange && memoryValue) {
memoryRange.value = bestPlan.ram;
memoryValue.textContent = bestPlan.ram;
}
}
}
// Synchronize CPU slider to match memory value with best matching plan
synchronizeCpuToMatchingPlan(targetMemory) {
const serviceLevel = this.domManager.getSelectedServiceLevel();
if (!serviceLevel) return;
// Get all available plans for the current service level
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
if (!availablePlans || availablePlans.length === 0) return;
// Snap memory to nearest available value first
const { memoryValues } = this.pricingDataManager.getAvailableSliderValues();
const snappedMemory = this.findNearestValue(targetMemory, memoryValues);
// Update memory slider to snapped value if different
if (snappedMemory !== targetMemory) {
const memoryRange = this.domManager.get('memoryRange');
const memoryValue = this.domManager.get('memoryValue');
if (memoryRange && memoryValue) {
memoryRange.value = snappedMemory;
memoryValue.textContent = snappedMemory;
}
}
// Find the plan that best matches the snapped memory requirement
let bestPlan = null;
let minDifference = Infinity;
availablePlans.forEach(plan => {
const planMemory = parseFloat(plan.ram);
// Look for plans that meet or exceed the memory requirement
if (planMemory >= snappedMemory) {
const difference = planMemory - snappedMemory;
if (difference < minDifference) {
minDifference = difference;
bestPlan = plan;
}
}
});
// If no plan meets the memory requirement, find the closest one below it
if (!bestPlan) {
availablePlans.forEach(plan => {
const planMemory = parseFloat(plan.ram);
const difference = Math.abs(planMemory - snappedMemory);
if (difference < minDifference) {
minDifference = difference;
bestPlan = plan;
}
});
}
// Update CPU slider to match the found plan
if (bestPlan) {
const cpuRange = this.domManager.get('cpuRange');
const cpuValue = this.domManager.get('cpuValue');
if (cpuRange && cpuValue) {
cpuRange.value = bestPlan.vcpus;
cpuValue.textContent = bestPlan.vcpus;
}
}
}
// Find the nearest value in an array to a target value
findNearestValue(target, availableValues) {
if (!availableValues || availableValues.length === 0) return target;
let nearest = availableValues[0];
let minDifference = Math.abs(target - nearest);
for (let i = 1; i < availableValues.length; i++) {
const difference = Math.abs(target - availableValues[i]);
if (difference < minDifference) {
minDifference = difference;
nearest = availableValues[i];
}
}
return nearest;
}
}
// Export for use in other modules
window.PriceCalculator = PriceCalculator;

View file

@ -1,225 +0,0 @@
/**
* Pricing Data Manager - Handles API calls and data extraction
*/
class PricingDataManager {
constructor(currentOffering) {
this.currentOffering = currentOffering;
this.pricingData = null;
this.storagePrice = null;
this.replicaInfo = null;
this.addonsData = null;
}
// Load pricing data from API endpoint
async loadPricingData() {
try {
const url = `/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load pricing data: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!data || typeof data !== 'object') {
throw new Error('Invalid pricing data received from server');
}
this.pricingData = data.pricing || data;
// Validate that we have usable pricing data
if (!this.pricingData || Object.keys(this.pricingData).length === 0) {
throw new Error('No pricing data available for this offering');
}
// Extract addons data from the plans - addons are embedded in each plan
this.extractAddonsData();
// Extract storage price from the first available plan
this.extractStoragePrice();
return this.pricingData;
} catch (error) {
console.error('Error loading pricing data:', error);
throw error;
}
}
// Extract replica information and storage price from pricing data
extractStoragePrice() {
if (!this.pricingData) return;
// Find the first plan with storage pricing data and replica info
for (const groupName of Object.keys(this.pricingData)) {
const group = this.pricingData[groupName];
for (const serviceLevel of Object.keys(group)) {
const plans = group[serviceLevel];
if (plans.length > 0 && plans[0].storage_price !== undefined) {
this.storagePrice = parseFloat(plans[0].storage_price);
this.replicaInfo = {
ha_replica_min: plans[0].ha_replica_min || 1,
ha_replica_max: plans[0].ha_replica_max || 1
};
return;
}
}
}
}
// Extract addons data from pricing plans
extractAddonsData() {
if (!this.pricingData) return;
this.addonsData = {};
// Extract addons from the first available plan for each service level
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
Object.keys(group).forEach(serviceLevel => {
const plans = group[serviceLevel];
if (plans.length > 0) {
// Use the first plan's addon data for this service level
const plan = plans[0];
const allAddons = [];
// Add mandatory addons
if (plan.mandatory_addons) {
plan.mandatory_addons.forEach(addon => {
allAddons.push({
...addon,
is_mandatory: true,
addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE"
});
});
}
// Add optional addons
if (plan.optional_addons) {
plan.optional_addons.forEach(addon => {
allAddons.push({
...addon,
is_mandatory: false,
addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE"
});
});
}
this.addonsData[serviceLevel] = allAddons;
}
});
});
}
// Get available service levels from pricing data
getAvailableServiceLevels() {
if (!this.pricingData) return new Set();
const availableServiceLevels = new Set();
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
Object.keys(group).forEach(serviceLevel => {
availableServiceLevels.add(serviceLevel);
});
});
return availableServiceLevels;
}
// Get maximum CPU and memory values from all plans
getSliderMaximums() {
if (!this.pricingData) return { maxCpus: 0, maxMemory: 0 };
let maxCpus = 0;
let maxMemory = 0;
// Find maximum CPU and memory across all plans
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
Object.keys(group).forEach(serviceLevel => {
group[serviceLevel].forEach(plan => {
const planCpus = parseFloat(plan.vcpus);
const planMemory = parseFloat(plan.ram);
if (planCpus > maxCpus) maxCpus = planCpus;
if (planMemory > maxMemory) maxMemory = planMemory;
});
});
});
return { maxCpus, maxMemory };
}
// Get all unique CPU and memory values from plans
getAvailableSliderValues() {
if (!this.pricingData) return { cpuValues: [], memoryValues: [] };
const cpuSet = new Set();
const memorySet = new Set();
// Collect all unique CPU and memory values across all plans
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
Object.keys(group).forEach(serviceLevel => {
group[serviceLevel].forEach(plan => {
cpuSet.add(parseFloat(plan.vcpus));
memorySet.add(parseFloat(plan.ram));
});
});
});
// Convert to sorted arrays
const cpuValues = Array.from(cpuSet).sort((a, b) => a - b);
const memoryValues = Array.from(memorySet).sort((a, b) => a - b);
return { cpuValues, memoryValues };
}
// Get all plans for a specific service level
getPlansForServiceLevel(serviceLevel) {
if (!this.pricingData || !serviceLevel) return [];
const availablePlans = [];
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
if (group[serviceLevel]) {
group[serviceLevel].forEach(plan => {
availablePlans.push({
...plan,
groupName: groupName
});
});
}
});
// Sort plans by vCPU, then by RAM
availablePlans.sort((a, b) => {
if (parseFloat(a.vcpus) !== parseFloat(b.vcpus)) {
return parseFloat(a.vcpus) - parseFloat(b.vcpus);
}
return parseFloat(a.ram) - parseFloat(b.ram);
});
return availablePlans;
}
// Getters
getPricingData() {
return this.pricingData;
}
getStoragePrice() {
return this.storagePrice;
}
getReplicaInfo() {
return this.replicaInfo;
}
getAddonsData() {
return this.addonsData;
}
}
// Export for use in other modules
window.PricingDataManager = PricingDataManager;

View file

@ -1,330 +0,0 @@
/**
* UI Manager - Handles UI updates and visual feedback
*/
class UIManager {
constructor() {
// Visual feedback states
this.isSlidersFaded = false;
}
// Update status message
updateStatusMessage(domManager, message, type) {
const planMatchStatus = domManager.get('planMatchStatus');
if (!planMatchStatus) return;
const iconClass = type === 'success' ? 'bi-check-circle' : 'bi-info-circle';
const textClass = type === 'success' ? 'text-success' : '';
const alertClass = type === 'success' ? 'alert-success' : 'alert-info';
planMatchStatus.innerHTML = `<i class="bi ${iconClass} me-2 ${textClass}"></i><span class="${textClass}">${message}</span>`;
planMatchStatus.className = `alert ${alertClass} mb-3`;
planMatchStatus.style.display = 'block';
}
// Show error message
showError(domManager, message) {
const planMatchStatus = domManager.get('planMatchStatus');
if (planMatchStatus) {
planMatchStatus.innerHTML = `<i class="bi bi-exclamation-triangle me-2 text-danger"></i><span class="text-danger">${message}</span>`;
planMatchStatus.className = 'alert alert-danger mb-3';
planMatchStatus.style.display = 'block';
}
}
// Show no matching plan found
showNoMatch(domManager) {
const planMatchStatus = domManager.get('planMatchStatus');
const selectedPlanDetails = domManager.get('selectedPlanDetails');
const noMatchFound = domManager.get('noMatchFound');
if (planMatchStatus) planMatchStatus.style.display = 'none';
if (selectedPlanDetails) selectedPlanDetails.style.display = 'none';
if (noMatchFound) noMatchFound.style.display = 'block';
}
// Show plan details in the UI
showPlanDetails(domManager, plan, storage, instances, serviceLevel, managedServicePrice, storagePriceValue, totalPriceValue) {
const selectedPlanDetails = domManager.get('selectedPlanDetails');
if (!selectedPlanDetails) return;
// Show plan details section
const planMatchStatus = domManager.get('planMatchStatus');
const noMatchFound = domManager.get('noMatchFound');
if (planMatchStatus) planMatchStatus.style.display = 'block';
selectedPlanDetails.style.display = 'block';
if (noMatchFound) noMatchFound.style.display = 'none';
// Update plan information
const planGroup = domManager.get('planGroup');
const planName = domManager.get('planName');
const planDescription = domManager.get('planDescription');
const planCpus = domManager.get('planCpus');
const planMemory = domManager.get('planMemory');
const planInstances = domManager.get('planInstances');
const planServiceLevel = domManager.get('planServiceLevel');
const managedServicePriceEl = domManager.get('managedServicePrice');
const storagePriceEl = domManager.get('storagePriceEl');
const storageAmount = domManager.get('storageAmount');
const totalPrice = domManager.get('totalPrice');
if (planGroup) planGroup.textContent = plan.groupName;
if (planName) planName.textContent = plan.compute_plan;
if (planDescription) planDescription.textContent = plan.compute_plan_group_description || '';
if (planCpus) planCpus.textContent = plan.vcpus;
if (planMemory) planMemory.textContent = plan.ram + ' GB';
if (planInstances) planInstances.textContent = instances;
if (planServiceLevel) planServiceLevel.textContent = serviceLevel;
// Update pricing display
if (managedServicePriceEl) managedServicePriceEl.textContent = managedServicePrice.toFixed(2);
if (storagePriceEl) storagePriceEl.textContent = storagePriceValue.toFixed(2);
if (storageAmount) storageAmount.textContent = storage;
if (totalPrice) totalPrice.textContent = totalPriceValue.toFixed(2);
}
// Update addon pricing display in the results panel
updateAddonPricingDisplay(domManager, mandatoryAddons, selectedOptionalAddons) {
// Get references to the managed service includes elements
const managedServiceIncludesContainer = domManager.get('managedServiceIncludesContainer');
if (managedServiceIncludesContainer) {
// Clear existing content
managedServiceIncludesContainer.innerHTML = '';
// Always add "Compute" as the first item
const computeRow = document.createElement('div');
computeRow.className = 'd-flex justify-content-between small text-muted mb-1';
computeRow.innerHTML = `
<span>Compute (vCPUs & Memory)</span>
<span>Included</span>
`;
managedServiceIncludesContainer.appendChild(computeRow);
// Add mandatory addons to the managed service includes section
if (mandatoryAddons && mandatoryAddons.length > 0) {
mandatoryAddons.forEach(addon => {
const addonRow = document.createElement('div');
addonRow.className = 'd-flex justify-content-between small text-muted mb-1';
addonRow.innerHTML = `
<span>Add-on: ${addon.name}</span>
<span>CHF ${addon.price}</span>
`;
managedServiceIncludesContainer.appendChild(addonRow);
});
}
}
// Update optional addons in the addon pricing container
const addonPricingContainer = domManager.get('addonPricingContainer');
if (!addonPricingContainer) return;
// Clear existing addon pricing display
addonPricingContainer.innerHTML = '';
// Add optional addons to pricing breakdown (these are added to total)
if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
selectedOptionalAddons.forEach(addon => {
const addonRow = document.createElement('div');
addonRow.className = 'd-flex justify-content-between align-items-center mb-2';
addonRow.innerHTML = `
<span class="text-nowrap flex-shrink-1" style="min-width: 0;">Add-on: ${addon.name}</span>
<span class="fw-bold text-nowrap flex-shrink-0" style="min-width: 110px; text-align: right;">CHF ${addon.price}</span>
`;
addonPricingContainer.appendChild(addonRow);
});
}
}
// Fade out specified sliders when plan is manually selected
fadeOutSliders(domManager, sliderTypes) {
sliderTypes.forEach(type => {
const sliderContainer = domManager.getSliderContainer(type);
if (sliderContainer) {
sliderContainer.style.transition = 'opacity 0.3s ease-in-out';
sliderContainer.style.opacity = '0.3';
sliderContainer.style.pointerEvents = 'none';
// Add visual indicator that sliders are disabled
const slider = sliderContainer.querySelector('.form-range');
if (slider) {
slider.style.cursor = 'not-allowed';
}
}
});
this.isSlidersFaded = true;
}
// Fade in specified sliders when auto-select mode is chosen
fadeInSliders(domManager, sliderTypes) {
sliderTypes.forEach(type => {
const sliderContainer = domManager.getSliderContainer(type);
if (sliderContainer) {
sliderContainer.style.transition = 'opacity 0.3s ease-in-out';
sliderContainer.style.opacity = '1';
sliderContainer.style.pointerEvents = 'auto';
// Remove visual indicator
const slider = sliderContainer.querySelector('.form-range');
if (slider) {
slider.style.cursor = 'pointer';
}
}
});
this.isSlidersFaded = false;
}
// Setup service levels dynamically from pricing data
setupServiceLevels(domManager, pricingDataManager) {
const serviceLevelGroup = domManager.get('serviceLevelGroup');
if (!serviceLevelGroup) return;
// Get all available service levels from the pricing data
const availableServiceLevels = pricingDataManager.getAvailableServiceLevels();
// Clear existing service level buttons
serviceLevelGroup.innerHTML = '';
// Create buttons for each available service level
let isFirst = true;
availableServiceLevels.forEach(serviceLevel => {
const inputId = `serviceLevel${serviceLevel.replace(/\s+/g, '')}`;
// Create radio input
const input = document.createElement('input');
input.type = 'radio';
input.className = 'btn-check';
input.name = 'serviceLevel';
input.id = inputId;
input.value = serviceLevel;
if (isFirst) {
input.checked = true;
isFirst = false;
}
// Create label
const label = document.createElement('label');
label.className = 'btn btn-outline-primary';
label.setAttribute('for', inputId);
label.textContent = serviceLevel;
serviceLevelGroup.appendChild(input);
serviceLevelGroup.appendChild(label);
});
// Update the serviceLevelInputs reference
domManager.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
// Note: Event listeners are now handled in price-calculator.js setupEventListeners method
// to properly preserve plan selection when service level changes
// this.setupServiceLevelEventListeners(domManager, pricingDataManager);
}
// Setup event listeners for service level inputs
setupServiceLevelEventListeners(domManager, pricingDataManager) {
const serviceLevelInputs = domManager.get('serviceLevelInputs');
if (!serviceLevelInputs) return;
// Get the main price calculator instance from window
const priceCalculator = window.priceCalculator;
if (!priceCalculator) return;
serviceLevelInputs.forEach(input => {
input.addEventListener('change', () => {
this.updateInstancesSlider(domManager, pricingDataManager);
priceCalculator.planManager.populatePlanDropdown(domManager);
priceCalculator.addonManager.updateAddons(domManager);
priceCalculator.updatePricing();
});
});
}
// Update slider maximums based on pricing data
updateSliderMaximums(domManager, pricingDataManager) {
const cpuRange = domManager.get('cpuRange');
const memoryRange = domManager.get('memoryRange');
if (!cpuRange || !memoryRange) return;
const { cpuValues, memoryValues } = pricingDataManager.getAvailableSliderValues();
// Set CPU slider range based on available plan values
if (cpuValues.length > 0) {
cpuRange.min = Math.min(...cpuValues);
cpuRange.max = Math.max(...cpuValues);
// Calculate step size - use the smallest difference between consecutive values
const cpuStep = this.calculateOptimalStep(cpuValues);
cpuRange.step = cpuStep;
}
// Set Memory slider range based on available plan values
if (memoryValues.length > 0) {
memoryRange.min = Math.min(...memoryValues);
memoryRange.max = Math.max(...memoryValues);
// Calculate step size - use the smallest difference between consecutive values
const memoryStep = this.calculateOptimalStep(memoryValues);
memoryRange.step = memoryStep;
}
// Update display values after changing min/max
domManager.updateSliderDisplayValues();
}
// Calculate optimal step size for slider based on available values
calculateOptimalStep(values) {
if (values.length <= 1) return 0.25; // Default step
// Find the smallest difference between consecutive values
let minDiff = Infinity;
for (let i = 1; i < values.length; i++) {
const diff = values[i] - values[i - 1];
if (diff > 0 && diff < minDiff) {
minDiff = diff;
}
}
// Use the minimum difference as step, but ensure it's reasonable
// Round to common step values (0.25, 0.5, 1, etc.)
if (minDiff <= 0.25) return 0.25;
if (minDiff <= 0.5) return 0.5;
if (minDiff <= 1) return 1;
return Math.ceil(minDiff);
}
// Update instances slider based on service level and replica info
updateInstancesSlider(domManager, pricingDataManager) {
const instancesRange = domManager.get('instancesRange');
const instancesValue = domManager.get('instancesValue');
const replicaInfo = pricingDataManager.getReplicaInfo();
if (!instancesRange || !replicaInfo) return;
const serviceLevel = domManager.getSelectedServiceLevel();
if (serviceLevel === 'Guaranteed Availability') {
// For GA, min is ha_replica_min
instancesRange.min = replicaInfo.ha_replica_min;
instancesRange.value = Math.max(instancesRange.value, replicaInfo.ha_replica_min);
} else {
// For BE, min is 1
instancesRange.min = 1;
instancesRange.value = Math.max(instancesRange.value, 1);
}
// Set max to ha_replica_max
instancesRange.max = replicaInfo.ha_replica_max;
// Update display value
if (instancesValue) instancesValue.textContent = instancesRange.value;
// Update the min/max display under the slider
const instancesMinDisplay = document.getElementById('instancesMinDisplay');
const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
if (instancesMinDisplay) instancesMinDisplay.textContent = instancesRange.min;
if (instancesMaxDisplay) instancesMaxDisplay.textContent = instancesRange.max;
}
}
// Export for use in other modules
window.UIManager = UIManager;

View file

@ -1,105 +0,0 @@
/**
* ROI Calculator - Modular Version
* This file loads all modules and provides global function wrappers for backward compatibility
*/
// Global function wrappers for backward compatibility with existing HTML
function updateCalculations() {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.updateCalculations();
}
}
function exportToPDF() {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.exportToPDF();
}
}
function exportToCSV() {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.exportToCSV();
}
}
function handleInvestmentAmountInput(input) {
InputUtils.handleInvestmentAmountInput(input);
}
function updateInvestmentAmount(value) {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.updateInvestmentAmount(value);
}
}
function updateRevenuePerInstance(value) {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.updateRevenuePerInstance(value);
}
}
function updateServalaShare(value) {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.updateServalaShare(value);
}
}
function updateGracePeriod(value) {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.updateGracePeriod(value);
}
}
function updateLoanRate(value) {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.updateLoanRate(value);
}
}
function updateScenarioChurn(scenarioKey, churnRate) {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.updateScenarioChurn(scenarioKey, churnRate);
}
}
function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth);
}
}
function resetAdvancedParameters() {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.resetAdvancedParameters();
}
}
function toggleScenario(scenarioKey) {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.toggleScenario(scenarioKey);
}
}
function toggleCollapsible(elementId) {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.toggleCollapsible(elementId);
}
}
function resetCalculator() {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.resetCalculator();
}
}
function toggleInvestmentModel() {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.toggleInvestmentModel();
}
}
function logout() {
if (window.ROICalculatorApp) {
window.ROICalculatorApp.logout();
}
}

View file

@ -1,34 +0,0 @@
# ROI Calculator Modules
This directory contains the modular ROI Calculator implementation, split into focused, maintainable modules.
## Module Structure
### Core Modules
- **`calculator-core.js`** - Main ROICalculator class with calculation logic
- **`chart-manager.js`** - Chart.js integration and chart rendering
- **`ui-manager.js`** - DOM updates, table rendering, and metric display
- **`export-manager.js`** - PDF and CSV export functionality
- **`input-utils.js`** - Input validation, parsing, and formatting utilities
- **`roi-calculator-app.js`** - Main application coordinator class
### Integration
- **`../roi-calculator-modular.js`** - Global function wrappers for backward compatibility
## Key Improvements
1. **Modular Architecture**: Each module has a single responsibility
2. **Error Handling**: Comprehensive try-catch blocks with graceful fallbacks
3. **No Global Variables**: App instance contained in window.ROICalculatorApp
4. **Type Safety**: Input validation and null checks throughout
5. **Separation of Concerns**: Calculation, UI, charts, and exports are separated
## Usage
All modules are automatically loaded via the HTML template. The ROICalculatorApp class coordinates all modules and provides the same public API as the original monolithic version.
## Backward Compatibility
All existing HTML onclick handlers and function calls continue to work through the global wrapper functions in `roi-calculator-modular.js`.

View file

@ -1,452 +0,0 @@
/**
* Core ROI Calculator Class
* Handles calculation logic and data management
*/
class ROICalculator {
constructor() {
this.scenarios = {
conservative: {
name: 'Conservative',
enabled: true,
churnRate: 0.025, // Lower churn with sticky managed services
phases: [
{ months: 6, newInstancesPerMonth: 15 }, // 5 new clients × 3 instances each
{ months: 6, newInstancesPerMonth: 24 }, // 6 new clients × 4 instances (growth + expansion)
{ months: 12, newInstancesPerMonth: 35 }, // 7 new clients × 5 instances (mature usage)
{ months: 12, newInstancesPerMonth: 40 } // 8 new clients × 5 instances
]
},
moderate: {
name: 'Moderate',
enabled: true,
churnRate: 0.03, // Balanced churn with growth
phases: [
{ months: 6, newInstancesPerMonth: 25 }, // 8 new clients × 3 instances each
{ months: 6, newInstancesPerMonth: 45 }, // 10 new clients × 4.5 instances (expansion)
{ months: 12, newInstancesPerMonth: 70 }, // 12 new clients × 6 instances (diverse services)
{ months: 12, newInstancesPerMonth: 90 } // 15 new clients × 6 instances
]
},
aggressive: {
name: 'Aggressive',
enabled: true,
churnRate: 0.035, // Slightly higher churn with rapid growth
phases: [
{ months: 6, newInstancesPerMonth: 40 }, // 12 new clients × 3.5 instances
{ months: 6, newInstancesPerMonth: 80 }, // 16 new clients × 5 instances (viral growth)
{ months: 12, newInstancesPerMonth: 120 }, // 20 new clients × 6 instances (full adoption)
{ months: 12, newInstancesPerMonth: 150 } // 25 new clients × 6 instances
]
}
};
this.charts = {};
this.monthlyData = {};
this.results = {};
// Note: Charts and initial calculation will be handled by the app coordinator
}
getInputValues() {
try {
// Get investment model with fallback
const investmentModelElement = document.querySelector('input[name="investment-model"]:checked');
const investmentModel = investmentModelElement ? investmentModelElement.value : 'direct';
// Get investment amount with validation
const investmentAmountElement = document.getElementById('investment-amount');
const investmentAmountValue = investmentAmountElement ? investmentAmountElement.getAttribute('data-value') : '500000';
const investmentAmount = parseFloat(investmentAmountValue) || 500000;
// Get timeframe with validation
const timeframeElement = document.getElementById('timeframe');
const timeframe = timeframeElement ? parseInt(timeframeElement.value) || 3 : 3;
// Get loan interest rate with validation
const loanRateElement = document.getElementById('loan-interest-rate');
const loanInterestRate = loanRateElement ? (parseFloat(loanRateElement.value) || 5.0) / 100 : 0.05;
// Get revenue per instance with validation
const revenueElement = document.getElementById('revenue-per-instance');
const revenuePerInstance = revenueElement ? parseFloat(revenueElement.value) || 50 : 50;
// Get servala share with validation
const shareElement = document.getElementById('servala-share');
const servalaShare = shareElement ? (parseFloat(shareElement.value) || 25) / 100 : 0.25;
// Get grace period with validation
const graceElement = document.getElementById('grace-period');
const gracePeriod = graceElement ? parseInt(graceElement.value) || 6 : 6;
// Get core service revenue with validation
const coreRevenueElement = document.getElementById('core-service-revenue');
const coreServiceRevenue = coreRevenueElement ? parseFloat(coreRevenueElement.value) || 0 : 0;
// Get currency with validation
const currencyElement = document.getElementById('currency');
const currency = currencyElement ? currencyElement.value || 'CHF' : 'CHF';
return {
investmentAmount,
timeframe,
investmentModel,
loanInterestRate,
revenuePerInstance,
coreServiceRevenue,
servalaShare,
gracePeriod,
currency
};
} catch (error) {
console.error('Error getting input values:', error);
// Return safe default values
return {
investmentAmount: 500000,
timeframe: 3,
investmentModel: 'direct',
loanInterestRate: 0.05,
revenuePerInstance: 50,
coreServiceRevenue: 0,
servalaShare: 0.25,
currency: 'CHF',
gracePeriod: 6
};
}
}
calculateScenario(scenarioKey, inputs) {
try {
const scenario = this.scenarios[scenarioKey];
if (!scenario.enabled) return null;
// Calculate loan payment if using loan model
let monthlyLoanPayment = 0;
if (inputs.investmentModel === 'loan') {
const monthlyRate = inputs.loanInterestRate / 12;
const numPayments = inputs.timeframe * 12;
// Calculate fixed monthly payment using amortization formula
if (monthlyRate > 0) {
monthlyLoanPayment = inputs.investmentAmount *
(monthlyRate * Math.pow(1 + monthlyRate, numPayments)) /
(Math.pow(1 + monthlyRate, numPayments) - 1);
} else {
monthlyLoanPayment = inputs.investmentAmount / numPayments;
}
}
// Market-realistic investment scaling factor (only for direct investment)
// Conservative scaling based on industry standards and economies of scale
const baseInvestment = 500000;
let investmentScaleFactor;
let churnReductionFactor;
let revenueMultiplier;
let acceleratedBreakEvenFactor;
if (inputs.investmentModel === 'loan') {
investmentScaleFactor = 1.0;
churnReductionFactor = 1.0;
revenueMultiplier = 1.0;
acceleratedBreakEvenFactor = 1.0;
} else {
// Conservative linear scaling with market-realistic caps
if (inputs.investmentAmount <= baseInvestment) {
investmentScaleFactor = inputs.investmentAmount / baseInvestment;
} else if (inputs.investmentAmount <= 1000000) {
// 500k to 1M: Moderate linear scaling (1.0x to 1.5x)
const ratio = inputs.investmentAmount / baseInvestment;
investmentScaleFactor = 1.0 + ((ratio - 1.0) * 0.5);
} else if (inputs.investmentAmount <= 1500000) {
// 1M to 1.5M: Conservative scaling (1.5x to 1.8x)
const progress = (inputs.investmentAmount - 1000000) / 500000;
investmentScaleFactor = 1.5 + (progress * 0.3);
} else {
// 1.5M to 2M: Maximum realistic scaling (1.8x to 2.0x)
const progress = (inputs.investmentAmount - 1500000) / 500000;
investmentScaleFactor = 1.8 + (progress * 0.2);
}
// Market-sustainable revenue premiums (10-20% max)
if (inputs.investmentAmount >= 2000000) {
revenueMultiplier = 1.2; // 20% premium - maximum sustainable
} else if (inputs.investmentAmount >= 1500000) {
revenueMultiplier = 1.15; // 15% premium
} else if (inputs.investmentAmount >= 1000000) {
revenueMultiplier = 1.1; // 10% premium
} else {
revenueMultiplier = 1.0; // No premium
}
// Modest break-even improvements (not dramatic acceleration)
if (inputs.investmentAmount >= 1500000) {
acceleratedBreakEvenFactor = 1.15; // 15% faster break-even
} else if (inputs.investmentAmount >= 1000000) {
acceleratedBreakEvenFactor = 1.1; // 10% faster break-even
} else {
acceleratedBreakEvenFactor = 1.0; // Standard break-even
}
// Realistic churn reduction based on customer success investments
const churnReductionRatio = Math.min((inputs.investmentAmount - baseInvestment) / 1500000, 1.0);
churnReductionFactor = Math.max(0.75, 1 - (churnReductionRatio * 0.25)); // Up to 25% churn reduction
}
// Calculate adjusted churn rate with investment-based reduction
const adjustedChurnRate = scenario.churnRate * churnReductionFactor;
const totalMonths = inputs.timeframe * 12;
const monthlyData = [];
let currentInstances = 0;
let cumulativeCSPRevenue = 0;
let cumulativeServalaRevenue = 0;
let breakEvenMonth = null;
// Calculate commercially viable grace period based on investment size
const baseGracePeriod = inputs.gracePeriod;
let gracePeriodBonus;
if (inputs.investmentAmount >= 1500000) {
gracePeriodBonus = 3; // 3 months extra for large investments
} else if (inputs.investmentAmount >= 1000000) {
gracePeriodBonus = 2; // 2 months extra for medium investments
} else {
gracePeriodBonus = Math.floor((inputs.investmentAmount - baseInvestment) / 500000); // More conservative bonus calculation
}
const effectiveGracePeriod = inputs.investmentModel === 'loan' ? 0 :
Math.min(baseGracePeriod + gracePeriodBonus, 6); // Maximum 6 months grace period
// Track baseline performance for performance bonuses (direct investment only)
let baselineInstances = 0; // Will track expected instances without performance scaling
// Track phase progression
let currentPhase = 0;
let monthsInCurrentPhase = 0;
for (let month = 1; month <= totalMonths; month++) {
// Determine current phase
if (monthsInCurrentPhase >= scenario.phases[currentPhase].months && currentPhase < scenario.phases.length - 1) {
currentPhase++;
monthsInCurrentPhase = 0;
}
// Calculate new instances for this month with investment scaling
const baseNewInstances = scenario.phases[currentPhase].newInstancesPerMonth;
const newInstances = Math.floor(baseNewInstances * investmentScaleFactor);
// Track baseline instances (without investment scaling) for performance comparison
const baselineNewInstances = baseNewInstances;
const baselineChurnedInstances = Math.floor(baselineInstances * scenario.churnRate);
baselineInstances = baselineInstances + baselineNewInstances - baselineChurnedInstances;
// Calculate churn using the pre-calculated adjusted churn rate
const churnedInstances = Math.floor(currentInstances * adjustedChurnRate);
// Update total instances
currentInstances = currentInstances + newInstances - churnedInstances;
// Calculate revenue based on investment model
let cspRevenue, servalaRevenue, monthlyRevenue, performanceBonus = 0, adjustedServalaShare = inputs.servalaShare;
if (inputs.investmentModel === 'loan') {
// Loan model: CSP receives fixed monthly loan payment (predictable returns)
cspRevenue = monthlyLoanPayment;
servalaRevenue = 0;
monthlyRevenue = monthlyLoanPayment;
} else {
// Direct investment model: Revenue based on instances with performance incentives
// Service revenue (shared with Servala) + Core service revenue (100% to CSP)
const baseServiceRevenue = currentInstances * inputs.revenuePerInstance;
const baseCoreRevenue = currentInstances * inputs.coreServiceRevenue;
// Apply revenue multiplier for large investments (economies of scale)
const serviceRevenue = baseServiceRevenue * revenueMultiplier;
const coreRevenue = baseCoreRevenue * revenueMultiplier;
monthlyRevenue = serviceRevenue;
// Calculate realistic performance bonus based on investment size
let maxPerformanceBonus;
if (inputs.investmentAmount >= 2000000) {
maxPerformanceBonus = 0.15; // 15% max bonus for largest investments
} else if (inputs.investmentAmount >= 1500000) {
maxPerformanceBonus = 0.12; // 12% max bonus for large investments
} else if (inputs.investmentAmount >= 1000000) {
maxPerformanceBonus = 0.10; // 10% max bonus for medium investments
} else {
maxPerformanceBonus = 0.08; // 8% max bonus for base investments
}
// Calculate performance bonus if CSP exceeds baseline expectations
if (baselineInstances > 0 && month > 6) { // Start performance tracking after 6 months
const performanceRatio = currentInstances / Math.max(baselineInstances, 1);
if (performanceRatio > 1.1) { // 10% threshold for performance bonus
performanceBonus = Math.max(0, Math.min(maxPerformanceBonus, (performanceRatio - 1.1) * 0.5));
adjustedServalaShare = Math.max(0.10, inputs.servalaShare - performanceBonus);
}
}
// Determine revenue split based on dynamic grace period
if (month <= effectiveGracePeriod) {
// During grace period: CSP keeps all service revenue + core revenue
cspRevenue = serviceRevenue + coreRevenue;
servalaRevenue = 0;
} else {
// After grace period: CSP keeps share of service revenue + all core revenue
cspRevenue = (serviceRevenue * (1 - adjustedServalaShare)) + coreRevenue;
servalaRevenue = serviceRevenue * adjustedServalaShare;
}
}
// Update cumulative revenue
cumulativeCSPRevenue += cspRevenue;
cumulativeServalaRevenue += servalaRevenue;
// Enhanced break-even calculation with acceleration for large investments
let netPosition; // CSP's net financial position
if (inputs.investmentModel === 'loan') {
// For loan model: net position is cumulative payments received minus loan principal outstanding
const principalPaid = inputs.investmentAmount * (month / totalMonths); // Simplified principal tracking
netPosition = cumulativeCSPRevenue - (inputs.investmentAmount - principalPaid);
} else {
// For direct investment: accelerated break-even for large investments
// Large investments benefit from faster effective cost recovery
const adjustedInvestment = inputs.investmentAmount / acceleratedBreakEvenFactor;
netPosition = cumulativeCSPRevenue - adjustedInvestment;
}
if (breakEvenMonth === null && netPosition >= 0) {
breakEvenMonth = month;
}
// Calculate revenue components for data tracking
let serviceRevenueForData, coreRevenueForData, totalRevenueForData;
if (inputs.investmentModel === 'loan') {
serviceRevenueForData = monthlyLoanPayment;
coreRevenueForData = 0;
totalRevenueForData = monthlyLoanPayment;
} else {
serviceRevenueForData = currentInstances * inputs.revenuePerInstance;
coreRevenueForData = currentInstances * inputs.coreServiceRevenue;
totalRevenueForData = serviceRevenueForData + coreRevenueForData;
}
monthlyData.push({
month,
scenario: scenario.name,
newInstances,
churnedInstances,
totalInstances: currentInstances,
baselineInstances,
serviceRevenue: serviceRevenueForData,
coreRevenue: coreRevenueForData,
totalRevenue: totalRevenueForData,
monthlyRevenue: serviceRevenueForData, // Keep for backward compatibility
cspRevenue,
servalaRevenue,
cumulativeCSPRevenue,
cumulativeServalaRevenue,
netPosition,
performanceBonus,
adjustedServalaShare,
effectiveGracePeriod,
investmentScaleFactor: investmentScaleFactor,
adjustedChurnRate: adjustedChurnRate,
roiPercent: inputs.investmentModel === 'loan' ?
((cumulativeCSPRevenue - inputs.investmentAmount) / inputs.investmentAmount * 100) :
(netPosition / inputs.investmentAmount * 100)
});
monthsInCurrentPhase++;
}
// Calculate final metrics with enhanced business intelligence
const totalRevenue = cumulativeCSPRevenue + cumulativeServalaRevenue;
let finalNetPosition, roi;
if (inputs.investmentModel === 'loan') {
finalNetPosition = cumulativeCSPRevenue - inputs.investmentAmount;
roi = (finalNetPosition / inputs.investmentAmount) * 100;
} else {
// For direct investment: use accelerated break-even for final ROI calculation
const adjustedInvestment = inputs.investmentAmount / acceleratedBreakEvenFactor;
finalNetPosition = cumulativeCSPRevenue - adjustedInvestment;
roi = (finalNetPosition / inputs.investmentAmount) * 100;
}
// Calculate average performance bonus over the investment period
const performanceBonusMonths = monthlyData.filter(m => m.performanceBonus > 0);
const avgPerformanceBonus = performanceBonusMonths.length > 0 ?
performanceBonusMonths.reduce((sum, m) => sum + m.performanceBonus, 0) / performanceBonusMonths.length : 0;
return {
scenario: scenario.name,
investmentModel: inputs.investmentModel,
finalInstances: currentInstances,
baselineFinalInstances: baselineInstances,
totalRevenue,
cspRevenue: cumulativeCSPRevenue,
servalaRevenue: cumulativeServalaRevenue,
netPosition: finalNetPosition,
roi,
breakEvenMonth,
effectiveGracePeriod,
avgPerformanceBonus,
monthlyData,
investmentScaleFactor: investmentScaleFactor,
revenueMultiplier: revenueMultiplier,
acceleratedBreakEvenFactor: acceleratedBreakEvenFactor,
maxPerformanceBonus: inputs.investmentModel === 'direct' ?
(inputs.investmentAmount >= 2000000 ? 15 :
inputs.investmentAmount >= 1500000 ? 12 :
inputs.investmentAmount >= 1000000 ? 10 : 8) : 0,
adjustedChurnRate: adjustedChurnRate * 100,
performanceMultiplier: baselineInstances > 0 ? (currentInstances / baselineInstances) : 1.0
};
} catch (error) {
console.error(`Error calculating scenario ${scenarioKey}:`, error);
return null;
}
}
updateCalculations() {
try {
const inputs = this.getInputValues();
this.results = {};
this.monthlyData = {};
// Show loading spinner
const loadingSpinner = document.getElementById('loading-spinner');
if (loadingSpinner) loadingSpinner.style.display = 'block';
// Calculate results for each enabled scenario with both investment models
Object.keys(this.scenarios).forEach(scenarioKey => {
// Calculate for Direct investment model
const directInputs = { ...inputs, investmentModel: 'direct' };
const directResult = this.calculateScenario(scenarioKey, directInputs);
if (directResult) {
this.results[scenarioKey + '_direct'] = directResult;
this.monthlyData[scenarioKey + '_direct'] = directResult.monthlyData;
}
// Calculate for Loan investment model
const loanInputs = { ...inputs, investmentModel: 'loan' };
const loanResult = this.calculateScenario(scenarioKey, loanInputs);
if (loanResult) {
this.results[scenarioKey + '_loan'] = loanResult;
this.monthlyData[scenarioKey + '_loan'] = loanResult.monthlyData;
}
});
// Update UI immediately
this.updateSummaryMetrics();
this.updateCharts();
this.updateComparisonTable();
this.updateMonthlyBreakdown();
// Hide loading spinner
if (loadingSpinner) loadingSpinner.style.display = 'none';
} catch (error) {
console.error('Error updating calculations:', error);
// Hide loading spinner on error
const loadingSpinner = document.getElementById('loading-spinner');
if (loadingSpinner) loadingSpinner.style.display = 'none';
}
}
}

View file

@ -1,452 +0,0 @@
/**
* Chart Management Module
* Handles Chart.js initialization and updates
*/
class ChartManager {
constructor(calculator) {
this.calculator = calculator;
this.charts = {};
}
initializeCharts() {
// Check if Chart.js is available
if (typeof Chart === 'undefined') {
console.error('Chart.js library not loaded. Charts will not be available.');
this.showChartError('Chart.js library failed to load. Please refresh the page.');
return;
}
try {
// ROI Progression Chart (replaces Instance Growth Chart)
const roiCanvas = document.getElementById('instanceGrowthChart');
if (!roiCanvas) {
console.error('ROI progression chart canvas not found');
return;
}
const roiCtx = roiCanvas.getContext('2d');
this.charts.roiProgression = new Chart(roiCtx, {
type: 'line',
data: { labels: [], datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
title: { display: true, text: 'ROI Progression Over Time - Direct Investment Only' }
},
scales: {
y: {
title: { display: true, text: 'ROI (%)' },
grid: {
color: function(context) {
return context.tick.value === 0 ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.1)';
}
}
},
x: {
title: { display: true, text: 'Month' }
}
}
}
});
// Net Position Chart (replaces simple Revenue Chart)
const netPositionCanvas = document.getElementById('revenueChart');
if (!netPositionCanvas) {
console.error('Net position chart canvas not found');
return;
}
const netPositionCtx = netPositionCanvas.getContext('2d');
this.charts.netPosition = new Chart(netPositionCtx, {
type: 'line',
data: { labels: [], datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
title: { display: true, text: 'Net Financial Position - Direct Investment Only' }
},
scales: {
y: {
title: { display: true, text: 'Net Position (CHF)' },
grid: {
color: function(context) {
return context.tick.value === 0 ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.1)';
}
}
},
x: {
title: { display: true, text: 'Month' }
}
}
}
});
// CSP Revenue Breakdown Chart
const cspRevenueCanvas = document.getElementById('cspRevenueChart');
if (!cspRevenueCanvas) {
console.error('CSP revenue breakdown chart canvas not found');
return;
}
const cspRevenueCtx = cspRevenueCanvas.getContext('2d');
this.charts.cspRevenue = new Chart(cspRevenueCtx, {
type: 'line',
data: { labels: [], datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
left: 10,
right: 200, // Add space for side legend
top: 10,
bottom: 10
}
},
plugins: {
legend: {
position: 'right',
align: 'start',
labels: {
boxWidth: 12,
padding: 15,
font: {
size: 11
},
usePointStyle: true,
generateLabels: function(chart) {
const original = Chart.defaults.plugins.legend.labels.generateLabels;
const labels = original.call(this, chart);
// Group labels by scenario for better organization
return labels.map(label => {
// Shorten label text for better fit
if (label.text.includes('Service Revenue')) {
label.text = label.text.replace(' - Service Revenue', ' - Service');
}
if (label.text.includes('Core Service Revenue')) {
label.text = label.text.replace(' - Core Service Revenue', ' - Core');
}
if (label.text.includes('CSP Total')) {
label.text = label.text.replace(' - CSP Total', ' - Total');
}
if (label.text.includes('Servala Revenue')) {
label.text = label.text.replace(' - Servala Revenue', ' - Servala');
}
return label;
});
}
}
},
title: {
display: true,
text: 'CSP Revenue Growth - Direct Investment Only',
font: {
size: 14,
weight: 'bold'
}
}
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Revenue Amount' },
stacked: false,
grid: {
color: 'rgba(0,0,0,0.1)'
}
},
x: {
title: { display: true, text: 'Month' },
grid: {
color: 'rgba(0,0,0,0.05)'
}
}
},
interaction: {
mode: 'index',
intersect: false
},
elements: {
point: {
radius: 3,
hoverRadius: 6
},
line: {
tension: 0.1
}
}
}
});
// Performance Comparison Chart (for cashFlowChart canvas)
const performanceCanvas = document.getElementById('cashFlowChart');
if (!performanceCanvas) {
console.error('Performance comparison chart canvas not found');
return;
}
const performanceCtx = performanceCanvas.getContext('2d');
this.charts.performance = new Chart(performanceCtx, {
type: 'bar',
data: { labels: [], datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
title: { display: true, text: 'Model Performance Comparison' }
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'ROI (%)' }
},
x: {
title: { display: true, text: 'Growth Scenario' }
}
}
}
});
} catch (error) {
console.error('Error initializing charts:', error);
this.showChartError('Failed to initialize charts. Please refresh the page.');
}
}
showChartError(message) {
// Show error message in place of charts
const chartContainers = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart', 'cspRevenueChart'];
chartContainers.forEach(containerId => {
const container = document.getElementById(containerId);
if (container) {
container.style.display = 'flex';
container.style.justifyContent = 'center';
container.style.alignItems = 'center';
container.style.minHeight = '300px';
container.style.backgroundColor = '#f8f9fa';
container.style.border = '1px solid #dee2e6';
container.style.borderRadius = '4px';
container.innerHTML = `<div class="text-muted text-center"><i class="bi bi-exclamation-triangle"></i><br>${message}</div>`;
}
});
}
updateCharts() {
try {
const scenarios = Object.keys(this.calculator.results);
if (scenarios.length === 0 || !this.charts.roiProgression) return;
const colors = {
conservative: '#28a745',
moderate: '#ffc107',
aggressive: '#dc3545'
};
const modelColors = {
direct: { border: '', background: '80' },
loan: { border: '', background: '40' }
};
// Get month labels
const maxMonths = Math.max(...scenarios.map(s => this.calculator.monthlyData[s].length));
const monthLabels = Array.from({ length: maxMonths }, (_, i) => `M${i + 1}`);
// Update ROI Progression Chart - Direct Investment Only
this.charts.roiProgression.data.labels = monthLabels;
this.charts.roiProgression.data.datasets = scenarios.filter(s =>
this.calculator.results[s] && s.includes('_direct')
).map(scenario => {
const scenarioBase = scenario.replace('_direct', '');
const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase;
return {
label: `${scenarioName}`,
data: this.calculator.monthlyData[scenario].map(d => d.roiPercent),
borderColor: colors[scenarioBase],
backgroundColor: colors[scenarioBase] + '30',
borderWidth: 3,
tension: 0.4,
pointBackgroundColor: this.calculator.monthlyData[scenario].map(d =>
d.roiPercent >= 0 ? colors[scenarioBase] : '#dc3545'
)
};
});
this.charts.roiProgression.update();
// Update Net Position Chart (Break-Even Analysis) - Direct Investment Only
this.charts.netPosition.data.labels = monthLabels;
this.charts.netPosition.data.datasets = scenarios.filter(s =>
this.calculator.results[s] && s.includes('_direct')
).map(scenario => {
const scenarioBase = scenario.replace('_direct', '');
const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase;
return {
label: `${scenarioName} Net Position`,
data: this.calculator.monthlyData[scenario].map(d => d.netPosition),
borderColor: colors[scenarioBase],
backgroundColor: colors[scenarioBase] + '30',
borderWidth: 3,
tension: 0.4,
fill: {
target: 'origin',
above: colors[scenarioBase] + '20',
below: '#dc354510'
}
};
});
this.charts.netPosition.update();
// Update Model Comparison Chart - Direct comparison of both models
const inputs = this.calculator.getInputValues();
// Get unique scenario names (without model suffix)
const baseScenarios = ['conservative', 'moderate', 'aggressive'].filter(s =>
this.calculator.scenarios[s].enabled
);
const comparisonLabels = baseScenarios.map(s => this.calculator.scenarios[s].name);
// Get net profit data for both models
// Update CSP Revenue Breakdown Chart (Direct Investment Only)
this.charts.cspRevenue.data.labels = monthLabels;
this.charts.cspRevenue.data.datasets = [];
// Filter to only direct investment scenarios
const directScenarios = scenarios.filter(s =>
this.calculator.results[s] && s.includes('_direct')
);
// Define revenue types and their styling
const revenueTypes = [
{
key: 'serviceRevenue',
label: 'Service Revenue',
borderWidth: 2,
borderDash: [],
opacity: '40'
},
{
key: 'coreRevenue',
label: 'Core Service Revenue',
borderWidth: 2,
borderDash: [3, 3],
opacity: '60'
},
{
key: 'cspRevenue',
label: 'CSP Total',
borderWidth: 3,
borderDash: [],
opacity: 'FF'
},
{
key: 'servalaRevenue',
label: 'Servala Revenue',
borderWidth: 1,
borderDash: [5, 5],
opacity: '80',
color: '#6c757d'
}
];
// Add datasets organized by revenue type for better legend grouping
directScenarios.forEach(scenario => {
const scenarioBase = scenario.replace('_direct', '');
const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase;
const monthlyData = this.calculator.monthlyData[scenario];
const scenarioColor = colors[scenarioBase] || '#007bff';
revenueTypes.forEach(type => {
// Skip Servala revenue if it's zero (no revenue sharing)
if (type.key === 'servalaRevenue') {
const hasServalaRevenue = monthlyData.some(d => (d.servalaRevenue || 0) > 0);
if (!hasServalaRevenue) return;
}
// Skip core revenue if it's zero
if (type.key === 'coreRevenue') {
const hasCoreRevenue = monthlyData.some(d => (d.coreRevenue || 0) > 0);
if (!hasCoreRevenue) return;
}
const dataValues = monthlyData.map(d => {
if (type.key === 'serviceRevenue') return d.serviceRevenue || d.monthlyRevenue || 0;
return d[type.key] || 0;
});
this.charts.cspRevenue.data.datasets.push({
label: `${scenarioName} - ${type.label}`,
data: dataValues,
borderColor: type.color || scenarioColor,
backgroundColor: (type.color || scenarioColor) + type.opacity,
borderWidth: type.borderWidth,
borderDash: type.borderDash,
fill: false,
tension: 0.1,
pointBackgroundColor: type.color || scenarioColor,
pointBorderColor: '#fff',
pointBorderWidth: 1
});
});
});
this.charts.cspRevenue.update();
// Update Performance Comparison Chart (ROI comparison for both models)
this.charts.performance.data.labels = comparisonLabels;
// Get ROI data for both models
const directROIData = baseScenarios.map(scenario => {
const result = this.calculator.results[scenario + '_direct'];
return result ? result.roi : 0;
});
const loanROIData = baseScenarios.map(scenario => {
const result = this.calculator.results[scenario + '_loan'];
return result ? result.roi : 0;
});
this.charts.performance.data.datasets = [
{
label: 'Direct Investment ROI',
data: directROIData,
backgroundColor: baseScenarios.map(scenario => colors[scenario] + '80'),
borderColor: baseScenarios.map(scenario => colors[scenario]),
borderWidth: 2
},
{
label: `Loan Model ROI (${(inputs.loanInterestRate * 100).toFixed(1)}% fixed)`,
data: loanROIData,
backgroundColor: '#ffc10780',
borderColor: '#e0a800',
borderWidth: 2
}
];
this.charts.performance.update();
} catch (error) {
console.error('Error updating charts:', error);
}
}
// Helper method to calculate loan payment
calculateLoanPayment(principal, annualRate, years) {
try {
const monthlyRate = annualRate / 12;
const numberOfPayments = years * 12;
if (monthlyRate === 0) {
return principal / numberOfPayments;
}
const monthlyPayment = principal * (monthlyRate * Math.pow(1 + monthlyRate, numberOfPayments)) /
(Math.pow(1 + monthlyRate, numberOfPayments) - 1);
return monthlyPayment;
} catch (error) {
console.error('Error calculating loan payment:', error);
return 0;
}
}
}

View file

@ -1,599 +0,0 @@
/**
* Export Management Module
* Handles PDF and CSV export functionality
*/
class ExportManager {
constructor(calculator, uiManager) {
this.calculator = calculator;
this.uiManager = uiManager;
}
async exportToPDF() {
// Check if jsPDF is available
if (typeof window.jspdf === 'undefined') {
alert('PDF export library is loading. Please try again in a moment.');
return;
}
// Show model selection dialog
const selectedModels = await this.showModelSelectionDialog();
if (!selectedModels) {
return; // User cancelled
}
try {
const { jsPDF } = window.jspdf;
const doc = new jsPDF('p', 'mm', 'a4');
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 20;
// Store selected models for use throughout the export
this.selectedModels = selectedModels;
// Color scheme
const colors = {
primary: [0, 123, 255],
success: [40, 167, 69],
warning: [255, 193, 7],
danger: [220, 53, 69],
dark: [33, 37, 41],
muted: [108, 117, 125]
};
let currentPage = 1;
let yPos = 30;
// Helper function to add new page with header/footer
const addPage = () => {
doc.addPage();
currentPage++;
yPos = 30;
this.addPageHeader(doc, margin, colors);
};
// Helper function to check if we need a new page
const checkPageBreak = (requiredSpace) => {
if (yPos + requiredSpace > pageHeight - 30) {
addPage();
}
};
// Title Page
this.createTitlePage(doc, pageWidth, pageHeight, margin, colors);
// Executive Summary Page
addPage();
yPos = this.createExecutiveSummary(doc, yPos, margin, colors);
// Investment Parameters Page
checkPageBreak(60);
yPos = this.createParametersSection(doc, yPos, margin, colors);
// Model Comparison Page
checkPageBreak(80);
yPos = this.createModelComparison(doc, yPos, margin, colors);
// Charts Pages
await this.addChartsToPDF(doc, colors, margin, addPage, checkPageBreak);
// Detailed Results Table
this.createDetailedResults(doc, colors, margin, addPage);
// Add footers to all pages
this.addFootersToAllPages(doc, currentPage, colors);
// Save the PDF
const filename = `servala-roi-investment-analysis-${new Date().toISOString().split('T')[0]}.pdf`;
doc.save(filename);
} catch (error) {
console.error('PDF Export Error:', error);
alert('An error occurred while generating the PDF. Please try again or export as CSV instead.');
}
}
createTitlePage(doc, pageWidth, pageHeight, margin, colors) {
// Background design element
doc.setFillColor(...colors.primary);
doc.rect(0, 0, pageWidth, 60, 'F');
// White title text
doc.setTextColor(255, 255, 255);
doc.setFontSize(28);
doc.setFont('helvetica', 'bold');
doc.text('Investment Analysis Report', pageWidth/2, 35, { align: 'center' });
// Subtitle
doc.setFontSize(14);
doc.setFont('helvetica', 'normal');
doc.text('CSP Partnership ROI Calculator', pageWidth/2, 45, { align: 'center' });
// Company section
doc.setTextColor(...colors.dark);
doc.setFontSize(20);
doc.setFont('helvetica', 'bold');
doc.text('Servala Partnership Opportunity', pageWidth/2, 90, { align: 'center' });
// Investment summary box
const inputs = this.calculator.getInputValues();
const boxY = 110;
const boxHeight = 40;
doc.setFillColor(248, 249, 250);
doc.roundedRect(margin, boxY, pageWidth - 2*margin, boxHeight, 3, 3, 'F');
doc.setDrawColor(...colors.muted);
doc.roundedRect(margin, boxY, pageWidth - 2*margin, boxHeight, 3, 3, 'S');
doc.setFontSize(16);
doc.setTextColor(...colors.primary);
doc.text('Investment Overview', pageWidth/2, boxY + 12, { align: 'center' });
doc.setFontSize(12);
doc.setTextColor(...colors.dark);
doc.text(`Investment Amount: ${this.formatCurrency(inputs.investmentAmount)}`, pageWidth/2, boxY + 22, { align: 'center' });
doc.text(`Analysis Period: ${inputs.timeframe} years`, pageWidth/2, boxY + 32, { align: 'center' });
// Generated date
doc.setFontSize(10);
doc.setTextColor(...colors.muted);
const currentDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
doc.text(`Generated on: ${currentDate}`, pageWidth/2, pageHeight - 30, { align: 'center' });
}
addPageHeader(doc, margin, colors) {
doc.setFillColor(...colors.primary);
doc.rect(0, 0, doc.internal.pageSize.getWidth(), 15, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Servala CSP Investment Analysis', margin, 10);
}
createExecutiveSummary(doc, yPos, margin, colors) {
doc.setFontSize(18);
doc.setTextColor(...colors.primary);
doc.setFont('helvetica', 'bold');
doc.text('Executive Summary', margin, yPos);
yPos += 15;
const enabledResults = Object.values(this.calculator.results);
const directResults = enabledResults.filter(r => r.investmentModel === 'direct' && this.selectedModels.direct);
const loanResults = enabledResults.filter(r => r.investmentModel === 'loan' && this.selectedModels.loan);
doc.setFontSize(11);
doc.setTextColor(...colors.dark);
doc.setFont('helvetica', 'normal');
// Investment models comparison
if (directResults.length > 0 && loanResults.length > 0) {
const avgDirectROI = directResults.reduce((sum, r) => sum + r.roi, 0) / directResults.length;
const avgLoanROI = loanResults.reduce((sum, r) => sum + r.roi, 0) / loanResults.length;
const avgDirectNetPos = directResults.reduce((sum, r) => sum + r.netPosition, 0) / directResults.length;
const avgLoanNetPos = loanResults.reduce((sum, r) => sum + r.netPosition, 0) / loanResults.length;
doc.text('This analysis compares two investment models across multiple growth scenarios:', margin, yPos);
yPos += 10;
// Direct Investment Summary
doc.setFillColor(40, 167, 69, 0.1); // Success color with opacity
doc.roundedRect(margin, yPos, (doc.internal.pageSize.getWidth() - 3*margin)/2, 35, 2, 2, 'F');
doc.setTextColor(...colors.success);
doc.setFont('helvetica', 'bold');
doc.text('Direct Investment Model', margin + 5, yPos + 8);
doc.setTextColor(...colors.dark);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text(`Average ROI: ${this.uiManager.formatPercentage(avgDirectROI)}`, margin + 5, yPos + 16);
doc.text(`Average Net Profit: ${this.formatCurrency(avgDirectNetPos)}`, margin + 5, yPos + 24);
doc.text('Performance-based with bonuses', margin + 5, yPos + 32);
// Loan Model Summary
const loanBoxX = margin + (doc.internal.pageSize.getWidth() - 3*margin)/2 + 10;
doc.setFillColor(255, 193, 7, 0.1); // Warning color with opacity
doc.roundedRect(loanBoxX, yPos, (doc.internal.pageSize.getWidth() - 3*margin)/2, 35, 2, 2, 'F');
doc.setTextColor(...colors.warning);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.text('Loan Model', loanBoxX + 5, yPos + 8);
doc.setTextColor(...colors.dark);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text(`Average ROI: ${this.uiManager.formatPercentage(avgLoanROI)}`, loanBoxX + 5, yPos + 16);
doc.text(`Average Net Profit: ${this.formatCurrency(avgLoanNetPos)}`, loanBoxX + 5, yPos + 24);
doc.text('Fixed returns, guaranteed', loanBoxX + 5, yPos + 32);
yPos += 45;
}
return yPos;
}
createParametersSection(doc, yPos, margin, colors) {
doc.setFontSize(16);
doc.setTextColor(...colors.primary);
doc.setFont('helvetica', 'bold');
doc.text('Investment Parameters', margin, yPos);
yPos += 15;
const inputs = this.calculator.getInputValues();
// Create parameter table
const params = [
['Investment Amount', this.formatCurrency(inputs.investmentAmount)],
['Investment Timeframe', `${inputs.timeframe} years`],
['Service Revenue per Instance', `${this.formatCurrency(inputs.revenuePerInstance)} / month`],
['Core Service Revenue per Instance', `${this.formatCurrency(inputs.coreServiceRevenue)} / month`],
['Total Revenue per Instance', `${this.formatCurrency(inputs.revenuePerInstance + inputs.coreServiceRevenue)} / month`],
['Loan Interest Rate', `${(inputs.loanInterestRate * 100).toFixed(1)}%`],
['Direct Investment Share', `${(inputs.servalaShare * 100).toFixed(0)}% to Servala`],
['Grace Period', `${inputs.gracePeriod} months`]
];
doc.setFontSize(11);
params.forEach(([label, value]) => {
doc.setTextColor(...colors.dark);
doc.setFont('helvetica', 'normal');
doc.text(label + ':', margin + 5, yPos);
doc.setFont('helvetica', 'bold');
doc.text(value, margin + 80, yPos);
yPos += 8;
});
return yPos + 10;
}
createModelComparison(doc, yPos, margin, colors) {
doc.setFontSize(16);
doc.setTextColor(...colors.primary);
doc.setFont('helvetica', 'bold');
doc.text('Investment Model Comparison', margin, yPos);
yPos += 15;
// Create comparison table
const scenarios = ['Conservative', 'Moderate', 'Aggressive'];
const tableData = [];
scenarios.forEach(scenarioName => {
const directResult = Object.values(this.calculator.results)
.find(r => r.scenario === scenarioName && r.investmentModel === 'direct');
const loanResult = Object.values(this.calculator.results)
.find(r => r.scenario === scenarioName && r.investmentModel === 'loan');
if (directResult && loanResult) {
tableData.push([
scenarioName,
this.formatCurrency(directResult.netPosition),
this.uiManager.formatPercentage(directResult.roi),
this.formatCurrency(loanResult.netPosition),
this.uiManager.formatPercentage(loanResult.roi)
]);
}
});
// Table headers
const headers = ['Scenario', 'Direct Net Profit', 'Direct ROI', 'Loan Net Profit', 'Loan ROI'];
const colWidths = [30, 35, 25, 35, 25];
// Draw table
this.drawTable(doc, margin, yPos, headers, tableData, colWidths, colors);
return yPos + (tableData.length + 2) * 8;
}
async addChartsToPDF(doc, colors, margin, addPage, checkPageBreak) {
// Add charts by capturing them as images
const chartIds = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart', 'modelComparisonChart'];
const chartTitles = [
'ROI Progression Over Time',
'Net Financial Position',
'Performance Comparison',
'Investment Model Comparison'
];
for (let i = 0; i < chartIds.length; i++) {
checkPageBreak(120);
const canvas = document.getElementById(chartIds[i]);
if (canvas) {
// Add chart title
doc.setFontSize(14);
doc.setTextColor(...colors.primary);
doc.setFont('helvetica', 'bold');
doc.text(chartTitles[i], margin, doc.internal.pageSize.getHeight() - 250);
// Capture chart as image
const imgData = canvas.toDataURL('image/png', 1.0);
doc.addImage(imgData, 'PNG', margin, doc.internal.pageSize.getHeight() - 240,
doc.internal.pageSize.getWidth() - 2*margin, 100);
if (i < chartIds.length - 1) addPage();
}
}
}
createDetailedResults(doc, colors, margin, addPage) {
addPage();
let yPos = 30;
doc.setFontSize(16);
doc.setTextColor(...colors.primary);
doc.setFont('helvetica', 'bold');
doc.text('Detailed Financial Results', margin, yPos);
yPos += 15;
// Create detailed results table
const headers = ['Scenario', 'Model', 'Net Profit', 'ROI', 'Break-even'];
const colWidths = [35, 25, 35, 25, 30];
const tableData = [];
Object.values(this.calculator.results).forEach(result => {
tableData.push([
result.scenario,
result.investmentModel === 'direct' ? 'Direct' : 'Loan',
this.formatCurrency(result.netPosition),
this.uiManager.formatPercentage(result.roi),
result.breakEvenMonth ? `${result.breakEvenMonth} months` : 'N/A'
]);
});
this.drawTable(doc, margin, yPos, headers, tableData, colWidths, colors);
}
drawTable(doc, x, y, headers, data, colWidths, colors) {
const rowHeight = 8;
const startX = x;
let currentY = y;
// Draw headers
doc.setFillColor(...colors.primary);
doc.rect(startX, currentY - 6, colWidths.reduce((sum, w) => sum + w, 0), rowHeight, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
let currentX = startX;
headers.forEach((header, i) => {
doc.text(header, currentX + 2, currentY - 1);
currentX += colWidths[i];
});
currentY += rowHeight;
// Draw data rows
doc.setTextColor(...colors.dark);
doc.setFont('helvetica', 'normal');
data.forEach((row, rowIndex) => {
if (rowIndex % 2 === 1) {
doc.setFillColor(248, 249, 250);
doc.rect(startX, currentY - 6, colWidths.reduce((sum, w) => sum + w, 0), rowHeight, 'F');
}
currentX = startX;
row.forEach((cell, i) => {
doc.text(String(cell), currentX + 2, currentY - 1);
currentX += colWidths[i];
});
currentY += rowHeight;
});
// Draw table border
doc.setDrawColor(...colors.muted);
doc.rect(startX, y - 6, colWidths.reduce((sum, w) => sum + w, 0), (data.length + 1) * rowHeight, 'S');
}
addFootersToAllPages(doc, totalPages, colors) {
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
const pageHeight = doc.internal.pageSize.getHeight();
const pageWidth = doc.internal.pageSize.getWidth();
doc.setFontSize(8);
doc.setTextColor(...colors.muted);
doc.text(`Page ${i} of ${totalPages}`, pageWidth - 30, pageHeight - 10);
doc.text('Generated by Servala CSP ROI Calculator', 20, pageHeight - 10);
// Add a line above footer
doc.setDrawColor(...colors.muted);
doc.line(20, pageHeight - 15, pageWidth - 20, pageHeight - 15);
}
}
async showModelSelectionDialog() {
return new Promise((resolve) => {
// Create modal dialog
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 90%;
`;
dialog.innerHTML = `
<h5 style="margin-bottom: 1rem; color: #007bff;">Select Investment Models for PDF</h5>
<p style="margin-bottom: 1.5rem; color: #6c757d;">Choose which models to include in your PDF report:</p>
<div style="margin-bottom: 1rem;">
<label style="display: flex; align-items: center; margin-bottom: 0.5rem; cursor: pointer;">
<input type="checkbox" id="pdf-direct-model" checked style="margin-right: 0.5rem;">
<span style="color: #28a745; font-weight: bold;">Direct Investment Model</span>
</label>
<small style="color: #6c757d; margin-left: 1.5rem;">Performance-based revenue sharing with scaling bonuses</small>
</div>
<div style="margin-bottom: 2rem;">
<label style="display: flex; align-items: center; margin-bottom: 0.5rem; cursor: pointer;">
<input type="checkbox" id="pdf-loan-model" checked style="margin-right: 0.5rem;">
<span style="color: #ffc107; font-weight: bold;">Loan Model</span>
</label>
<small style="color: #6c757d; margin-left: 1.5rem;">Fixed interest lending with guaranteed returns</small>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button id="pdf-cancel-btn" style="padding: 0.5rem 1rem; border: 1px solid #ccc; background: white; border-radius: 4px; cursor: pointer;">Cancel</button>
<button id="pdf-export-btn" style="padding: 0.5rem 1rem; border: none; background: #007bff; color: white; border-radius: 4px; cursor: pointer;">Export PDF</button>
</div>
`;
modal.appendChild(dialog);
document.body.appendChild(modal);
// Handle button clicks
document.getElementById('pdf-cancel-btn').addEventListener('click', () => {
document.body.removeChild(modal);
resolve(null);
});
document.getElementById('pdf-export-btn').addEventListener('click', () => {
const directSelected = document.getElementById('pdf-direct-model').checked;
const loanSelected = document.getElementById('pdf-loan-model').checked;
if (!directSelected && !loanSelected) {
alert('Please select at least one investment model.');
return;
}
document.body.removeChild(modal);
resolve({
direct: directSelected,
loan: loanSelected
});
});
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
resolve(null);
}
});
});
}
formatCurrency(amount) {
try {
// Get current currency from the page
const currencyElement = document.getElementById('currency');
const currency = currencyElement ? currencyElement.value : 'CHF';
// Determine locale based on currency
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
// Consistent currency formatting: currency in front, no decimals for whole numbers
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
} catch (error) {
console.error('Error formatting currency:', error);
return `CHF ${Math.round(amount).toLocaleString()}`;
}
}
exportToCSV() {
try {
// Create comprehensive CSV with summary and detailed data
let csvContent = 'CSP ROI Calculator Export\n';
csvContent += `Generated on: ${new Date().toLocaleDateString()}\n\n`;
// Add input parameters
csvContent += 'INPUT PARAMETERS\n';
const inputs = this.calculator.getInputValues();
csvContent += `Currency,${inputs.currency}\n`;
csvContent += `Investment Amount,${inputs.investmentAmount}\n`;
csvContent += `Timeframe (years),${inputs.timeframe}\n`;
csvContent += `Investment Model,${inputs.investmentModel === 'loan' ? 'Loan Model' : 'Direct Investment'}\n`;
if (inputs.investmentModel === 'loan') {
csvContent += `Loan Interest Rate (%),${(inputs.loanInterestRate * 100).toFixed(1)}\n`;
}
csvContent += `Service Revenue per Instance (${inputs.currency}),${inputs.revenuePerInstance}\n`;
csvContent += `Core Service Revenue per Instance (${inputs.currency}),${inputs.coreServiceRevenue}\n`;
csvContent += `Total Revenue per Instance (${inputs.currency}),${inputs.revenuePerInstance + inputs.coreServiceRevenue}\n`;
if (inputs.investmentModel === 'direct') {
csvContent += `Servala Share (%),${(inputs.servalaShare * 100).toFixed(0)}\n`;
csvContent += `Grace Period (months),${inputs.gracePeriod}\n`;
}
csvContent += '\n';
// Add scenario summary
csvContent += 'SCENARIO SUMMARY\n';
csvContent += `Scenario,Investment Model,Final Instances,Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),ROI (%),Break-even (months)\n`;
Object.values(this.calculator.results).forEach(result => {
const modelText = result.investmentModel === 'loan' ? 'Loan' : 'Direct';
csvContent += `${result.scenario},${modelText},${result.finalInstances},${result.totalRevenue.toFixed(2)},${result.cspRevenue.toFixed(2)},${result.servalaRevenue.toFixed(2)},${result.roi.toFixed(2)},${result.breakEvenMonth || 'N/A'}\n`;
});
csvContent += '\n';
// Add detailed monthly data
csvContent += 'MONTHLY BREAKDOWN\n';
csvContent += `Month,Scenario,New Instances,Churned Instances,Total Instances,Service Revenue (${inputs.currency}),Core Revenue (${inputs.currency}),Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),Cumulative CSP Revenue (${inputs.currency}),Cumulative Servala Revenue (${inputs.currency})\n`;
// Combine all monthly data
const allData = [];
Object.keys(this.calculator.monthlyData).forEach(scenario => {
this.calculator.monthlyData[scenario].forEach(monthData => {
allData.push(monthData);
});
});
allData.sort((a, b) => a.month - b.month || a.scenario.localeCompare(b.scenario));
allData.forEach(data => {
csvContent += `${data.month},${data.scenario},${data.newInstances},${data.churnedInstances},${data.totalInstances},${(data.serviceRevenue || data.monthlyRevenue || 0).toFixed(2)},${(data.coreRevenue || 0).toFixed(2)},${(data.totalRevenue || data.monthlyRevenue || 0).toFixed(2)},${data.cspRevenue.toFixed(2)},${data.servalaRevenue.toFixed(2)},${data.cumulativeCSPRevenue.toFixed(2)},${data.cumulativeServalaRevenue.toFixed(2)}\n`;
});
// Create and download file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
const filename = `servala-csp-roi-data-${new Date().toISOString().split('T')[0]}.csv`;
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up
URL.revokeObjectURL(url);
} catch (error) {
console.error('CSV Export Error:', error);
alert('An error occurred while generating the CSV file. Please try again.');
}
}
}

View file

@ -1,176 +0,0 @@
/**
* Input Utilities Module
* Handles input formatting, validation, and parsing
*/
class InputUtils {
static formatNumberWithCommas(num, currency = null) {
try {
// Get current currency if not provided
if (!currency) {
const currencyElement = document.getElementById('currency');
currency = currencyElement ? currencyElement.value : 'CHF';
}
// Use appropriate locale for number formatting
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
return parseInt(num).toLocaleString(locale);
} catch (error) {
console.error('Error formatting number with commas:', error);
return String(num);
}
}
static parseFormattedNumber(str) {
if (typeof str !== 'string') {
return 0;
}
try {
// Remove all non-numeric characters except decimal points and commas
const cleaned = str.replace(/[^\d,.-]/g, '');
// Handle empty string
if (!cleaned) {
return 0;
}
// Remove commas and parse as float
const result = parseFloat(cleaned.replace(/,/g, ''));
// Return 0 for invalid numbers or NaN
return isNaN(result) ? 0 : result;
} catch (error) {
console.error('Error parsing formatted number:', error);
return 0;
}
}
static handleInvestmentAmountInput(input) {
if (!input || typeof input.value !== 'string') {
console.error('Invalid input element provided to handleInvestmentAmountInput');
return;
}
try {
// Allow only digits, no immediate formatting during typing
let value = input.value.replace(/[^\d]/g, '');
// Handle empty input
if (!value) {
input.setAttribute('data-value', '0');
return;
}
// Parse the numeric value
let numericValue = parseInt(value) || 0;
// Update the data attribute with the raw numeric value (no limits during typing)
input.setAttribute('data-value', numericValue.toString());
// Update the input value (keep it clean, no commas during typing)
input.value = value;
// Update the slider if it exists (with limits)
const slider = document.getElementById('investment-slider');
if (slider) {
const minValue = parseInt(slider.min) || 100000;
const maxValue = parseInt(slider.max) || 2000000;
const sliderValue = Math.max(minValue, Math.min(maxValue, numericValue));
slider.value = sliderValue;
}
// Trigger calculations with debouncing
if (window.ROICalculatorApp && window.ROICalculatorApp.calculator) {
clearTimeout(input._calculationTimeout);
input._calculationTimeout = setTimeout(() => {
window.ROICalculatorApp.calculator.updateCalculations();
}, 300); // 300ms delay to avoid excessive calculations during typing
}
} catch (error) {
console.error('Error handling investment amount input:', error);
// Set a safe default value
input.setAttribute('data-value', '500000');
input.value = '500000';
}
}
static handleInvestmentAmountBlur(input) {
if (!input) return;
try {
// Get the numeric value
let numericValue = parseInt(input.getAttribute('data-value')) || 500000;
// Enforce min/max limits on blur
const minValue = 100000;
const maxValue = 2000000;
if (numericValue < minValue) {
numericValue = minValue;
} else if (numericValue > maxValue) {
numericValue = maxValue;
}
// Update the data attribute with the corrected value
input.setAttribute('data-value', numericValue.toString());
// Format and display the value with commas on blur
input.value = InputUtils.formatNumberWithCommas(numericValue);
// Update the slider
const slider = document.getElementById('investment-slider');
if (slider) {
slider.value = numericValue;
}
// Trigger immediate calculation on blur
if (window.ROICalculatorApp && window.ROICalculatorApp.calculator) {
clearTimeout(input._calculationTimeout);
window.ROICalculatorApp.calculator.updateCalculations();
}
} catch (error) {
console.error('Error handling investment amount blur:', error);
// Set a safe default value
input.setAttribute('data-value', '500000');
input.value = '500,000';
}
}
static handleInvestmentAmountFocus(input) {
if (!input) return;
try {
// Remove commas when focusing for easier editing
const numericValue = input.getAttribute('data-value') || '500000';
input.value = numericValue;
} catch (error) {
console.error('Error handling investment amount focus:', error);
}
}
static getCSRFToken() {
try {
// Try to get CSRF token from meta tag first
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
// Fallback to cookie method
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'csrftoken') {
return decodeURIComponent(value);
}
}
// If no CSRF token found, return empty string (will cause server error, but won't break JS)
console.error('CSRF token not found');
return '';
} catch (error) {
console.error('Error getting CSRF token:', error);
return '';
}
}
}

View file

@ -1,634 +0,0 @@
/**
* ROI Calculator Application
* Main application class that coordinates all modules
*/
class ROICalculatorApp {
constructor() {
this.calculator = null;
this.chartManager = null;
this.uiManager = null;
this.exportManager = null;
this.isInitialized = false;
}
async initialize() {
if (this.isInitialized) {
console.warn('ROI Calculator already initialized');
return;
}
try {
// Create the main calculator instance
this.calculator = new ROICalculator();
// Create UI and chart managers
this.uiManager = new UIManager(this.calculator);
this.chartManager = new ChartManager(this.calculator);
this.exportManager = new ExportManager(this.calculator, this.uiManager);
// Replace the methods in calculator with manager methods
this.calculator.updateSummaryMetrics = () => this.uiManager.updateSummaryMetrics();
this.calculator.updateCharts = () => this.chartManager.updateCharts();
this.calculator.updateComparisonTable = () => this.uiManager.updateComparisonTable();
this.calculator.updateMonthlyBreakdown = () => this.uiManager.updateMonthlyBreakdown();
this.calculator.initializeCharts = () => this.chartManager.initializeCharts();
this.calculator.formatCurrency = (amount) => this.uiManager.formatCurrency(amount);
this.calculator.formatCurrencyDetailed = (amount) => this.uiManager.formatCurrencyDetailed(amount);
this.calculator.formatPercentage = (value) => this.uiManager.formatPercentage(value);
// Re-initialize charts with the chart manager
this.chartManager.initializeCharts();
this.calculator.charts = this.chartManager.charts;
// Initialize tooltips
this.initializeTooltips();
// Check export libraries
this.checkExportLibraries();
// Run initial calculation
this.calculator.updateCalculations();
this.isInitialized = true;
console.log('ROI Calculator initialized successfully');
} catch (error) {
console.error('Failed to initialize ROI Calculator:', error);
}
}
// Initialize tooltips with Bootstrap (loaded directly via CDN)
initializeTooltips() {
// Wait for Bootstrap to be available
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
try {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
} catch (error) {
console.warn('Failed to initialize Bootstrap tooltips:', error);
this.initializeNativeTooltips();
}
} else {
// Retry after a short delay for deferred scripts
setTimeout(() => this.initializeTooltips(), 100);
}
}
initializeNativeTooltips() {
try {
var tooltipElements = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipElements.forEach(function (element) {
// Ensure the title attribute is set for native tooltips
var tooltipContent = element.getAttribute('title');
if (!tooltipContent) {
// Get tooltip content from data-bs-original-title or title attribute
tooltipContent = element.getAttribute('data-bs-original-title');
if (tooltipContent) {
element.setAttribute('title', tooltipContent);
}
}
});
} catch (error) {
console.error('Error initializing native tooltips:', error);
}
}
// Check if export libraries are loaded
checkExportLibraries() {
try {
const pdfButton = document.querySelector('button[onclick="exportToPDF()"]');
if (typeof window.jspdf === 'undefined') {
if (pdfButton) {
pdfButton.disabled = true;
pdfButton.innerHTML = '<i class="bi bi-file-pdf"></i> Loading PDF...';
}
// Retry after a short delay
setTimeout(() => {
if (typeof window.jspdf !== 'undefined' && pdfButton) {
pdfButton.disabled = false;
pdfButton.innerHTML = '<i class="bi bi-file-pdf"></i> Export PDF Report';
}
}, 2000);
}
} catch (error) {
console.error('Error checking export libraries:', error);
}
}
// Public API methods for global function calls
updateCalculations() {
if (this.calculator) {
this.calculator.updateCalculations();
this.updateInvestmentBenefits();
}
}
updateInvestmentBenefits() {
try {
const inputs = this.calculator.getInputValues();
const investmentAmount = inputs.investmentAmount;
const baseInvestment = 500000;
// Calculate market-realistic instance scaling factor
let scalingFactor;
if (investmentAmount <= baseInvestment) {
scalingFactor = investmentAmount / baseInvestment;
} else if (investmentAmount <= 1000000) {
const ratio = investmentAmount / baseInvestment;
scalingFactor = 1.0 + ((ratio - 1.0) * 0.5); // Linear scaling to 1.5x
} else if (investmentAmount <= 1500000) {
const progress = (investmentAmount - 1000000) / 500000;
scalingFactor = 1.5 + (progress * 0.3); // Up to 1.8x
} else {
const progress = (investmentAmount - 1500000) / 500000;
scalingFactor = 1.8 + (progress * 0.2); // Max 2.0x
}
// Calculate sustainable revenue premium
let revenuePremium;
if (investmentAmount >= 2000000) {
revenuePremium = 20; // Maximum 20% premium
} else if (investmentAmount >= 1500000) {
revenuePremium = 15; // 15% premium
} else if (investmentAmount >= 1000000) {
revenuePremium = 10; // 10% premium
} else {
revenuePremium = 0;
}
// Calculate commercially viable grace period
const baseGracePeriod = inputs.gracePeriod;
let gracePeriodBonus;
if (investmentAmount >= 1500000) {
gracePeriodBonus = 3; // 3 months max bonus
} else if (investmentAmount >= 1000000) {
gracePeriodBonus = 2; // 2 months bonus
} else {
gracePeriodBonus = Math.floor((investmentAmount - baseInvestment) / 500000);
}
const totalGracePeriod = Math.min(Math.max(0, baseGracePeriod + gracePeriodBonus), 6); // Max 6 months
// Calculate realistic max performance bonus
let maxBonus;
if (investmentAmount >= 2000000) {
maxBonus = 15; // 15% max bonus
} else if (investmentAmount >= 1500000) {
maxBonus = 12; // 12% max bonus
} else if (investmentAmount >= 1000000) {
maxBonus = 10; // 10% max bonus
} else {
maxBonus = 8; // 8% max bonus
}
// Update UI elements
const instanceScaling = document.getElementById('instance-scaling');
if (instanceScaling) {
instanceScaling.textContent = scalingFactor.toFixed(1) + 'x';
instanceScaling.className = scalingFactor >= 2.0 ? 'benefit-value text-success fw-bold' :
scalingFactor >= 1.5 ? 'benefit-value text-primary fw-bold' : 'benefit-value text-secondary fw-bold';
}
const revenuePremiumEl = document.getElementById('revenue-premium');
if (revenuePremiumEl) {
revenuePremiumEl.textContent = '+' + revenuePremium + '%';
revenuePremiumEl.className = revenuePremium >= 40 ? 'benefit-value text-success fw-bold' :
revenuePremium >= 20 ? 'benefit-value text-warning fw-bold' : 'benefit-value text-secondary fw-bold';
}
const gracePeriodEl = document.getElementById('grace-period-display');
if (gracePeriodEl) {
gracePeriodEl.textContent = totalGracePeriod + ' months';
gracePeriodEl.className = totalGracePeriod >= 12 ? 'benefit-value text-success fw-bold' :
totalGracePeriod >= 9 ? 'benefit-value text-info fw-bold' : 'benefit-value text-secondary fw-bold';
}
const maxBonusEl = document.getElementById('max-bonus');
if (maxBonusEl) {
maxBonusEl.textContent = maxBonus + '%';
maxBonusEl.className = maxBonus >= 30 ? 'benefit-value text-success fw-bold' :
maxBonus >= 20 ? 'benefit-value text-warning fw-bold' : 'benefit-value text-secondary fw-bold';
}
} catch (error) {
console.error('Error updating investment benefits:', error);
}
}
exportToPDF() {
if (this.exportManager) {
this.exportManager.exportToPDF();
}
}
exportToCSV() {
if (this.exportManager) {
this.exportManager.exportToCSV();
}
}
updateInvestmentAmount(value) {
try {
const input = document.getElementById('investment-amount');
if (input) {
input.setAttribute('data-value', value);
input.value = InputUtils.formatNumberWithCommas(value);
this.updateCalculations();
}
} catch (error) {
console.error('Error updating investment amount:', error);
}
}
updateRevenuePerInstance(value) {
try {
const element = document.getElementById('revenue-per-instance');
if (element) {
element.value = value;
this.updateCalculations();
}
} catch (error) {
console.error('Error updating revenue per instance:', error);
}
}
updateServalaShare(value) {
try {
const element = document.getElementById('servala-share');
if (element) {
element.value = value;
this.updateCalculations();
}
} catch (error) {
console.error('Error updating servala share:', error);
}
}
updateGracePeriod(value) {
try {
const element = document.getElementById('grace-period');
if (element) {
element.value = value;
this.updateCalculations();
}
} catch (error) {
console.error('Error updating grace period:', error);
}
}
updateLoanRate(value) {
try {
const element = document.getElementById('loan-interest-rate');
if (element) {
element.value = value;
this.updateCalculations();
}
} catch (error) {
console.error('Error updating loan rate:', error);
}
}
updateCoreServiceRevenue(value) {
try {
const element = document.getElementById('core-service-revenue');
if (element) {
element.value = value;
this.updateCalculations();
}
} catch (error) {
console.error('Error updating core service revenue:', error);
}
}
updateCurrency(value) {
try {
const currencyElement = document.getElementById('currency');
if (currencyElement) {
currencyElement.value = value;
}
// Update all currency-related UI labels
this.updateCurrencyLabels(value);
// Update calculations to reflect new currency formatting
this.updateCalculations();
} catch (error) {
console.error('Error updating currency:', error);
}
}
updateCurrencyLabels(currency) {
try {
// Update investment amount prefix
const investmentPrefix = document.getElementById('investment-currency-prefix');
if (investmentPrefix) {
investmentPrefix.textContent = currency;
}
// Update investment min/max labels
const investmentMinLabel = document.getElementById('investment-min-label');
if (investmentMinLabel) {
investmentMinLabel.textContent = `${currency} 100K`;
}
const investmentMaxLabel = document.getElementById('investment-max-label');
if (investmentMaxLabel) {
investmentMaxLabel.textContent = `${currency} 2M`;
}
// Update revenue per instance suffix
const revenueSuffix = document.getElementById('revenue-currency-suffix');
if (revenueSuffix) {
revenueSuffix.textContent = `${currency}/month`;
}
// Update core service revenue suffix (it's a direct span, not ID-based)
const coreRevenueInput = document.getElementById('core-service-revenue');
if (coreRevenueInput) {
const coreRevenueSpan = coreRevenueInput.parentElement.querySelector('.input-group-text');
if (coreRevenueSpan) {
coreRevenueSpan.textContent = `${currency}/month`;
}
}
// Update all other currency labels throughout the interface
const currencyLabels = document.querySelectorAll('.currency-label');
currencyLabels.forEach(label => {
label.textContent = currency;
});
// Update range slider labels with currency
const revenueLabel = document.querySelector('label[for="revenue-per-instance"]');
if (revenueLabel) {
revenueLabel.innerHTML = revenueLabel.innerHTML.replace(/(CHF|EUR)/, currency);
}
const coreRevenueLabel = document.querySelector('label[for="core-service-revenue"]');
if (coreRevenueLabel) {
coreRevenueLabel.innerHTML = coreRevenueLabel.innerHTML.replace(/(CHF|EUR)/, currency);
}
// Update investment amount field display if it has a value
const investmentInput = document.getElementById('investment-amount');
if (investmentInput && investmentInput.getAttribute('data-value')) {
const currentValue = investmentInput.getAttribute('data-value');
investmentInput.value = InputUtils.formatNumberWithCommas(currentValue, currency);
}
} catch (error) {
console.error('Error updating currency labels:', error);
}
}
updateScenarioChurn(scenarioKey, churnRate) {
try {
if (this.calculator && this.calculator.scenarios[scenarioKey]) {
this.calculator.scenarios[scenarioKey].churnRate = parseFloat(churnRate) / 100;
this.updateCalculations();
}
} catch (error) {
console.error('Error updating scenario churn:', error);
}
}
updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) {
try {
if (this.calculator && this.calculator.scenarios[scenarioKey] && this.calculator.scenarios[scenarioKey].phases[phaseIndex]) {
this.calculator.scenarios[scenarioKey].phases[phaseIndex].newInstancesPerMonth = parseInt(newInstancesPerMonth);
this.updateCalculations();
}
} catch (error) {
console.error('Error updating scenario phase:', error);
}
}
resetAdvancedParameters() {
if (!confirm('Reset all advanced parameters to default values?')) {
return;
}
try {
if (!this.calculator) return;
// Reset Conservative
this.calculator.scenarios.conservative.churnRate = 0.025;
this.calculator.scenarios.conservative.phases = [
{ months: 6, newInstancesPerMonth: 15 },
{ months: 6, newInstancesPerMonth: 24 },
{ months: 12, newInstancesPerMonth: 35 },
{ months: 12, newInstancesPerMonth: 40 }
];
// Reset Moderate
this.calculator.scenarios.moderate.churnRate = 0.03;
this.calculator.scenarios.moderate.phases = [
{ months: 6, newInstancesPerMonth: 25 },
{ months: 6, newInstancesPerMonth: 45 },
{ months: 12, newInstancesPerMonth: 70 },
{ months: 12, newInstancesPerMonth: 90 }
];
// Reset Aggressive
this.calculator.scenarios.aggressive.churnRate = 0.035;
this.calculator.scenarios.aggressive.phases = [
{ months: 6, newInstancesPerMonth: 40 },
{ months: 6, newInstancesPerMonth: 80 },
{ months: 12, newInstancesPerMonth: 120 },
{ months: 12, newInstancesPerMonth: 150 }
];
// Update UI inputs
const inputMappings = [
['conservative-churn', '2.5'],
['conservative-phase-0', '15'],
['conservative-phase-1', '24'],
['conservative-phase-2', '35'],
['conservative-phase-3', '40'],
['moderate-churn', '3.0'],
['moderate-phase-0', '25'],
['moderate-phase-1', '45'],
['moderate-phase-2', '70'],
['moderate-phase-3', '90'],
['aggressive-churn', '3.5'],
['aggressive-phase-0', '40'],
['aggressive-phase-1', '80'],
['aggressive-phase-2', '120'],
['aggressive-phase-3', '150']
];
inputMappings.forEach(([id, value]) => {
const element = document.getElementById(id);
if (element) {
element.value = value;
}
});
this.updateCalculations();
} catch (error) {
console.error('Error resetting advanced parameters:', error);
}
}
toggleScenario(scenarioKey) {
try {
const checkbox = document.getElementById(scenarioKey + '-enabled');
if (!checkbox || !this.calculator) return;
const enabled = checkbox.checked;
this.calculator.scenarios[scenarioKey].enabled = enabled;
const card = document.getElementById(scenarioKey + '-card');
if (card) {
if (enabled) {
card.classList.add('active');
card.classList.remove('disabled');
} else {
card.classList.remove('active');
card.classList.add('disabled');
}
}
this.updateCalculations();
} catch (error) {
console.error('Error toggling scenario:', error);
}
}
toggleCollapsible(elementId) {
try {
const content = document.getElementById(elementId);
if (!content) return;
const header = content.previousElementSibling;
if (!header) return;
const chevron = header.querySelector('.bi-chevron-down, .bi-chevron-up');
if (content.classList.contains('show')) {
content.classList.remove('show');
if (chevron) {
chevron.classList.remove('bi-chevron-up');
chevron.classList.add('bi-chevron-down');
}
} else {
content.classList.add('show');
if (chevron) {
chevron.classList.remove('bi-chevron-down');
chevron.classList.add('bi-chevron-up');
}
}
} catch (error) {
console.error('Error toggling collapsible:', error);
}
}
resetCalculator() {
if (!confirm('Are you sure you want to reset all parameters to default values?')) {
return;
}
try {
// Reset input values
const investmentInput = document.getElementById('investment-amount');
if (investmentInput) {
investmentInput.setAttribute('data-value', '500000');
investmentInput.value = '500,000';
}
const resetMappings = [
['investment-slider', 500000],
['timeframe', 3],
['loan-interest-rate', 5.0],
['loan-rate-slider', 5.0],
['revenue-per-instance', 50],
['revenue-slider', 50],
['servala-share', 25],
['share-slider', 25],
['grace-period', 6],
['grace-slider', 6]
];
resetMappings.forEach(([id, value]) => {
const element = document.getElementById(id);
if (element) {
element.value = value;
}
});
// Both models are now calculated simultaneously
// Reset scenarios
['conservative', 'moderate', 'aggressive'].forEach(scenario => {
const checkbox = document.getElementById(scenario + '-enabled');
const card = document.getElementById(scenario + '-card');
if (checkbox) checkbox.checked = true;
if (this.calculator) this.calculator.scenarios[scenario].enabled = true;
if (card) {
card.classList.add('active');
card.classList.remove('disabled');
}
});
// Reset advanced parameters
this.resetAdvancedParameters();
// Both models are now calculated simultaneously, no toggle needed
// Recalculate
this.updateCalculations();
} catch (error) {
console.error('Error resetting calculator:', error);
}
}
// toggleInvestmentModel removed - both models are now calculated simultaneously
updateMonthlyBreakdownFilters() {
try {
if (this.uiManager) {
this.uiManager.updateMonthlyBreakdown();
}
} catch (error) {
console.error('Error updating monthly breakdown filters:', error);
}
}
logout() {
if (!confirm('Are you sure you want to logout?')) {
return;
}
try {
// Create a form to submit logout request
const form = document.createElement('form');
form.method = 'POST';
form.action = window.location.pathname;
// Add CSRF token from page meta tag or cookie
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = InputUtils.getCSRFToken();
form.appendChild(csrfInput);
// Add logout parameter
const logoutInput = document.createElement('input');
logoutInput.type = 'hidden';
logoutInput.name = 'logout';
logoutInput.value = 'true';
form.appendChild(logoutInput);
document.body.appendChild(form);
form.submit();
} catch (error) {
console.error('Error during logout:', error);
}
}
}
// Initialize the application when DOM is ready
document.addEventListener('DOMContentLoaded', function () {
window.ROICalculatorApp = new ROICalculatorApp();
window.ROICalculatorApp.initialize();
});

View file

@ -1,309 +0,0 @@
/**
* UI Management Module
* Handles DOM updates, table rendering, and metric display
*/
class UIManager {
constructor(calculator) {
this.calculator = calculator;
}
updateSummaryMetrics() {
try {
const enabledResults = Object.values(this.calculator.results);
if (enabledResults.length === 0) {
this.setElementText('net-position-direct', 'CHF 0');
this.setElementText('net-position-loan', 'CHF 0');
this.setElementText('roi-percentage-direct', '0%');
this.setElementText('roi-percentage-loan', '0%');
return;
}
// Separate direct and loan results
const directResults = enabledResults.filter(r => r.investmentModel === 'direct');
const loanResults = enabledResults.filter(r => r.investmentModel === 'loan');
// Calculate averages for direct investment
if (directResults.length > 0) {
const avgNetPositionDirect = directResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / directResults.length;
const avgROIDirect = directResults.reduce((sum, r) => sum + r.roi, 0) / directResults.length;
this.setElementText('net-position-direct', this.formatCurrency(avgNetPositionDirect));
this.setElementText('roi-percentage-direct', this.formatPercentage(avgROIDirect));
// Update styling for direct metrics
const netPositionDirectElement = document.getElementById('net-position-direct');
if (netPositionDirectElement) {
if (avgNetPositionDirect > 0) {
netPositionDirectElement.className = 'fw-bold text-success';
} else if (avgNetPositionDirect < 0) {
netPositionDirectElement.className = 'fw-bold text-danger';
} else {
netPositionDirectElement.className = 'fw-bold';
}
}
}
// Calculate averages for loan investment
if (loanResults.length > 0) {
const avgNetPositionLoan = loanResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / loanResults.length;
const avgROILoan = loanResults.reduce((sum, r) => sum + r.roi, 0) / loanResults.length;
this.setElementText('net-position-loan', this.formatCurrency(avgNetPositionLoan));
this.setElementText('roi-percentage-loan', this.formatPercentage(avgROILoan));
// Update styling for loan metrics
const netPositionLoanElement = document.getElementById('net-position-loan');
if (netPositionLoanElement) {
if (avgNetPositionLoan > 0) {
netPositionLoanElement.className = 'fw-bold text-success';
} else if (avgNetPositionLoan < 0) {
netPositionLoanElement.className = 'fw-bold text-danger';
} else {
netPositionLoanElement.className = 'fw-bold';
}
}
}
} catch (error) {
console.error('Error updating summary metrics:', error);
}
}
updateComparisonTable() {
try {
const tbody = document.getElementById('comparison-tbody');
if (!tbody) {
console.error('Comparison table body not found');
return;
}
tbody.innerHTML = '';
// Sort results by scenario first, then by model
const sortedResults = Object.values(this.calculator.results).sort((a, b) => {
const scenarioCompare = a.scenario.localeCompare(b.scenario);
if (scenarioCompare !== 0) return scenarioCompare;
return a.investmentModel.localeCompare(b.investmentModel);
});
sortedResults.forEach(result => {
const isDirect = result.investmentModel === 'direct';
const scenarioColor = this.getScenarioColor(result.scenario);
// Model badge with better styling
const modelBadge = isDirect ?
'<span class="fw-bold text-success">Direct</span>' :
'<span class="fw-bold text-warning">Loan</span>';
// Key features based on model
const keyFeatures = isDirect ?
`Grace period: ${result.effectiveGracePeriod || 6} months<br>` +
`Performance multiplier: ${result.performanceMultiplier ? result.performanceMultiplier.toFixed(2) : '1.0'}x` :
`Fixed ${this.formatPercentage(this.calculator.getInputValues().loanInterestRate * 100)} rate<br>` +
'Predictable returns';
const row = tbody.insertRow();
row.innerHTML = `
<td><span class="fw-bold" style="color: ${scenarioColor}">${result.scenario}</span></td>
<td>${modelBadge}</td>
<td>${result.finalInstances ? result.finalInstances.toLocaleString() : 'N/A'}</td>
<td class="fw-bold ${result.netPosition >= 0 ? 'text-success' : 'text-danger'}">
${this.formatCurrencyDetailed(result.netPosition || 0)}
</td>
<td class="fw-bold ${result.roi >= 15 ? 'text-success' : result.roi >= 5 ? 'text-warning' : result.roi < 0 ? 'text-danger' : ''}">
${this.formatPercentage(result.roi || 0)}
</td>
<td>${result.breakEvenMonth ? result.breakEvenMonth + ' months' : '<span class="text-muted">No break-even</span>'}</td>
<td><small class="text-muted">${keyFeatures}</small></td>
`;
});
} catch (error) {
console.error('Error updating comparison table:', error);
}
}
updateMonthlyBreakdown() {
try {
const tbody = document.getElementById('monthly-tbody');
if (!tbody) {
console.error('Monthly breakdown table body not found');
return;
}
tbody.innerHTML = '';
// Get filter settings
const filters = this.getMonthlyBreakdownFilters();
// Combine all monthly data and sort by month, then scenario, then model
const allData = [];
Object.keys(this.calculator.monthlyData).forEach(resultKey => {
this.calculator.monthlyData[resultKey].forEach(monthData => {
const model = resultKey.includes('_loan') ? 'loan' : 'direct';
const scenario = resultKey.replace('_direct', '').replace('_loan', '');
// Apply filters
if (!filters.models[model] || !filters.scenarios[scenario.toLowerCase()]) {
return; // Skip this entry if filtered out
}
allData.push({
...monthData,
model: model,
scenarioKey: scenario
});
});
});
// Sort by month first, then scenario, then model (direct first)
allData.sort((a, b) => {
const monthCompare = a.month - b.month;
if (monthCompare !== 0) return monthCompare;
const scenarioCompare = a.scenarioKey.localeCompare(b.scenarioKey);
if (scenarioCompare !== 0) return scenarioCompare;
return a.model === 'direct' ? -1 : 1; // Direct first
});
allData.forEach(data => {
const row = tbody.insertRow();
const scenarioColor = this.getScenarioColor(data.scenario);
const isDirect = data.model === 'direct';
// Model styling
const modelBadge = isDirect ?
'<span class="text-success fw-bold">Direct</span>' :
'<span class="text-warning fw-bold">Loan</span>';
// Net position styling
const netPositionClass = (data.netPosition || 0) >= 0 ? 'text-success' : 'text-danger';
row.innerHTML = `
<td class="text-center"><strong>${data.month}</strong></td>
<td><span style="color: ${scenarioColor}" class="fw-bold">${data.scenario}</span></td>
<td>${modelBadge}</td>
<td class="text-end">${data.totalInstances ? data.totalInstances.toLocaleString() : '0'}</td>
<td class="text-end">${this.formatCurrencyDetailed(data.serviceRevenue || data.monthlyRevenue || 0)}</td>
<td class="text-end">${this.formatCurrencyDetailed(data.coreRevenue || 0)}</td>
<td class="text-end fw-bold">${this.formatCurrencyDetailed(data.totalRevenue || data.monthlyRevenue || 0)}</td>
<td class="text-end fw-bold">${this.formatCurrencyDetailed(data.cspRevenue || 0)}</td>
<td class="text-end text-muted">${this.formatCurrencyDetailed(data.servalaRevenue || 0)}</td>
<td class="text-end fw-bold ${netPositionClass}">${this.formatCurrencyDetailed(data.netPosition || 0)}</td>
`;
});
} catch (error) {
console.error('Error updating monthly breakdown:', error);
}
}
setElementText(elementId, text) {
const element = document.getElementById(elementId);
if (element) {
element.textContent = text;
}
}
formatCurrency(amount, currency = null) {
try {
// Get current currency if not provided
if (!currency) {
const currencyElement = document.getElementById('currency');
currency = currencyElement ? currencyElement.value : 'CHF';
}
// Determine locale based on currency
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
// Use compact notation for large numbers in metric cards
if (amount >= 1000000) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
notation: 'compact',
minimumFractionDigits: 0,
maximumFractionDigits: 1
}).format(amount);
} else {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
} catch (error) {
console.error('Error formatting currency:', error);
return `${currency || 'CHF'} ${amount.toFixed(0)}`;
}
}
formatCurrencyDetailed(amount, currency = null) {
try {
// Get current currency if not provided
if (!currency) {
const currencyElement = document.getElementById('currency');
currency = currencyElement ? currencyElement.value : 'CHF';
}
// Determine locale based on currency
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
// Use full formatting for detailed views (tables, exports)
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
} catch (error) {
console.error('Error formatting detailed currency:', error);
return `${currency || 'CHF'} ${amount.toFixed(0)}`;
}
}
formatPercentage(value) {
try {
return new Intl.NumberFormat('de-CH', {
style: 'percent',
minimumFractionDigits: 1,
maximumFractionDigits: 1
}).format(value / 100);
} catch (error) {
console.error('Error formatting percentage:', error);
return `${value.toFixed(1)}%`;
}
}
getScenarioColor(scenarioName) {
switch(scenarioName.toLowerCase()) {
case 'conservative': return '#28a745';
case 'moderate': return '#ffc107';
case 'aggressive': return '#dc3545';
default: return '#6c757d';
}
}
getMonthlyBreakdownFilters() {
try {
return {
models: {
direct: document.getElementById('breakdown-direct-enabled')?.checked ?? true,
loan: document.getElementById('breakdown-loan-enabled')?.checked ?? true
},
scenarios: {
conservative: document.getElementById('breakdown-conservative-enabled')?.checked ?? true,
moderate: document.getElementById('breakdown-moderate-enabled')?.checked ?? true,
aggressive: document.getElementById('breakdown-aggressive-enabled')?.checked ?? true
}
};
} catch (error) {
console.error('Error getting monthly breakdown filters:', error);
// Return default filters if there's an error
return {
models: { direct: true, loan: true },
scenarios: { conservative: true, moderate: true, aggressive: true }
};
}
}
}

View file

@ -15,7 +15,7 @@
{% social_meta_tags %}
{% json_ld_structured_data %}
<link rel="stylesheet" type="text/css" href='{% static "css/bootstrap-icons.min.css" %}'>
<link rel="stylesheet" href='{% static "css/bootstrap-icons.min.css" %}'>
<link rel="stylesheet" type="text/css" href='{% static "css/servala-main.css" %}'>
{% block extra_css %}{% endblock %}
@ -25,7 +25,6 @@
<script defer src="{% static "js/htmx204.min.js" %}"></script>
<script defer src="{% static "js/alpine-collapse.min.js" %}"></script>
<script defer src="{% static "js/servala-main.js" %}"></script>
<script defer src="{% static "js/bootstrap.bundle.min.js" %}"></script>
<script defer src="{% static "js/servala-addons.js" %}"></script>
{% block extra_js %}{% endblock %}
</head>
@ -35,49 +34,15 @@
<div class="bg-primary text-white py-2 text-center">
<div class="container">
<a href="https://www.vshn.ch/en/blog/vshn-launches-servala-open-cloud-native-service-hub/" class="text-white text-decoration-none" target="_blank">
VSHN launches Servala The Sovereign App Store
VSHN launches Servala Open Cloud Native Service Hub
</a>
<i class="bi bi-box-arrow-up-right"></i>
</div>
</div>
<header class="site-header position-relative"
x-data="{
isMenuOpen: false,
isAtTop: true,
toggleMenu() {
this.isMenuOpen = !this.isMenuOpen;
},
closeMenu() {
if (window.innerWidth < 992 && this.isMenuOpen) {
this.isMenuOpen = false;
}
},
handleScroll() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
this.isAtTop = scrollTop <= 200;
}
}"
x-init="
let scrollThrottle = false;
window.addEventListener('scroll', () => {
if (!scrollThrottle) {
requestAnimationFrame(() => {
handleScroll();
scrollThrottle = false;
});
scrollThrottle = true;
}
});
window.addEventListener('resize', () => {
if (window.innerWidth >= 992 && isMenuOpen) {
isMenuOpen = false;
}
});
">
<div class="header-nav"
:class="isAtTop ? 'header-nav--top' : 'header-nav--fixed'"
id="headerNav">
<header x-data="{sideNav: false, atTop: true}" class="site-header position-relative">
<div class="header-nav" :class="{ 'header-nav--top': atTop, 'header-nav--fixed': !atTop }"
x-on:scroll.window="atTop = (window.pageYOffset > 200) ? false : true;">
<div class="container-xl mx-auto px-3 px-lg-0 position-relative">
<div class="nav__wrapper d-flex justify-content-between align-items-center">
<div class="nav__brand logo">
@ -85,40 +50,31 @@
<img src="{% static "img/header-logo.png" %}" alt="Servala Logo" width="191" height="43">
</a>
</div>
<div class="nav__menu"
:class="isMenuOpen ? 'nav__menu-active' : 'nav__menu-hidden'"
id="navMenu">
<div x-cloak class="nav__menu" :class="sideNav ? 'nav__menu-active' : 'nav__menu-hidden'">
<nav class="navbar d-lg-flex justify-content-lg-end align-items-lg-center">
<ul class="navbar__menu menu mr-lg-27">
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:homepage' %}" @click="closeMenu()">Home</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:service_list' %}" @click="closeMenu()">Services</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:provider_list' %}" @click="closeMenu()">Cloud Provider</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:partner_list' %}" @click="closeMenu()">Partner</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:article_list' %}" @click="closeMenu()">Articles</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:about' %}" @click="closeMenu()">About</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:homepage' %}">Home</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:service_list' %}">Services</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:article_list' %}">Articles</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:provider_list' %}">Cloud Providers</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:partner_list' %}">Consulting Partners</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:about' %}">About</a></li>
</ul>
<ul class="menu-cta mb-0">
<li class="mr-17"><a class="btn btn-outline-light btn-outline-primary" href="{% url 'services:contact' %}" role="button" @click="closeMenu()">Contact</a></li>
<li class="mr-17"><a class="btn btn-outline-light btn-outline-primary" href="{% url 'services:contact' %}" role="button">Contact</a></li>
</ul>
</nav>
</div>
<div class="nav__toggle">
<button @click="toggleMenu()"
name="menu"
class="nav__button"
role="button"
:aria-expanded="isMenuOpen.toString()"
aria-controls="navMenu">
<button @click="sideNav = !sideNav" name="menu" class="nav__button" role="button">
<svg class="nav__button-svg" width="22" height="24">
<line class="button-svg__line"
:class="isMenuOpen ? 'svg-line-top' : ''"
x1="0" x2="22" y1="6" y2="6"></line>
<line class="button-svg__line"
:class="isMenuOpen ? 'svg-line-center' : ''"
x1="0" x2="22" y1="12" y2="12"></line>
<line class="button-svg__line"
:class="isMenuOpen ? 'svg-line-bottom' : ''"
x1="0" x2="22" y1="18" y2="18"></line>
<line class="button-svg__line" :class="{ 'svg-line-top': sideNav === true }" id="top" x1="0" x2="22"
y1="6" y2="6"></line>
<line class="button-svg__line" :class="{ 'svg-line-center': sideNav === true }" id="middle" x1="0"
x2="22" y1="12" y2="12">
</line>
<line class="button-svg__line" :class="{ 'svg-line-bottom': sideNav === true }" id="bottom" x1="0"
x2="22" y1="18" y2="18"></line>
</svg>
</button>
</div>
@ -126,7 +82,7 @@
</div>
</div>
</header>
{% block content %}
{% endblock %}
@ -180,7 +136,7 @@
</a>
</div>
<div class="fs-14 fw-semibold">
<p>Unlock the Power of Sovereign Managed Applications</p>
<p>Unlock the Power of Cloud Native Applications</p>
</div>
<div class="d-flex align-items-center space-x-20">
<a href="https://www.linkedin.com/company/servala/">
@ -192,7 +148,7 @@
</a>
</div>
</div>
<div class="col-12 col-lg-2 mb-60 mb-lg-0">
<div class="space-y-20">
<h4 class="fs-base fw-semibold">Contents</h4>
@ -206,7 +162,7 @@
</ul>
</div>
</div>
<div class="col-12 col-lg-2 mb-60 mb-lg-0">
<div class="space-y-20">
<h4 class="fs-base fw-semibold">Company</h4>
@ -218,7 +174,7 @@
</ul>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="space-y-20 w-lg-90">
<h4 class="fs-base fw-semibold">Contact</h4>
@ -275,7 +231,7 @@
</ul>
</div>
</div>
</div>
</div>
</section>

View file

@ -1,749 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}CSP ROI Calculator{% endblock %}
{% block extra_head %}
<meta name="csrf-token" content="{{ csrf_token }}">
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href='{% static "css/roi-calculator.css" %}'>
{% endblock %}
{% block extra_js %}
<script src="{% static "js/chart.umd.min.js" %}"></script>
<script src="{% static "js/jspdf.umd.min.js" %}"></script>
<!-- ROI Calculator Modules -->
<script src="{% static "js/roi-calculator/input-utils.js" %}"></script>
<script src="{% static "js/roi-calculator/calculator-core.js" %}"></script>
<script src="{% static "js/roi-calculator/chart-manager.js" %}"></script>
<script src="{% static "js/roi-calculator/ui-manager.js" %}"></script>
<script src="{% static "js/roi-calculator/export-manager.js" %}"></script>
<script src="{% static "js/roi-calculator/roi-calculator-app.js" %}"></script>
<script>
// Global function wrappers for HTML onclick handlers
function updateCalculations() { window.ROICalculatorApp?.updateCalculations(); }
function exportToPDF() { window.ROICalculatorApp?.exportToPDF(); }
function exportToCSV() { window.ROICalculatorApp?.exportToCSV(); }
function handleInvestmentAmountInput(input) { InputUtils.handleInvestmentAmountInput(input); }
function handleInvestmentAmountFocus(input) { InputUtils.handleInvestmentAmountFocus(input); }
function handleInvestmentAmountBlur(input) { InputUtils.handleInvestmentAmountBlur(input); }
function updateInvestmentAmount(value) { window.ROICalculatorApp?.updateInvestmentAmount(value); }
function updateRevenuePerInstance(value) { window.ROICalculatorApp?.updateRevenuePerInstance(value); }
function updateServalaShare(value) { window.ROICalculatorApp?.updateServalaShare(value); }
function updateGracePeriod(value) { window.ROICalculatorApp?.updateGracePeriod(value); }
function updateLoanRate(value) { window.ROICalculatorApp?.updateLoanRate(value); }
function updateCoreServiceRevenue(value) { window.ROICalculatorApp?.updateCoreServiceRevenue(value); }
function updateCurrency() {
const currencyElement = document.getElementById('currency');
const value = currencyElement ? currencyElement.value : 'CHF';
window.ROICalculatorApp?.updateCurrency(value);
}
function updateScenarioChurn(scenarioKey, churnRate) { window.ROICalculatorApp?.updateScenarioChurn(scenarioKey, churnRate); }
function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) { window.ROICalculatorApp?.updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth); }
function resetAdvancedParameters() { window.ROICalculatorApp?.resetAdvancedParameters(); }
function toggleScenario(scenarioKey) { window.ROICalculatorApp?.toggleScenario(scenarioKey); }
function updateMonthlyBreakdownFilters() { window.ROICalculatorApp?.updateMonthlyBreakdownFilters(); }
function toggleCollapsible(elementId) { window.ROICalculatorApp?.toggleCollapsible(elementId); }
function resetCalculator() { window.ROICalculatorApp?.resetCalculator(); }
// toggleInvestmentModel function removed - both models calculated simultaneously
function logout() { window.ROICalculatorApp?.logout(); }
// Manual toggle functions for collapse elements
function toggleAdvancedControls() {
const element = document.getElementById('advancedControls');
const button = document.getElementById('advancedToggleBtn');
console.log('Toggling advanced controls, current classes:', element.className);
if (element.style.display === 'none' || element.style.display === '') {
element.style.display = 'block';
button.innerHTML = '<i class="bi bi-gear"></i> Less';
console.log('Showing advanced controls');
} else {
element.style.display = 'none';
button.innerHTML = '<i class="bi bi-gear"></i> More';
console.log('Hiding advanced controls');
}
}
function toggleDataCollapse() {
const element = document.getElementById('dataCollapse');
const button = document.getElementById('dataToggleBtn');
console.log('Toggling data collapse, current style:', element.style.display);
if (element.style.display === 'none' || element.style.display === '') {
element.style.display = 'block';
button.classList.remove('collapsed');
button.setAttribute('aria-expanded', 'true');
console.log('Showing data collapse');
} else {
element.style.display = 'none';
button.classList.add('collapsed');
button.setAttribute('aria-expanded', 'false');
console.log('Hiding data collapse');
}
}
// Initialize collapse states
document.addEventListener('DOMContentLoaded', function() {
// Ensure both sections start collapsed
const advancedControls = document.getElementById('advancedControls');
const dataCollapse = document.getElementById('dataCollapse');
if (advancedControls) {
advancedControls.style.display = 'none';
}
if (dataCollapse) {
dataCollapse.style.display = 'none';
}
console.log('Collapse elements initialized as hidden');
});
</script>
{% endblock %}
{% block content %}
<div class="container-fluid p-0" style="min-height: 100vh;">
<!-- Minimal Header with Controls -->
<div class="bg-light border-bottom sticky-top" style="z-index: 1030;">
<div class="container-fluid">
<!-- Title Row -->
<div class="row py-2 border-bottom">
<div class="col-md-6">
<h4 class="mb-0">CSP ROI Calculator</h4>
<small class="text-muted">Real-time investment analysis</small>
</div>
<div class="col-md-6 text-end">
<a href="{% url 'services:roi_calculator_help' %}" class="btn btn-sm btn-outline me-1" target="_blank">
<i class="bi bi-question-circle"></i> Help
</a>
<button type="button" class="btn btn-sm btn-outline-primary me-1" onclick="exportToPDF()">
<i class="bi bi-file-pdf"></i> PDF
</button>
<button type="button" class="btn btn-sm btn-outline-success me-1" onclick="exportToCSV()">
<i class="bi bi-file-csv"></i> CSV
</button>
<button type="button" class="btn btn-sm btn-outline me-1" onclick="resetCalculator()">
<i class="bi bi-arrow-clockwise"></i> Reset
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="logout()">
<i class="bi bi-box-arrow-right"></i> Logout
</button>
</div>
</div>
<!-- Main Configuration Section -->
<div class="py-4">
<div class="row">
<!-- Left Column: Configuration Fields -->
<div class="col-lg-8 col-xl-7">
<div class="main-config-fields">
<!-- Investment Amount -->
<div class="mb-4">
<label class="form-label fw-semibold mb-2">Initial Investment</label>
<div class="input-group input-group-lg">
<span class="input-group-text" id="investment-currency-prefix">CHF</span>
<input type="text" class="form-control" id="investment-amount"
data-value="500000" value="500,000"
oninput="handleInvestmentAmountInput(this)"
onfocus="handleInvestmentAmountFocus(this)"
onblur="handleInvestmentAmountBlur(this)"
placeholder="Enter amount (100,000 - 2,000,000)">
</div>
<input type="range" class="form-range mt-3" id="investment-slider"
min="100000" max="2000000" step="50000" value="500000"
onchange="updateInvestmentAmount(this.value)">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted" id="investment-min-label">CHF 100K</small>
<small class="text-muted" id="investment-max-label">CHF 2M</small>
</div>
</div>
<!-- Currency, Analysis Period & Service Revenue Row -->
<div class="row mb-4">
<div class="col-md-3">
<label class="form-label fw-semibold mb-2">Currency</label>
<select class="form-select form-select-lg" id="currency" onchange="updateCurrency()">
<option value="CHF" selected>CHF (Swiss Franc)</option>
<option value="EUR">EUR (Euro)</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold mb-2">Analysis Period</label>
<select class="form-select form-select-lg" id="timeframe" onchange="updateCalculations()">
<option value="1">1 Year</option>
<option value="2">2 Years</option>
<option value="3" selected>3 Years</option>
<option value="4">4 Years</option>
<option value="5">5 Years</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold mb-2">Service Revenue per Instance</label>
<div class="input-group input-group-lg">
<input type="number" class="form-control" id="revenue-per-instance"
min="20" max="200" step="5" value="50" onchange="updateCalculations()">
<span class="input-group-text" id="revenue-currency-suffix">CHF/month</span>
</div>
<input type="range" class="form-range mt-3" id="revenue-slider"
min="20" max="200" step="5" value="50"
onchange="updateRevenuePerInstance(this.value)">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted" id="revenue-min-label">CHF 20</small>
<small class="text-muted" id="revenue-max-label">CHF 200</small>
</div>
</div>
</div>
<!-- Growth Scenarios -->
<div class="mb-4">
<label class="form-label fw-semibold mb-3">Growth Scenarios</label>
<div class="d-flex gap-4 flex-wrap">
<div class="form-check form-check-lg">
<input class="form-check-input" type="checkbox" id="conservative-enabled" checked onchange="toggleScenario('conservative')">
<label class="form-check-label fw-medium" for="conservative-enabled" data-bs-toggle="tooltip" title="Conservative: 2% churn, steady growth">
<span class="text-success fs-4"></span> Conservative
</label>
</div>
<div class="form-check form-check-lg">
<input class="form-check-input" type="checkbox" id="moderate-enabled" checked onchange="toggleScenario('moderate')">
<label class="form-check-label fw-medium" for="moderate-enabled" data-bs-toggle="tooltip" title="Moderate: 3% churn, balanced growth">
<span class="text-warning fs-4"></span> Moderate
</label>
</div>
<div class="form-check form-check-lg">
<input class="form-check-input" type="checkbox" id="aggressive-enabled" checked onchange="toggleScenario('aggressive')">
<label class="form-check-label fw-medium" for="aggressive-enabled" data-bs-toggle="tooltip" title="Aggressive: 5% churn, rapid growth">
<span class="text-danger fs-4"></span> Aggressive
</label>
</div>
</div>
</div>
<!-- Investment Benefits Display -->
<div class="row mb-4" id="investment-benefits">
<div class="col-12">
<div class="card bg-light border-0">
<div class="card-header bg-success text-white">
<h6 class="mb-0"><i class="bi bi-trophy"></i> Your Investment Benefits</h6>
</div>
<div class="card-body py-3">
<div class="row text-center">
<div class="col-md-3 col-6 mb-2">
<div class="benefit-metric">
<div class="benefit-value text-primary fw-bold" id="instance-scaling">1.0x</div>
<div class="benefit-label small text-muted">Instance Scaling</div>
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="benefit-metric">
<div class="benefit-value text-success fw-bold" id="revenue-premium">+0%</div>
<div class="benefit-label small text-muted">Revenue Premium</div>
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="benefit-metric">
<div class="benefit-value text-info fw-bold" id="grace-period-display">6 months</div>
<div class="benefit-label small text-muted">Grace Period</div>
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="benefit-metric">
<div class="benefit-value text-warning fw-bold" id="max-bonus">15%</div>
<div class="benefit-label small text-muted">Max Performance Bonus</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex gap-3 flex-wrap">
<button class="btn btn-outline-info btn-lg" type="button" onclick="toggleAdvancedControls()" id="advancedToggleBtn">
<i class="bi bi-gear"></i> Advanced Settings
</button>
</div>
</div>
</div>
<!-- Right Column: Real-Time Results -->
<div class="col-lg-4 col-xl-5">
<div class="results-panel h-100">
<div class="card h-100 border-0 shadow">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-graph-up"></i> Real-Time Results</h5>
<small class="opacity-75">Live calculations based on your parameters</small>
</div>
<div class="card-body d-flex flex-column justify-content-center">
<!-- Direct Investment Results -->
<div class="result-item mb-4">
<div class="d-flex align-items-center mb-2">
<div class="result-icon bg-success text-white rounded-circle me-3 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
<i class="bi bi-rocket"></i>
</div>
<div>
<h6 class="mb-0 text-success fw-bold">Direct Investment</h6>
<small class="text-muted">Performance-based returns</small>
</div>
</div>
<div class="result-metrics bg-light rounded p-3">
<div class="row text-center">
<div class="col-6">
<div class="h4 mb-1 text-success fw-bold" id="net-position-direct">CHF 0</div>
<small class="text-muted">Net Profit</small>
</div>
<div class="col-6">
<div class="h4 mb-1 text-primary fw-bold" id="roi-percentage-direct">0%</div>
<small class="text-muted">Total ROI</small>
</div>
</div>
</div>
</div>
<!-- Loan Model Results -->
<div class="result-item">
<div class="d-flex align-items-center mb-2">
<div class="result-icon bg-warning text-dark rounded-circle me-3 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
<i class="bi bi-bank"></i>
</div>
<div>
<h6 class="mb-0 text-warning fw-bold">Loan Model</h6>
<small class="text-muted">Fixed guaranteed returns</small>
</div>
</div>
<div class="result-metrics bg-light rounded p-3">
<div class="row text-center">
<div class="col-6">
<div class="h4 mb-1 text-success fw-bold" id="net-position-loan">CHF 0</div>
<small class="text-muted">Net Profit</small>
</div>
<div class="col-6">
<div class="h4 mb-1 text-primary fw-bold" id="roi-percentage-loan">0%</div>
<small class="text-muted">Total ROI</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Controls Section -->
<div class="collapse" id="advancedControls">
<div class="bg-light border-top py-4">
<div class="container-fluid">
<h6 class="text-primary mb-4"><i class="bi bi-gear"></i> Advanced Configuration</h6>
<!-- Investment Model Parameters -->
<div class="row mb-4">
<div class="col-12">
<h6 class="text-secondary mb-3">Investment Model Settings</h6>
</div>
<!-- Loan Rate -->
<div class="col-lg-3 col-md-6 mb-3">
<label class="form-label fw-semibold mb-2">Loan Interest Rate</label>
<div class="input-group">
<input type="number" class="form-control" id="loan-interest-rate"
min="3" max="8" step="0.1" value="5.0" onchange="updateCalculations()">
<span class="input-group-text">% annual</span>
</div>
<input type="range" class="form-range mt-2" id="loan-rate-slider"
min="3" max="8" step="0.1" value="5.0" onchange="updateLoanRate(this.value)">
<div class="d-flex justify-content-between">
<small class="text-muted">3%</small>
<small class="text-muted">8%</small>
</div>
</div>
<!-- Servala Share -->
<div class="col-lg-3 col-md-6 mb-3">
<label class="form-label fw-semibold mb-2">Servala Revenue Share</label>
<div class="input-group">
<input type="number" class="form-control" id="servala-share"
min="10" max="40" step="1" value="25" onchange="updateCalculations()">
<span class="input-group-text">%</span>
</div>
<input type="range" class="form-range mt-2" id="share-slider"
min="10" max="40" step="1" value="25" onchange="updateServalaShare(this.value)">
<div class="d-flex justify-content-between">
<small class="text-muted">10%</small>
<small class="text-muted">40%</small>
</div>
</div>
<!-- Grace Period -->
<div class="col-lg-3 col-md-6 mb-3">
<label class="form-label fw-semibold mb-2">Grace Period</label>
<div class="input-group">
<input type="number" class="form-control" id="grace-period"
min="0" max="24" step="1" value="6" onchange="updateCalculations()">
<span class="input-group-text">months</span>
</div>
<input type="range" class="form-range mt-2" id="grace-slider"
min="0" max="24" step="1" value="6" onchange="updateGracePeriod(this.value)">
<div class="d-flex justify-content-between">
<small class="text-muted">0</small>
<small class="text-muted">24</small>
</div>
</div>
<!-- Core Service Revenue -->
<div class="col-lg-3 col-md-6 mb-3">
<label class="form-label fw-semibold mb-2">Core Service Revenue</label>
<div class="input-group">
<input type="number" class="form-control" id="core-service-revenue"
min="0" max="500" step="5" value="0" onchange="updateCalculations()">
<span class="input-group-text">CHF/month</span>
</div>
<input type="range" class="form-range mt-2" id="core-revenue-slider"
min="0" max="500" step="5" value="0" onchange="updateCoreServiceRevenue(this.value)">
<div class="d-flex justify-content-between">
<small class="text-muted">CHF 0</small>
<small class="text-muted">CHF 500</small>
</div>
<small class="text-muted">Additional compute/storage revenue per instance</small>
</div>
</div>
<!-- Scenario Parameters -->
<div class="row mb-4">
<div class="col-12">
<h6 class="text-secondary mb-3">Growth Scenario Tuning</h6>
</div>
<!-- Conservative Scenario -->
<div class="col-lg-4 mb-4">
<div class="card h-100">
<div class="card-header bg-success text-white">
<h6 class="mb-0"><i class="bi bi-shield-check"></i> Conservative Scenario</h6>
</div>
<div class="card-body">
<!-- Churn Rate -->
<div class="mb-3">
<label class="form-label fw-semibold mb-2">Monthly Churn Rate</label>
<div class="input-group input-group-sm">
<input type="number" class="form-control" id="conservative-churn"
min="0" max="10" step="0.1" value="2.0" onchange="updateScenarioChurn('conservative', this.value)">
<span class="input-group-text">%</span>
</div>
</div>
<!-- Phase Growth Parameters -->
<label class="form-label fw-semibold mb-2">Instance Growth per Phase</label>
<div class="row g-2">
<div class="col-6">
<label class="form-label small">Phase 1 (6mo)</label>
<input type="number" class="form-control form-control-sm" id="conservative-phase-0"
min="10" max="200" value="50" onchange="updateScenarioPhase('conservative', 0, this.value)">
</div>
<div class="col-6">
<label class="form-label small">Phase 2 (6mo)</label>
<input type="number" class="form-control form-control-sm" id="conservative-phase-1"
min="10" max="200" value="75" onchange="updateScenarioPhase('conservative', 1, this.value)">
</div>
<div class="col-6">
<label class="form-label small">Phase 3 (12mo)</label>
<input type="number" class="form-control form-control-sm" id="conservative-phase-2"
min="10" max="300" value="100" onchange="updateScenarioPhase('conservative', 2, this.value)">
</div>
<div class="col-6">
<label class="form-label small">Phase 4 (12mo)</label>
<input type="number" class="form-control form-control-sm" id="conservative-phase-3"
min="10" max="300" value="150" onchange="updateScenarioPhase('conservative', 3, this.value)">
</div>
</div>
</div>
</div>
</div>
<!-- Moderate Scenario -->
<div class="col-lg-4 mb-4">
<div class="card h-100">
<div class="card-header bg-warning text-dark">
<h6 class="mb-0"><i class="bi bi-speedometer2"></i> Moderate Scenario</h6>
</div>
<div class="card-body">
<!-- Churn Rate -->
<div class="mb-3">
<label class="form-label fw-semibold mb-2">Monthly Churn Rate</label>
<div class="input-group input-group-sm">
<input type="number" class="form-control" id="moderate-churn"
min="0" max="10" step="0.1" value="3.0" onchange="updateScenarioChurn('moderate', this.value)">
<span class="input-group-text">%</span>
</div>
</div>
<!-- Phase Growth Parameters -->
<label class="form-label fw-semibold mb-2">Instance Growth per Phase</label>
<div class="row g-2">
<div class="col-6">
<label class="form-label small">Phase 1 (6mo)</label>
<input type="number" class="form-control form-control-sm" id="moderate-phase-0"
min="20" max="300" value="100" onchange="updateScenarioPhase('moderate', 0, this.value)">
</div>
<div class="col-6">
<label class="form-label small">Phase 2 (6mo)</label>
<input type="number" class="form-control form-control-sm" id="moderate-phase-1"
min="20" max="400" value="200" onchange="updateScenarioPhase('moderate', 1, this.value)">
</div>
<div class="col-6">
<label class="form-label small">Phase 3 (12mo)</label>
<input type="number" class="form-control form-control-sm" id="moderate-phase-2"
min="20" max="500" value="300" onchange="updateScenarioPhase('moderate', 2, this.value)">
</div>
<div class="col-6">
<label class="form-label small">Phase 4 (12mo)</label>
<input type="number" class="form-control form-control-sm" id="moderate-phase-3"
min="20" max="600" value="400" onchange="updateScenarioPhase('moderate', 3, this.value)">
</div>
</div>
</div>
</div>
</div>
<!-- Aggressive Scenario -->
<div class="col-lg-4 mb-4">
<div class="card h-100">
<div class="card-header bg-danger text-white">
<h6 class="mb-0"><i class="bi bi-rocket"></i> Aggressive Scenario</h6>
</div>
<div class="card-body">
<!-- Churn Rate -->
<div class="mb-3">
<label class="form-label fw-semibold mb-2">Monthly Churn Rate</label>
<div class="input-group input-group-sm">
<input type="number" class="form-control" id="aggressive-churn"
min="0" max="15" step="0.1" value="5.0" onchange="updateScenarioChurn('aggressive', this.value)">
<span class="input-group-text">%</span>
</div>
</div>
<!-- Phase Growth Parameters -->
<label class="form-label fw-semibold mb-2">Instance Growth per Phase</label>
<div class="row g-2">
<div class="col-6">
<label class="form-label small">Phase 1 (6mo)</label>
<input type="number" class="form-control form-control-sm" id="aggressive-phase-0"
min="50" max="500" value="200" onchange="updateScenarioPhase('aggressive', 0, this.value)">
</div>
<div class="col-6">
<label class="form-label small">Phase 2 (6mo)</label>
<input type="number" class="form-control form-control-sm" id="aggressive-phase-1"
min="50" max="600" value="400" onchange="updateScenarioPhase('aggressive', 1, this.value)">
</div>
<div class="col-6">
<label class="form-label small">Phase 3 (12mo)</label>
<input type="number" class="form-control form-control-sm" id="aggressive-phase-2"
min="50" max="800" value="600" onchange="updateScenarioPhase('aggressive', 2, this.value)">
</div>
<div class="col-6">
<label class="form-label small">Phase 4 (12mo)</label>
<input type="number" class="form-control form-control-sm" id="aggressive-phase-3"
min="50" max="1000" value="800" onchange="updateScenarioPhase('aggressive', 3, this.value)">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Reset Button -->
<div class="row">
<div class="col-12 text-center">
<button type="button" class="btn btn-outline-secondary" onclick="resetAdvancedParameters()">
<i class="bi bi-arrow-clockwise"></i> Reset to Defaults
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CHARTS - Maximum Space -->
<div class="container-fluid px-3 py-3" style="background: #f8f9fa;">
<!-- Loading Spinner -->
<div class="loading-spinner text-center py-5" id="loading-spinner" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Calculating...</span>
</div>
<p class="mt-2">Calculating scenarios...</p>
</div>
<!-- PRIMARY CHART - Full Width, Large Height -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 pb-0">
<h5 class="mb-1"><i class="bi bi-graph-up-arrow text-primary"></i> ROI Progression Over Time</h5>
<p class="small text-muted mb-0">Investment profitability timeline - when you'll break even and achieve target returns</p>
</div>
<div class="card-body pt-3">
<canvas id="instanceGrowthChart" style="height: 500px; width: 100%;"></canvas>
</div>
</div>
</div>
</div>
<!-- SECONDARY CHARTS - Side by Side, Large -->
<div class="row mb-4">
<div class="col-xl-6 mb-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0 pb-0">
<h5 class="mb-1"><i class="bi bi-cash-stack text-success"></i> Net Financial Position</h5>
<p class="small text-muted mb-0">Cumulative profit/loss over time</p>
</div>
<div class="card-body pt-3">
<canvas id="revenueChart" style="height: 400px; width: 100%;"></canvas>
</div>
</div>
</div>
<div class="col-xl-6 mb-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0 pb-0">
<h5 class="mb-1"><i class="bi bi-bar-chart text-warning"></i> Performance Comparison</h5>
<p class="small text-muted mb-0">ROI performance across growth scenarios</p>
</div>
<div class="card-body pt-3">
<canvas id="cashFlowChart" style="height: 400px; width: 100%;"></canvas>
</div>
</div>
</div>
</div>
<!-- CSP REVENUE BREAKDOWN CHART - Full Width -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 pb-0">
<h5 class="mb-1"><i class="bi bi-cash-stack text-success"></i> CSP Revenue Breakdown</h5>
<p class="small text-muted mb-0">Direct investment revenue breakdown: Service fees, core infrastructure sales, total CSP revenue, and Servala share over time</p>
</div>
<div class="card-body pt-3">
<canvas id="cspRevenueChart" style="height: 400px; width: 100%;"></canvas>
</div>
</div>
</div>
</div>
<!-- DATA TABLE - Collapsible to Save Space -->
<div class="row mb-4">
<div class="col-12">
<div class="accordion" id="dataAccordion">
<div class="accordion-item border-0 shadow-sm">
<h2 class="accordion-header" id="dataHeading">
<button class="accordion-button collapsed" type="button" onclick="toggleDataCollapse()" id="dataToggleBtn">
<i class="bi bi-table me-2"></i> Detailed Financial Analysis
</button>
</h2>
<div id="dataCollapse" class="accordion-collapse collapse" aria-labelledby="dataHeading" data-bs-parent="#dataAccordion">
<div class="accordion-body">
<!-- Improved Scenario Performance Summary -->
<h6 class="mb-3">Investment Model Comparison by Scenario</h6>
<div class="table-responsive mb-4">
<table class="table table-sm table-hover" id="comparison-table">
<thead class="table-dark">
<tr>
<th>Scenario</th>
<th>Investment Model</th>
<th>Final Scale</th>
<th>Your Net Profit</th>
<th>Total ROI</th>
<th>Break-even Time</th>
<th>Key Features</th>
</tr>
</thead>
<tbody id="comparison-tbody">
<!-- Dynamic content -->
</tbody>
</table>
</div>
<!-- Monthly Financial Flow -->
<h6 class="mb-3">Monthly Financial Breakdown</h6>
<!-- Breakdown Filter Controls -->
<div class="row mb-3 breakdown-filters">
<div class="col-md-6">
<label class="form-label small fw-semibold mb-2">Investment Models</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="breakdown-direct-enabled" checked onchange="updateMonthlyBreakdownFilters()">
<label class="form-check-label small fw-medium text-success" for="breakdown-direct-enabled">
Direct Investment
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="breakdown-loan-enabled" checked onchange="updateMonthlyBreakdownFilters()">
<label class="form-check-label small fw-medium text-warning" for="breakdown-loan-enabled">
Loan Model
</label>
</div>
</div>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold mb-2">Growth Scenarios</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="breakdown-conservative-enabled" checked onchange="updateMonthlyBreakdownFilters()">
<label class="form-check-label small fw-medium" for="breakdown-conservative-enabled">
<span class="text-success"></span> Conservative
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="breakdown-moderate-enabled" checked onchange="updateMonthlyBreakdownFilters()">
<label class="form-check-label small fw-medium" for="breakdown-moderate-enabled">
<span class="text-warning"></span> Moderate
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="breakdown-aggressive-enabled" checked onchange="updateMonthlyBreakdownFilters()">
<label class="form-check-label small fw-medium" for="breakdown-aggressive-enabled">
<span class="text-danger"></span> Aggressive
</label>
</div>
</div>
</div>
</div>
<div class="table-responsive" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped" id="monthly-table">
<thead class="table-dark sticky-top">
<tr>
<th>Month</th>
<th>Scenario</th>
<th>Model</th>
<th>Instances</th>
<th>Service Revenue</th>
<th>Core Revenue</th>
<th>Total Revenue</th>
<th>Your Share</th>
<th>Servala Share</th>
<th>Cumulative Net Position</th>
</tr>
</thead>
<tbody id="monthly-tbody">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,46 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Authentication Required{% endblock %}
{% block content %}
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow">
<div class="card-body">
<div class="text-center mb-4">
<i class="bi bi-shield-lock text-primary" style="font-size: 3rem;"></i>
<h2 class="h4 mt-3">Authentication Required</h2>
<p class="text-muted">Please enter the password to access the CSP ROI Calculator</p>
</div>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %}" role="alert">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% if not password_error %}
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Access Calculator</button>
</form>
{% else %}
<div class="text-center">
<p class="text-muted">The calculator is temporarily unavailable due to configuration issues.</p>
<a href="/" class="btn btn-outline-secondary">Return to Homepage</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,755 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}ROI Calculator Help - Servala Investment Models{% endblock %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href='{% static "css/roi-calculator.css" %}'>
<style>
.help-section {
margin-bottom: 2rem;
}
.help-section h2 {
color: #007bff;
border-bottom: 2px solid #007bff;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.help-section h3 {
color: #28a745;
margin-top: 1.5rem;
}
.comparison-table {
font-size: 0.9rem;
}
.model-card {
border-left: 4px solid;
padding: 1rem;
margin-bottom: 1rem;
background: #f8f9fa;
}
.loan-model {
border-left-color: #ffc107;
}
.direct-model {
border-left-color: #28a745;
}
/* Enhanced navigation styling */
.list-group-item {
border: none !important;
font-size: 0.9rem;
transition: all 0.2s ease;
display: block !important;
width: 100% !important;
clear: both;
}
.list-group-flush {
display: flex !important;
flex-direction: column !important;
}
.list-group-item:hover {
background-color: #f8f9fa;
padding-left: 1rem !important;
}
.list-group-item.active {
background-color: #007bff;
color: white;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Section spacing */
.help-section {
scroll-margin-top: 2rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container my-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1>ROI Calculator Help</h1>
<p class="text-muted">Understanding Servala's Investment Models</p>
</div>
<div>
<a href="{% url 'services:csp_roi_calculator' %}" class="btn btn-primary">
<i class="bi bi-arrow-left"></i> Back to Calculator
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<!-- Investment Scaling Benefits Section (MOST IMPORTANT) -->
<div class="help-section" id="investment-benefits">
<h2><i class="bi bi-graph-up text-primary"></i> Investment Benefits & Market-Realistic Returns</h2>
<div class="alert alert-info">
<h5><i class="bi bi-bar-chart"></i> Conservative Scaling Based on Industry Standards</h5>
<p class="mb-0">Our calculator uses proven, market-realistic scaling based on European managed services industry data. Returns are conservative and align with current investment expectations.</p>
</div>
<h3>Market-Realistic Investment Benefits</h3>
<div class="table-responsive">
<table class="table table-striped comparison-table">
<thead class="table-success">
<tr>
<th>Investment Amount</th>
<th>Instance Multiplier</th>
<th>Revenue Premium</th>
<th>Performance Bonus Cap</th>
<th>Grace Period Bonus</th>
<th>Expected 3-Year ROI</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>500,000</strong></td>
<td>1.0x</td>
<td>Standard rates</td>
<td>8%</td>
<td>Base period</td>
<td class="text-success"><strong>40-60%</strong></td>
</tr>
<tr class="table-warning">
<td><strong>1,000,000</strong></td>
<td><span class="text-primary">1.5x</span></td>
<td><span class="text-primary">+10% per instance</span></td>
<td>10%</td>
<td>+2 months</td>
<td class="text-success"><strong>60-80%</strong></td>
</tr>
<tr class="table-info">
<td><strong>1,500,000</strong></td>
<td><span class="text-success">1.8x</span></td>
<td><span class="text-success">+15% per instance</span></td>
<td>12%</td>
<td>+3 months</td>
<td class="text-success"><strong>70-90%</strong></td>
</tr>
<tr class="table-success">
<td><strong>2,000,000</strong></td>
<td><span class="text-success fw-bold">2.0x</span></td>
<td><span class="text-success fw-bold">+20% per instance</span></td>
<td><span class="text-success fw-bold">15%</span></td>
<td>+3 months</td>
<td class="text-success"><strong>80-100%</strong></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Model Comparison Section -->
<div class="help-section" id="comparison">
<h2><i class="bi bi-bar-chart"></i> Investment Model Comparison</h2>
<div class="row">
<div class="col-md-6">
<div class="model-card loan-model">
<h5><i class="bi bi-bank"></i> Loan Model</h5>
<p><strong>3-8% Annual Returns</strong></p>
<p>Fixed interest lending with guaranteed monthly payments. Low risk, predictable returns.</p>
<ul class="text-muted small">
<li>Guaranteed monthly payments</li>
<li>No performance risk</li>
<li>Fixed 3-8% annual returns</li>
<li>Contractual protection</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="model-card direct-model">
<h5><i class="bi bi-rocket"></i> Direct Investment</h5>
<p><strong>40-100% Market-Realistic Returns</strong></p>
<p>Performance-based revenue sharing with conservative scaling and sustainable grace periods.</p>
<ul class="text-success small">
<li>Linear scaling up to 2.0x instances</li>
<li>Revenue premiums up to 20%</li>
<li>Performance bonuses up to 15%</li>
<li>Grace periods max 6 months</li>
</ul>
</div>
</div>
</div>
<h3>Direct Comparison - 3 Years ROI:</h3>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Model</th>
<th>Risk Level</th>
<th>Expected ROI</th>
<th>Break-even</th>
<th>Profit Potential</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Loan Model (1M)</strong></td>
<td><span class="badge bg-success">Low</span></td>
<td>15-25% over 3 years</td>
<td>12-18 months</td>
<td>150,000 - 250,000</td>
</tr>
<tr class="table-warning">
<td><strong>Direct Investment (1M)</strong></td>
<td><span class="badge bg-warning">Moderate</span></td>
<td>60-80% over 3 years</td>
<td>18-24 months</td>
<td>600,000 - 800,000</td>
</tr>
<tr class="table-success">
<td><strong>Direct Investment (2M)</strong></td>
<td><span class="badge bg-danger">High</span></td>
<td>80-100% over 3 years</td>
<td>20-26 months</td>
<td>1,600,000 - 2,000,000</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Calculator Guide Section -->
<div class="help-section" id="calculator-guide">
<h2><i class="bi bi-calculator"></i> How to Use the Calculator</h2>
<h3>Quick Start Guide</h3>
<div class="row">
<div class="col-md-6">
<h5>Essential Settings</h5>
<ol>
<li><strong>Investment Amount:</strong> Use slider or type amount (100K - 2M)</li>
<li><strong>Timeframe:</strong> Choose 1-5 years for your projection</li>
<li><strong>Currency:</strong> Select CHF or EUR</li>
<li><strong>Growth Scenario:</strong> Enable scenarios that match your market</li>
</ol>
</div>
<div class="col-md-6">
<h5>Understanding Results</h5>
<ul>
<li><strong>Net Position:</strong> Your profit after investment</li>
<li><strong>ROI Percentage:</strong> Return on investment rate</li>
<li><strong>Investment Benefits:</strong> Real-time scaling display</li>
<li><strong>Break-even:</strong> When you start profiting</li>
</ul>
</div>
</div>
<h3>Advanced Parameters (Optional)</h3>
<div class="row">
<div class="col-md-6">
<ul>
<li><strong>Service Revenue/Instance:</strong> Monthly Servala service fee (20-200)</li>
<li><strong>Core Revenue/Instance:</strong> Additional infrastructure revenue (0-500)</li>
<li><strong>Loan Rate:</strong> Annual interest for loan model (3-8%)</li>
</ul>
</div>
<div class="col-md-6">
<ul>
<li><strong>Servala Share:</strong> Revenue split for direct investment (10-40%)</li>
<li><strong>Grace Period:</strong> 100% revenue retention period (0-24 months)</li>
<li><strong>Churn Rates:</strong> Customer loss by scenario (0-15%)</li>
</ul>
</div>
</div>
</div>
<!-- Growth Scenarios Section -->
<div class="help-section" id="scenarios">
<h2><i class="bi bi-speedometer2"></i> Growth Scenarios</h2>
<p>Choose the scenarios that best match your market conditions and sales capabilities:</p>
<div class="row">
<div class="col-md-4">
<div class="card border-success">
<div class="card-header bg-success text-white">
<h5 class="mb-0">Safe (Conservative)</h5>
</div>
<div class="card-body">
<p><strong>2.5% monthly churn</strong></p>
<p>Steady growth: 15-40 new instances/month</p>
<p>Best for: Established markets, risk-averse CSPs</p>
<p class=\"small text-muted\">~5-8 new clients/month × 3-5 instances each</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-warning">
<div class="card-header bg-warning text-white">
<h5 class="mb-0">Balanced (Moderate)</h5>
</div>
<div class="card-body">
<p><strong>3% monthly churn</strong></p>
<p>Balanced growth: 25-90 new instances/month</p>
<p>Best for: Competitive markets, balanced approach</p>
<p class=\"small text-muted\">~8-15 new clients/month × 3-6 instances each</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h5 class="mb-0">Fast (Aggressive)</h5>
</div>
<div class="card-body">
<p><strong>3.5% monthly churn</strong></p>
<p>Rapid growth: 40-150 new instances/month</p>
<p>Best for: High-growth strategies, active sales</p>
<p class=\"small text-muted\">~12-25 new clients/month × 3-6 instances each</p>
</div>
</div>
</div>
</div>
</div>
<!-- Understanding Charts Section -->
<div class="help-section" id="charts">
<h2><i class="bi bi-graph-up"></i> Reading the Charts</h2>
<h3>Key Charts Explained</h3>
<div class="row">
<div class="col-md-6">
<h5>1. ROI Progression Over Time</h5>
<p>Shows when your investment becomes profitable and how returns develop monthly. Look for the point where lines cross zero.</p>
<h5>2. Net Financial Position</h5>
<p>Your cumulative profit/loss over time. Above zero = profitable, below zero = still recovering investment.</p>
</div>
<div class="col-md-6">
<h5>3. Performance Comparison</h5>
<p>ROI percentages across different growth scenarios - compare best and worst-case outcomes.</p>
<h5>4. CSP Revenue Breakdown</h5>
<p>Monthly revenue analysis showing service revenue, core revenue, CSP total, and Servala revenue share.</p>
</div>
</div>
<h4>Chart Legend</h4>
<ul>
<li><strong>Solid Lines:</strong> Direct Investment Model</li>
<li><strong>Dashed Lines:</strong> Loan Model</li>
<li><strong>Green:</strong> Conservative Scenario | <strong>Yellow:</strong> Moderate | <strong>Red:</strong> Aggressive</li>
</ul>
</div>
<!-- Market Context & Risk Factors Section -->
<div class=\"help-section\" id=\"market-context\">
<h2><i class=\"bi bi-exclamation-triangle text-warning\"></i> Market Context & Risk Factors</h2>
<div class=\"alert alert-warning\">
<h6><i class=\"bi bi-info-circle\"></i> Important Disclaimer</h6>
<p class=\"mb-0\">These projections are based on European managed services market data (13-15% CAGR) and current industry standards. Actual results may vary significantly based on market conditions, execution, and competitive factors.</p>
</div>
<h3>Key Risk Factors</h3>
<div class=\"row\">
<div class=\"col-md-6\">
<h5>Market Risks</h5>
<ul>
<li><strong>Competition:</strong> US hyperscalers (AWS, Azure, Google) may respond aggressively</li>
<li><strong>Market Maturity:</strong> European managed services adoption varies by region</li>
<li><strong>Economic Conditions:</strong> Recession or funding limitations could impact growth</li>
<li><strong>Regulatory Changes:</strong> Data sovereignty laws may change</li>
</ul>
</div>
<div class=\"col-md-6\">
<h5>Execution Risks</h5>
<ul>
<li><strong>Technology Development:</strong> Platform development may face delays</li>
<li><strong>Partner Adoption:</strong> CSPs may be slower to adopt than projected</li>
<li><strong>Talent Acquisition:</strong> Skilled technical resources are scarce</li>
<li><strong>Customer Churn:</strong> Actual churn rates may exceed projections</li>
</ul>
</div>
</div>
<h3>Conservative Scenario Modeling</h3>
<div class="table-responsive">
<table class="table table-striped comparison-table">
<thead class="table-success">
<tr>
<th>Scenario</th>
<th>Market Conditions</th>
<th>Expected Growth</th>
<th>Break-even Time</th>
<th>3-Year ROI Range</th>
</tr>
</thead>
<tbody>
<tr class="table-danger">
<td><strong>Pessimistic</strong></td>
<td>Economic downturn, strong competition</td>
<td>5-10% annually</td>
<td>30-36 months</td>
<td>20-40%</td>
</tr>
<tr class="table-warning">
<td><strong>Realistic</strong></td>
<td>Normal market conditions</td>
<td>15-25% annually</td>
<td>18-24 months</td>
<td>50-80%</td>
</tr>
<tr class="table-success">
<td><strong>Optimistic</strong></td>
<td>Favorable market, rapid adoption</td>
<td>25-35% annually</td>
<td>12-18 months</td>
<td>80-120%</td>
</tr>
</tbody>
</table>
</div>
<div class=\"alert alert-info mt-3\">
<h6><i class=\"bi bi-lightbulb\"></i> Investment Recommendation</h6>
<p class=\"mb-0\">Consider these projections as best-case scenarios under favorable conditions. Prudent investors should plan for the \"Realistic\" scenario while hoping for \"Optimistic\" outcomes.</p>
</div>
</div>
<!-- Market Analysis & Validation Section -->
<div class="help-section" id="market-analysis">
<h2><i class="bi bi-graph-up-arrow text-success"></i> Market Analysis & ROI Validation</h2>
<div class="alert alert-success">
<h6><i class="bi bi-check-circle"></i> Market-Validated Projections</h6>
<p class="mb-0">Our ROI calculator uses data-driven projections based on comprehensive European managed services market research. All growth scenarios and return expectations are benchmarked against industry standards and competitive analysis.</p>
</div>
<h3>European Managed Services Market Reality</h3>
<div class="row">
<div class="col-md-6">
<h5><i class="bi bi-currency-euro"></i> Market Size & Growth</h5>
<ul>
<li><strong>Market Value</strong>: €51-85 billion in 2024 <a href="https://www.grandviewresearch.com/horizon/outlook/managed-services-market/europe" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
<li><strong>Projected Growth</strong>: €113-255 billion by 2030-2033 <a href="https://www.marketdataforecast.com/market-reports/europe-managed-services-market" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
<li><strong>CAGR</strong>: 13-15% annually (matches Servala's assumptions) <a href="https://www.mordorintelligence.com/industry-reports/europe-managed-services-market" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
<li><strong>SME Growth</strong>: 10.6% CAGR (fastest growing segment) <a href="https://www.grandviewresearch.com/industry-analysis/managed-services-market" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
</ul>
</div>
<div class="col-md-6">
<h5><i class="bi bi-cloud"></i> Cloud Adoption Trends</h5>
<ul>
<li><strong>EU Enterprise Adoption</strong>: 45% use cloud services in 2023 <a href="https://ec.europa.eu/eurostat/statistics-explained/index.php/Cloud_computing_-_statistics_on_the_use_by_enterprises" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
<li><strong>Multi-Cloud Strategy</strong>: 92% of organizations use multiple providers <a href="https://www.cloudzero.com/blog/cloud-computing-statistics/" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
<li><strong>Self-Service Growth</strong>: Strong demand for automated provisioning <a href="https://www.appvia.io/blog/why-self-service-is-key-to-cloud-adoption" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
<li><strong>Swiss Leadership</strong>: 95% have adopted some cloud services <a href="https://finance.yahoo.com/news/swiss-public-cloud-market-grows-090000699.html" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
</ul>
</div>
</div>
<h3>Competitive Landscape Analysis</h3>
<div class="alert alert-warning">
<h6><i class="bi bi-exclamation-triangle"></i> European Provider Challenge</h6>
<p class="mb-0">European cloud providers' market share declined from 27% to 13% over the past five years as US hyperscalers (AWS, Azure, GCP) now control 72% of the regional market. <a href="https://www.telecoms.com/public-cloud/european-cloud-players-face-declining-market-share-as-us-hyperscalers-clean-up" target="_blank" class="text-primary">Source: Telecoms.com</a></p>
</div>
<div class="table-responsive">
<table class="table table-striped comparison-table">
<thead class="table-success">
<tr>
<th>Market Factor</th>
<th>Current Reality</th>
<th>Servala Opportunity</th>
<th>Source</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Hyperscaler Dominance</strong></td>
<td>AWS (31%), Azure (25%), GCP (11%)</td>
<td>European alternative with data sovereignty</td>
<td><a href="https://holori.com/cloud-market-share-2024-aws-azure-gcp/" target="_blank" class="text-primary">Holori 2024</a></td>
</tr>
<tr>
<td><strong>Data Sovereignty</strong></td>
<td>GDPR, NIS2 compliance requirements</td>
<td>Built-in European regulatory compliance</td>
<td><a href="https://thenextweb.com/news/european-cloud-alternative-to-aws-azure-and-gcp" target="_blank" class="text-primary">TNW Analysis</a></td>
</tr>
<tr>
<td><strong>Vendor Lock-in Concerns</strong></td>
<td>Growing enterprise resistance</td>
<td>Open-source, multi-cloud architecture</td>
<td><a href="https://www.computerweekly.com/feature/Introducing-the-EuroStack-initiative-Could-this-turn-the-tide-on-hyperscale-cloud-in-Europe" target="_blank" class="text-primary">Computer Weekly</a></td>
</tr>
<tr>
<td><strong>Regional Provider Decline</strong></td>
<td>Market share falling despite 167% revenue growth</td>
<td>Platform to help regain competitiveness</td>
<td><a href="https://www.telecoms.com/public-cloud/european-cloud-players-face-declining-market-share-as-us-hyperscalers-clean-up" target="_blank" class="text-primary">Telecoms.com</a></td>
</tr>
</tbody>
</table>
</div>
<h3>ROI Projections: Market Benchmarking</h3>
<div class="row">
<div class="col-md-6">
<h5><i class="bi bi-calculator"></i> Growth Scenario Validation</h5>
<div class="card border-success">
<div class="card-body">
<h6 class="card-title text-success">Conservative Scenario</h6>
<p><strong>15-40 instances/month</strong></p>
<ul class="small">
<li>5-8 new clients/month × 3-5 instances each</li>
<li>Aligns with EU enterprise cloud adoption (45%)</li>
<li>Conservative vs. Servala's current 65+ instances/customer</li>
</ul>
</div>
</div>
<div class="card border-warning mt-2">
<div class="card-body">
<h6 class="card-title text-warning">Aggressive Scenario</h6>
<p><strong>40-150 instances/month</strong></p>
<ul class="small">
<li>12-25 new clients/month × 3-6 instances each</li>
<li>Leverages self-service signup trends</li>
<li>Supported by 92% multi-cloud adoption rate</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<h5><i class="bi bi-graph-up"></i> Return Expectations</h5>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-info">
<tr>
<th>Investment Level</th>
<th>Servala ROI</th>
<th>Market Benchmark</th>
<th>Assessment</th>
</tr>
</thead>
<tbody>
<tr>
<td>500K (3 years)</td>
<td>40-60%</td>
<td>SaaS CAC: $1.18-1.50/ARR</td>
<td><span class="badge bg-success">Realistic</span></td>
</tr>
<tr>
<td>1M (3 years)</td>
<td>60-80%</td>
<td>Managed services: 13-15% CAGR</td>
<td><span class="badge bg-success">Conservative</span></td>
</tr>
<tr>
<td>2M (3 years)</td>
<td>80-100%</td>
<td>High-growth SaaS: 50-100%</td>
<td><span class="badge bg-warning">Ambitious</span></td>
</tr>
</tbody>
</table>
</div>
<div class="alert alert-info mt-2">
<h6><i class="bi bi-info-circle"></i> SaaS Benchmarks</h6>
<p class="mb-0 small">Industry CAC ranges $400-$5,000 per customer (low to high-touch). Servala's self-service model targets the lower end while premium revenue per instance supports healthy unit economics. <a href="https://churnfree.com/blog/average-customer-acquisition-cost-saas/" target="_blank" class="text-primary">Source: ChurnFree</a></p>
</div>
</div>
</div>
<h3>Market Timing & Strategic Positioning</h3>
<div class="row">
<div class="col-md-4">
<div class="card border-primary">
<div class="card-header bg-primary text-white">
<h6 class="mb-0"><i class="bi bi-shield-check"></i> Regulatory Tailwinds</h6>
</div>
<div class="card-body">
<ul class="small mb-0">
<li><strong>GDPR Compliance</strong>: European data residency requirements</li>
<li><strong>NIS2 Directive</strong>: Enhanced cybersecurity mandates</li>
<li><strong>Digital Sovereignty</strong>: Growing political support for European solutions</li>
</ul>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-success">
<div class="card-header bg-success text-white">
<h6 class="mb-0"><i class="bi bi-graph-up"></i> Market Dynamics</h6>
</div>
<div class="card-body">
<ul class="small mb-0">
<li><strong>Self-Service Demand</strong>: Growing preference for automated provisioning</li>
<li><strong>Multi-Cloud Strategy</strong>: 92% of enterprises avoid single vendor</li>
<li><strong>SME Growth</strong>: Fastest segment at 10.6% CAGR</li>
</ul>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-warning">
<div class="card-header bg-warning text-white">
<h6 class="mb-0"><i class="bi bi-target"></i> Competitive Advantage</h6>
</div>
<div class="card-body">
<ul class="small mb-0">
<li><strong>Open Source</strong>: No vendor lock-in vs. proprietary platforms</li>
<li><strong>European Focus</strong>: Built for EU regulatory environment</li>
<li><strong>Proven Traction</strong>: 2,000+ instances, 30+ customers</li>
</ul>
</div>
</div>
</div>
</div>
<div class="alert alert-success mt-4">
<h6><i class="bi bi-check-circle-fill"></i> Market Validation Summary</h6>
<p class="mb-2">Our market analysis confirms that Servala's ROI projections are <strong>conservative and market-realistic</strong>:</p>
<ul class="mb-0">
<li><strong>Growth scenarios</strong> align with European cloud adoption trends and self-service demand</li>
<li><strong>ROI expectations</strong> (40-100%) match industry benchmarks for SaaS and managed services</li>
<li><strong>Market timing</strong> leverages regulatory tailwinds and competitive gaps in the European market</li>
<li><strong>Business model</strong> addresses proven market needs with validated technology (2,000+ instances running)</li>
</ul>
</div>
<div class="text-center mt-4">
<h6>Additional Research Sources</h6>
<div class="row text-small">
<div class="col-md-4">
<p><strong>Market Research:</strong><br>
<a href="https://www.statista.com/topics/8472/cloud-computing-in-europe/" target="_blank" class="text-muted">Statista - Cloud Computing in Europe</a><br>
<a href="https://www.forrester.com/report/the-state-of-cloud-in-europe-2024/RES181812" target="_blank" class="text-muted">Forrester - State of Cloud Europe 2024</a></p>
</div>
<div class="col-md-4">
<p><strong>Industry Analysis:</strong><br>
<a href="https://www.gminsights.com/industry-analysis/managed-services-market" target="_blank" class="text-muted">GM Insights - Managed Services Market</a><br>
<a href="https://straitsresearch.com/report/managed-services-market" target="_blank" class="text-muted">Straits Research - Market Trends</a></p>
</div>
<div class="col-md-4">
<p><strong>Competitive Intelligence:</strong><br>
<a href="https://www.g2.com/articles/cloud-computing-statistics" target="_blank" class="text-muted">G2 - Cloud Computing Statistics</a><br>
<a href="https://spacelift.io/blog/cloud-computing-statistics" target="_blank" class="text-muted">Spacelift - Industry Statistics</a></p>
</div>
</div>
</div>
</div>
<!-- Currency Support Section -->
<div class="help-section" id="currency-support">
<h2><i class="bi bi-cash-stack"></i> Currency Support</h2>
<div class="row">
<div class="col-md-6">
<h5><i class="bi bi-cash"></i> Swiss Franc (CHF) - Default</h5>
<ul>
<li>Swiss locale formatting (de-CH)</li>
<li>Traditional Swiss business format</li>
</ul>
</div>
<div class="col-md-6">
<h5><i class="bi bi-currency-euro"></i> Euro (EUR)</h5>
<ul>
<li>European locale formatting (de-DE)</li>
<li>EU business format compliance</li>
</ul>
</div>
</div>
<div class="alert alert-info mt-3">
<h6><i class="bi bi-info-circle"></i> Important</h6>
<p class="mb-0">Currency selection only changes display format - no conversion is performed. Enter all amounts in your chosen currency.</p>
</div>
</div>
<!-- Technical Details Section (LOWER PRIORITY) -->
<div class="help-section" id="technical-details">
<h2><i class="bi bi-gear"></i> Technical Details</h2>
<div class="row">
<div class="col-md-6">
<h4>Loan Model Details</h4>
<ul>
<li><strong>Payment Calculation:</strong> Standard amortization formula</li>
<li><strong>Interest Rates:</strong> 3-8% annually</li>
<li><strong>Risk Level:</strong> Very low - contractually guaranteed</li>
<li><strong>Break-even:</strong> Typically 12-18 months</li>
</ul>
<h5>Monthly Payment Formula</h5>
<code>Monthly Payment = P × [r(1+r)^n] / [(1+r)^n - 1]</code>
<p class="small text-muted mt-2">Where P = Principal, r = Monthly rate, n = Total payments</p>
</div>
<div class="col-md-6">
<h4>Direct Investment Details</h4>
<ul>
<li><strong>Revenue Streams:</strong> Service fees + Core infrastructure sales</li>
<li><strong>Performance Tracking:</strong> Automatic baseline comparison</li>
<li><strong>Grace Periods:</strong> 100% revenue retention periods</li>
<li><strong>Churn Reduction:</strong> Investment-based customer success</li>
</ul>
<h5>Performance Multiplier</h5>
<p>Automatically calculated: Actual instances ÷ Baseline instances</p>
<p class="text-muted small">1.0x = baseline, 1.5x = 50% above baseline, 2.0x = double baseline</p>
</div>
</div>
</div>
<!-- FAQ Section (LOWEST PRIORITY) -->
<div class="help-section" id="faq">
<h2><i class="bi bi-question-circle"></i> Frequently Asked Questions</h2>
<div class="row">
<div class="col-md-6">
<h5>Basic Questions</h5>
<h6>What does "Net Position" mean?</h6>
<p>Your profit after subtracting your initial investment. Positive = profitable.</p>
<h6>What is Core Service Revenue?</h6>
<p>Additional income from selling compute/storage per instance. <strong>100% retained by CSP</strong> - not shared with Servala.</p>
<h6>What happens during grace periods?</h6>
<p>You keep 100% of service revenue + all core revenue. Larger investments get longer grace periods.</p>
</div>
<div class="col-md-6">
<h5>Advanced Questions</h5>
<h6>How are performance bonuses calculated?</h6>
<p>Bonuses apply when you exceed 110% of baseline growth, providing up to 15% additional revenue share for large investments.</p>
<h6>What is the Performance Multiplier?</h6>
<p>Automatically calculated metric: actual results ÷ baseline expectations. Cannot be manually configured.</p>
<h6>How accurate are projections?</h6>
<p>Based on industry benchmarks and historical data, but actual results may vary with market conditions.</p>
</div>
</div>
</div>
<div class="text-center mt-5 mb-4">
<a href="{% url 'services:csp_roi_calculator' %}" class="btn btn-success btn-lg me-3">
<i class="bi bi-calculator"></i> Start Calculating Your ROI
</a>
<a href="{% url 'services:csp_roi_calculator' %}" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> Back to Calculator
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -2,7 +2,7 @@
{% load static %}
{% load contact_tags %}
{% block title %}About The Sovereign App Store{% endblock %}
{% block title %}About Open Cloud Native Services Hub{% endblock %}
{% block content %}
<section class="section bg-primary-subtle">
@ -10,7 +10,7 @@
<header class="section-primary__header text-center">
<h1 class="section-h1 fs-40 fs-lg-64 mb-24">About Servala</h1>
<div class="text-gray-300 w-lg-37 mx-auto">
<p class="mb-0">The Sovereign App Store. Unlock the Power of Sovereign Managed Applications.</p>
<p class="mb-0">Open Cloud Native Service Hub. Unlock the Power of Cloud Native Applications.</p>
</div>
</header>
</div>
@ -238,7 +238,7 @@
</div>
<div class="w-lg-30">
<div class="page-header__image-wrapper">
<img class="page-header__image" src="{% static "img/sir-vala-text.png" %}" alt="Cartoon serval cat named Sir Vala with blue eyes and a happy expression. It's the mascot of Servala - The Sovereign App Store" title="Sir Vala - Mascot of Servala">
<img class="page-header__image" src="{% static "img/sir-vala-text.png" %}" alt="Cartoon serval cat named Sir Vala with blue eyes and a happy expression. It's the mascot of Servala - Open Cloud Native Service Hub" title="Sir Vala - Mascot of Servala">
</div>
</div>
</div>
@ -251,4 +251,4 @@
</div>
</div>
</section>
{% endblock %}
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}The Sovereign App Store{% endblock %}
{% block title %}Open Cloud Native Services Hub{% endblock %}
{% block content %}
<section class="section section-hero bg-primary-subtle">
@ -9,9 +9,9 @@
<div class="section-hero-mask"></div>
<div class="px-3 px-lg-0 pt-80 pb-120 position-relative">
<header class="section-hero__header">
<h1 class="section-h1 fs-40 fs-lg-64">Sovereign App Store</h1>
<h1 class="section-h1 fs-40 fs-lg-64">Servala - Open Cloud Native Service Hub</h1>
<div class="section-hero__desc">
<p>Unlock the Power of Sovereign Managed Applications.</p>
<p>Unlock the Power of Cloud Native Applications.</p>
<p>Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.</p>
</div>
<div>
@ -41,22 +41,16 @@
<div class="row">
{% for service in featured_services %}
<div class="col-12 col-md-6 col-lg-3 mb-20 mb-lg-0">
<div class="card h-100 d-flex flex-column clickable-card"
<div class="card h-100 d-flex flex-column clickable-card"
onclick="cardClicked(event, '{{ service.get_absolute_url }}')">
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ service.get_absolute_url }}" class="clickable-link">
{% if service.get_logo %}
<img src="{{ service.get_logo.url }}"
alt="{{ service.name }}"
<img src="{{ service.logo.url }}"
alt="{{ service.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
{% else %}
<div class="text-muted" style="height: 100px; width: 250px; display: flex; align-items: center; justify-content: center; border: 1px solid #dee2e6; border-radius: 0.375rem;">
{{ service.name }}
</div>
{% endif %}
</a>
</div>
</div>
@ -104,20 +98,18 @@
<div class="row">
{% for provider in featured_providers %}
<div class="col-12 col-md-6 col-lg-3 mb-20 mb-lg-0">
<div class="card h-100 d-flex flex-column clickable-card"
<div class="card h-100 d-flex flex-column clickable-card"
onclick="cardClicked(event, '{{ provider.get_absolute_url }}')">
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
{% if provider.get_logo %}
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ provider.get_absolute_url }}" class="clickable-link">
<img src="{{ provider.get_logo.url }}"
alt="{{ provider.name }}"
<img src="{{ provider.logo.url }}"
alt="{{ provider.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>
</div>
{% endif %}
</div>
<h3 class="card__title">
<a href="{{ provider.get_absolute_url }}" class="text-decoration-none clickable-link">{{ provider.name }}</a>
@ -147,9 +139,9 @@
<div class="">
<header class="section__header w-100 d-flex justify-content-between align-items-center">
<div class="section__header-text">
<h2 class="section__header-h2">Partners</h2>
<h2 class="section__header-h2">Consulting Partners</h2>
<div class="section__desc">
<p>Explore all available Partners on Servala, with new ones added regularly.</p>
<p>Explore all available Consulting Partners on Servala, with new ones added regularly.</p>
</div>
</div>
<div class="d-none d-lg-block">
@ -160,20 +152,18 @@
<div class="row">
{% for partner in featured_partners %}
<div class="col-12 col-md-6 col-lg-3 mb-20 mb-lg-0">
<div class="card h-100 d-flex flex-column clickable-card"
<div class="card h-100 d-flex flex-column clickable-card"
onclick="cardClicked(event, '{{ partner.get_absolute_url }}')">
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
{% if partner.get_logo %}
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ partner.get_absolute_url }}" class="clickable-link">
<img src="{{ partner.get_logo.url }}"
alt="{{ partner.name }}"
<img src="{{ partner.logo.url }}"
alt="{{ partner.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>
</div>
{% endif %}
</div>
<h3 class="card__title">
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none clickable-link">{{ partner.name }}</a>
@ -208,7 +198,7 @@
</div>
<div class="col-12 col-lg-8">
<header class="section-primary__header">
<h2 class="section-h1 fs-40 fs-lg-60">Servala - The Sovereign App Store</h2>
<h2 class="section-h1 fs-40 fs-lg-60">Servala - Open Cloud Native Service Hub</h2>
<div class="section-primary__desc">
<p>Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.</p>
<p>Discover:</p>
@ -216,7 +206,7 @@
<div>
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:service_list' %}" role="button">Services</a>
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:provider_list' %}" role="button">Cloud Providers</a>
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:partner_list' %}" role="button">Partners</a>
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:partner_list' %}" role="button">Consulting Partners</a>
</div>
</header>
</div>
@ -257,4 +247,4 @@
</div>
</div>
</section>
{% endblock %}
{% endblock %}

View file

@ -6,11 +6,6 @@
{% block meta_description %}{{ article.excerpt }}{% endblock %}
{% block meta_keywords %}{{ article.meta_keywords }}{% endblock %}
{% block extra_head %}
<!-- RSS Feed -->
<link rel="alternate" type="application/rss+xml" title="Servala Articles RSS Feed" href="{% url 'services:article_rss' %}">
{% endblock %}
{% block content %}
<section class="section bg-primary-subtle">
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
@ -21,13 +16,25 @@
<div class="d-flex justify-content-center align-items-center gap-3 text-sm">
<span>By {{ article.author.get_full_name|default:article.author.username }}</span>
<span></span>
<span>{{ article.article_date|date:"M d, Y" }}</span>
<span>{{ article.created_at|date:"M d, Y" }}</span>
{% if article.updated_at != article.created_at %}
{% endif %}
</div>
</div>
</header>
</div>
</section>
{% if article.image %}
<section class="section py-0">
<div class="container-xl mx-auto">
<div class="article-hero-image">
<img src="{{ article.image.url }}" alt="{{ article.title }}" class="img-fluid w-100" style="max-height: 400px; object-fit: cover;">
</div>
</div>
</section>
{% endif %}
<section class="section">
<div class="container-xl mx-auto px-3 px-lg-0 pt-60 pt-lg-80 pb-40">
<div class="row">
@ -46,9 +53,9 @@
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Service</h5>
{% if article.related_service.get_logo %}
{% if article.related_service.logo %}
<div class="mb-3 d-flex" style="height: 60px;">
<img src="{{ article.related_service.get_logo.url }}" alt="{{ article.related_service.name }} logo"
<img src="{{ article.related_service.logo.url }}" alt="{{ article.related_service.name }} logo"
class="img-fluid" style="max-height: 50px; object-fit: contain;">
</div>
{% endif %}
@ -63,16 +70,13 @@
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Partner</h5>
{% if article.related_consulting_partner.get_logo %}
{% if article.related_consulting_partner.logo %}
<div class="mb-3 d-flex" style="height: 60px;">
<img src="{{ article.related_consulting_partner.get_logo.url }}" alt="{{ article.related_consulting_partner.name }} logo"
<img src="{{ article.related_consulting_partner.logo.url }}" alt="{{ article.related_consulting_partner.name }} logo"
class="img-fluid" style="max-height: 50px; object-fit: contain;">
</div>
{% endif %}
<p class="card-text">{{ article.related_consulting_partner.name }}</p>
<div class="mb-2">
<span class="badge bg-primary">{{ article.related_consulting_partner.get_category_display_badge }}</span>
</div>
<a href="{{ article.related_consulting_partner.get_absolute_url }}" class="btn btn-primary btn-sm">View Partner</a>
</div>
</div>
@ -83,9 +87,9 @@
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Provider</h5>
{% if article.related_cloud_provider.get_logo %}
{% if article.related_cloud_provider.logo %}
<div class="mb-3 d-flex" style="height: 60px;">
<img src="{{ article.related_cloud_provider.get_logo.url }}" alt="{{ article.related_cloud_provider.name }} logo"
<img src="{{ article.related_cloud_provider.logo.url }}" alt="{{ article.related_cloud_provider.name }} logo"
class="img-fluid" style="max-height: 50px; object-fit: contain;">
</div>
{% endif %}
@ -105,32 +109,15 @@
<h3>Related Articles</h3>
<div class="row">
{% for related_article in related_articles %}
<div class="col-12 col-md-4 mb-30">
<div class="card h-100 d-flex flex-column clickable-card" onclick="cardClicked(event, '{{ related_article.get_absolute_url }}')">
{% if related_article.get_image %}
<div class="d-flex justify-content-between mb-3">
<div class="card__image flex-shrink-0">
<img src="{{ related_article.get_image.url }}" alt="{{ related_article.title }}" class="img-fluid">
</div>
</div>
<div class="col-12 col-md-4 mb-4">
<div class="card h-100 clickable-card" onclick="cardClicked(event, '{{ related_article.get_absolute_url }}')">
{% if related_article.image %}
<img src="{{ related_article.image.url }}" class="card-img-top mb-2" alt="{{ related_article.title }}" style="height: 200px; object-fit: cover;">
{% endif %}
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
<h3 class="card__title">
{{ related_article.title }}
</h3>
<p class="card__subtitle">
<span class="text-muted">
By {{ related_article.author.get_full_name|default:related_article.author.username }}
</span>
<span class="text-muted ms-2">
{{ related_article.article_date|date:"M d, Y" }}
</span>
</p>
</div>
<div class="card__desc flex-grow-1">
<p class="mb-0">{{ related_article.excerpt|truncatewords:15 }}</p>
</div>
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ related_article.title }}</h5>
<p class="card-text flex-grow-1">{{ related_article.excerpt|truncatewords:15 }}</p>
<small class="text-muted">{{ related_article.created_at|date:"M d, Y" }}</small>
</div>
</div>
</div>

View file

@ -5,29 +5,13 @@
{% block title %}Articles{% endblock %}
{% block meta_description %}Explore all articles on Servala, covering cloud services, consulting partners, and cloud provider insights.{% endblock %}
{% block extra_head %}
<!-- RSS Feed -->
<link rel="alternate" type="application/rss+xml" title="Servala Articles RSS Feed" href="{% url 'services:article_rss' %}">
{% endblock %}
{% block content %}
<section class="section bg-primary-subtle">
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
<header class="section-primary__header text-center">
<h1 class="section-h1 fs-40 fs-lg-64 mb-24">Articles</h1>
<div class="text-gray-300 w-lg-37 mx-auto">
<p class="mb-3">Discover insights, guides, and updates about cloud services, consulting partners, and technology trends.</p>
<!-- RSS Feed Link -->
<div class="mb-0">
<a href="{% url 'services:article_rss' %}" class="btn btn-outline-light btn-sm" title="Subscribe to RSS Feed">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="me-2">
<path d="M4 11a9 9 0 0 1 9 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 4a16 16 0 0 1 16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="5" cy="19" r="1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Subscribe to RSS Feed
</a>
</div>
<p class="mb-0">Discover insights, guides, and updates about cloud services, consulting partners, and technology trends.</p>
</div>
</header>
</div>
@ -161,11 +145,11 @@
<div class="col-12 col-md-6 col-lg-4 mb-30">
<div class="card {% if article.is_featured %}card-featured{% endif %} h-100 d-flex flex-column clickable-card"
onclick="cardClicked(event, '{{ article.get_absolute_url }}')">
{% if article.get_image or article.is_featured %}
{% if article.image or article.is_featured %}
<div class="d-flex justify-content-between mb-3">
{% if article.get_image %}
{% if article.image %}
<div class="card__image flex-shrink-0">
<img src="{{ article.get_image.url }}" alt="{{ article.title }}" class="img-fluid">
<img src="{{ article.image.url }}" alt="{{ article.title }}" class="img-fluid">
</div>
{% endif %}
{% if article.is_featured %}
@ -185,7 +169,7 @@
By {{ article.author.get_full_name|default:article.author.username }}
</span>
<span class="text-muted ms-2">
{{ article.article_date|date:"M d, Y" }}
{{ article.created_at|date:"M d, Y" }}
</span>
</p>
</div>

View file

@ -77,8 +77,8 @@
<div class="w-lg-34 bg-purple-50 rounded-16 p-24 d-flex flex-column">
<div class="d-flex align-items-center mb-24">
<div class="card__image mb-0">
{% if selected_offering.service.get_logo %}
<img class="img-fluid" src="{{ selected_offering.service.get_logo.url }}" alt="Service Logo">
{% if selected_offering.service.logo %}
<img class="img-fluid" src="{{ selected_offering.service.logo.url }}" alt="Service Logo">
{% endif %}
</div>
<div class="card__header ps-16">

View file

@ -1,104 +1,12 @@
{% extends 'base.html' %}
{% load static %}
{% load compress %}
{% load contact_tags %}
{% load json_ld_tags %}
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
{% block extra_js %}
{% if debug %}
<!-- Development: Load individual modules for easier debugging -->
<script src="{% static 'js/price-calculator/dom-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/pricing-data-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/plan-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/addon-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/ui-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/order-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/price-calculator.js' %}"></script>
<script>
// Initialize calculator when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Check if we're on a page that needs the price calculator
if (document.getElementById('cpuRange')) {
try {
window.priceCalculator = new PriceCalculator();
} catch (error) {
console.error('Failed to initialize price calculator:', error);
}
}
});
// Global function for traditional plan selection (used by template buttons)
function selectPlan(element) {
if (!element) return;
const planId = element.getAttribute('data-plan-id');
const planName = element.getAttribute('data-plan-name');
// Find the plan dropdown in the contact form
const planDropdown = document.getElementById('id_choice');
if (planDropdown) {
// Find the option with matching plan id and select it
for (let i = 0; i < planDropdown.options.length; i++) {
const optionValue = planDropdown.options[i].value;
if (optionValue.startsWith(planId + '|')) {
planDropdown.selectedIndex = i;
break;
}
}
}
}
</script>
{% else %}
<!-- Production: Load compressed bundle -->
{% compress js %}
<script src="{% static 'js/price-calculator/dom-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/pricing-data-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/plan-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/addon-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/ui-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/order-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/price-calculator.js' %}"></script>
<script>
// Initialize calculator when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Check if we're on a page that needs the price calculator
if (document.getElementById('cpuRange')) {
try {
window.priceCalculator = new PriceCalculator();
} catch (error) {
console.error('Failed to initialize price calculator:', error);
}
}
});
// Global function for traditional plan selection (used by template buttons)
function selectPlan(element) {
if (!element) return;
const planId = element.getAttribute('data-plan-id');
const planName = element.getAttribute('data-plan-name');
// Find the plan dropdown in the contact form
const planDropdown = document.getElementById('id_choice');
if (planDropdown) {
// Find the option with matching plan id and select it
for (let i = 0; i < planDropdown.options.length; i++) {
const optionValue = planDropdown.options[i].value;
if (optionValue.startsWith(planId + '|')) {
planDropdown.selectedIndex = i;
break;
}
}
}
}
</script>
{% endcompress %}
{% endif %}
<script defer src="{% static "js/price-calculator.js" %}"></script>
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
{% json_ld_structured_data %}
{% endblock %}
{% block content %}
@ -121,9 +29,9 @@ function selectPlan(element) {
<div class="pr-lg-6">
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if offering.service.get_logo %}
{% if offering.service.logo %}
<a href="{{ offering.service.get_absolute_url }}">
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.get_logo.url }}"
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.logo.url }}"
alt="{{ offering.service.name }} logo" style="max-height: 120px; object-fit: contain;">
</a>
{% endif %}
@ -139,7 +47,7 @@ function selectPlan(element) {
<div class="mb-40">
<h3 class="fw-semibold mb-12">Runs on</h3>
<a href="{{ offering.cloud_provider.get_absolute_url }}">
<img class="img-fluid" src="{{ offering.cloud_provider.get_logo.url }}" alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;">
<img class="img-fluid" src="{{ offering.cloud_provider.logo.url }}" alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;">
</a>
</div>
@ -296,317 +204,65 @@ function selectPlan(element) {
<!-- Price Calculator -->
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
{% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %}
<!-- Interactive Price Calculator -->
<h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
<div class="bg-light rounded-4 p-4 mb-4">
<div class="row">
<!-- Calculator Controls -->
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-body">
<!-- CPU Slider -->
<div class="mb-4">
<label for="cpuRange" class="form-label d-flex justify-content-between">
<span>vCPUs</span>
<span class="fw-bold" id="cpuValue">0.5</span>
</label>
<input type="range" class="form-range" id="cpuRange" min="0.25" max="32" value="0.5" step="0.25">
<div class="d-flex justify-content-between text-muted small">
<span id="cpuMinDisplay">0.25</span>
<span id="cpuMaxDisplay">32</span>
</div>
</div>
<!-- Memory Slider -->
<div class="mb-4">
<label for="memoryRange" class="form-label d-flex justify-content-between">
<span>Memory (GB)</span>
<span class="fw-bold" id="memoryValue">1</span>
</label>
<input type="range" class="form-range" id="memoryRange" min="0.25" max="128" value="1" step="0.25">
<div class="d-flex justify-content-between text-muted small">
<span id="memoryMinDisplay">0.25 GB</span>
<span id="memoryMaxDisplay">128 GB</span>
</div>
</div>
<!-- Storage Slider -->
<div class="mb-4">
<label for="storageRange" class="form-label d-flex justify-content-between">
<span>Storage (GB)</span>
<span class="fw-bold" id="storageValue">20</span>
</label>
<input type="range" class="form-range" id="storageRange" min="10" max="1000" value="20" step="10">
<div class="d-flex justify-content-between text-muted small">
<span id="storageMinDisplay">10 GB</span>
<span id="storageMaxDisplay">1000 GB</span>
</div>
</div>
<!-- Replicas Slider -->
<div class="mb-4">
<label for="instancesRange" class="form-label d-flex justify-content-between">
<span>Replicas</span>
<span class="fw-bold" id="instancesValue">1</span>
</label>
<input type="range" class="form-range" id="instancesRange" min="1" max="1" value="1" step="1">
<div class="d-flex justify-content-between text-muted small">
<span id="instancesMinDisplay">1</span>
<span id="instancesMaxDisplay">1</span>
</div>
</div>
<!-- Service Level Selection -->
<div class="mb-4">
<label class="form-label">Service Level</label>
<div class="btn-group w-100" role="group" id="serviceLevelGroup">
<input type="radio" class="btn-check" name="serviceLevel" id="serviceLevelBestEffort" value="Best Effort" checked>
<label class="btn btn-outline-primary" for="serviceLevelBestEffort">Best Effort</label>
<input type="radio" class="btn-check" name="serviceLevel" id="serviceLevelGuaranteed" value="Guaranteed Availability">
<label class="btn btn-outline-primary" for="serviceLevelGuaranteed">Guaranteed Availability</label>
</div>
</div>
<!-- Addons Section - Hidden by default, shown by JS if addons exist -->
<div class="mb-4" id="addonsSection" style="display: none;">
<label class="form-label">Add-ons</label>
<div id="addonsContainer">
<!-- Add-ons will be dynamically populated here -->
</div>
</div>
<!-- Direct Plan Selection -->
<div class="mb-4">
<label for="planSelect" class="form-label">Or choose a specific plan</label>
<select class="form-select" id="planSelect">
<option value="">Auto-select best matching plan</option>
</select>
<p><small class="form-text text-muted">Selecting a plan will override the slider configuration</small></p>
<p><small class="form-text text-muted"><i class="bi bi-info-circle me-1"></i> Interested in a custom plan? Let us know via the <a href="#form">contact form</a>.</small></p>
</div>
</div>
</div>
</div>
<!-- Results Panel -->
<div class="col-12 col-lg-6">
<div class="card h-100 border-primary">
<div class="card-body">
<h5 class="card-title text-primary mb-4">Your Plan</h5>
<!-- Plan Match Status -->
<div id="planMatchStatus" class="alert alert-info mb-3">
<i class="bi bi-info-circle me-2"></i>
<span>Finding best matching plan...</span>
</div>
<!-- Selected Plan Details -->
<div id="selectedPlanDetails" style="display: none;">
<div class="mb-3">
<div class="d-flex align-items-center mb-2">
<span class="badge me-2" id="planGroup"></span>
<strong id="planName"></strong>
</div>
<small class="text-muted" id="planDescription"></small>
</div>
<div class="row mb-3">
<div class="col-3">
<small class="text-muted">vCPUs</small>
<div class="fw-bold" id="planCpus"></div>
</div>
<div class="col-3">
<small class="text-muted">Memory</small>
<div class="fw-bold" id="planMemory"></div>
</div>
<div class="col-3">
<small class="text-muted">Replicas</small>
<div class="fw-bold" id="planInstances"></div>
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<small class="text-muted">Service Level</small>
<div class="fw-bold">
<a href="https://products.vshn.ch/service_levels.html" target="_blank" class="text-decoration-none" id="planServiceLevel"></a>
</div>
</div>
</div>
<!-- Pricing Breakdown -->
<div class="border-top pt-3">
<!-- Managed Service Section -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center flex-shrink-1" style="min-width: 0; max-width: calc(100% - 120px);">
<span class="text-nowrap me-1">Managed Service</span>
<button class="btn btn-link btn-sm p-0 text-muted flex-shrink-0" type="button" data-bs-toggle="collapse" data-bs-target="#managedServiceIncludes" aria-expanded="false" aria-controls="managedServiceIncludes" title="Show what's included" id="managedServiceToggleButton">
<i class="bi bi-info-circle" id="managedServiceToggleIcon"></i>
</button>
</div>
<span class="fw-bold text-nowrap flex-shrink-0" style="min-width: 110px; text-align: right;">CHF <span id="managedServicePrice">0.00</span></span>
</div>
<!-- What's included in managed service (collapsible) -->
<div class="collapse" id="managedServiceIncludes">
<div class="ps-3 border-start border-2 border-subtle">
<div id="managedServiceIncludesContainer">
<!-- Required add-ons will be dynamically added here -->
</div>
</div>
</div>
</div>
<!-- Storage - separate billable item -->
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-nowrap flex-shrink-1" style="min-width: 0;">Storage - <span id="storageAmount">20</span> GB</span>
<span class="fw-bold text-nowrap flex-shrink-0" style="min-width: 110px; text-align: right;">CHF <span id="storagePrice">0.00</span></span>
</div>
<!-- Optional Addons Pricing -->
<div id="addonPricingContainer">
<!-- Optional addon pricing will be dynamically added here -->
</div>
<hr>
<div class="d-flex justify-content-between align-items-center">
<span class="fs-5 fw-bold text-nowrap flex-shrink-1" style="min-width: 0;">Total Monthly Price</span>
<span class="fs-4 fw-bold text-primary text-nowrap flex-shrink-0" style="min-width: 120px; text-align: right;">CHF <span id="totalPrice">0.00</span></span>
</div>
<small class="text-muted mt-2 d-block">
<i class="bi bi-info-circle me-1"></i>
Monthly pricing based on 30 days (720 hours). Metering is conducted per hour. Introductory pricing subject to change.
</small>
</div>
</div>
<!-- No Match Found -->
<div id="noMatchFound" style="display: none;" class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
No matching plan found for your requirements. Please adjust your configuration.
</div>
</div>
</div>
</div>
</div>
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans & Pricing</h3>
<div class="mb-3">
<label for="currencySelect" class="form-label">Select Currency:</label>
<select id="currencySelect" class="form-select w-auto d-inline-block">
<option value="CHF">CHF</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
<!-- Order Button -->
<div class="text-center mt-4">
<a href="#order-form" class="btn btn-primary btn-lg px-5 py-3 fw-semibold">
<i class="bi bi-cart me-2"></i>Order This Configuration
</a>
</div>
<!-- Order Form Section -->
<div id="order-form" class="pt-40" style="scroll-margin-top: 30px;">
<h4 class="fs-22 fw-semibold lh-1 mb-12">Order Your Configuration</h4>
<div class="row">
<div class="col-12">
{% embedded_contact_form source="Configuration Order" service=offering.service offering_id=offering.id %}
</div>
</div>
<div class="table-responsive">
<table class="table table-bordered" id="plansTable">
<thead>
<tr>
<th>Plan Name</th>
<th>Description</th>
<th>Price</th>
</tr>
</thead>
<tbody>
<!-- Plan rows will be populated by JS -->
</tbody>
</table>
</div>
{% elif offering.plans.all %}
<!-- Traditional Plans -->
<h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
<div class="bg-light rounded-4 p-4 mb-4">
<div class="row">
{% for plan in offering.plans.all %}
<div class="col-12 {% if offering.plans.all|length == 1 %}col-lg-8 mx-auto{% elif offering.plans.all|length == 2 %}col-lg-6{% else %}col-lg-4{% endif %} mb-4">
<div class="card h-100 {% if plan.is_best %}border-success border-2 shadow-sm{% else %}border-primary shadow-sm{% endif %} position-relative">
{% if plan.is_best %}
<!-- Best Plan Badge -->
<div class="position-absolute top-0 start-50 translate-middle" style="z-index: 10;">
<span class="badge bg-success px-3 py-2 fs-6 fw-bold shadow-sm text-nowrap">
<i class="bi bi-star-fill me-1"></i>Best Choice
</span>
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans</h3>
<div class="row">
{% for plan in offering.plans.all %}
<div class="col-12 col-lg-6 {% if not forloop.last %}mb-20 mb-lg-0{% endif %}">
<div class="bg-purple-50 rounded-16 border-all p-24">
<div class="bg-white border-all rounded-7 p-20 mb-20">
<h3 class="text-purple fs-22 fw-semibold lh-1-7 mb-0">{{ plan.name }}</h3>
{% if plan.plan_description %}
<div class="text-black mb-20">
{{ plan.plan_description.text|safe }}
</div>
{% endif %}
<div class="card-body pt-3 d-flex flex-column">
<h5 class="card-title {% if plan.is_best %}text-success{% else %}text-primary{% endif %} mb-3 fw-bold">
<i class="bi bi-{% if plan.is_best %}award{% else %}box{% endif %} me-2"></i>{{ plan.name }}
</h5>
<!-- Plan Description -->
{% if plan.plan_description %}
<div class="mb-3">
<small class="text-muted">Description</small>
<div class="text-dark">
{{ plan.plan_description.text|safe }}
</div>
</div>
{% endif %}
{% if plan.description %}
<div class="mb-3">
<small class="text-muted">Details</small>
<div class="text-dark">
{{ plan.description|safe }}
</div>
</div>
{% endif %}
<!-- Pricing Information -->
{% if plan.plan_prices.exists %}
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex flex-column">
<div class="mb-2">
<small class="{% if plan.is_best %}text-success fw-semibold{% else %}text-muted{% endif %}">Pricing</small>
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between mb-2">
<span>Monthly Price</span>
<div class="text-end">
{% for price in plan.plan_prices.all %}
<div class="fs-5 fw-bold {% if plan.is_best %}text-success{% else %}text-primary{% endif %}">{{ price.currency }} {{ price.amount }}</div>
{% endfor %}
</div>
</div>
</div>
<small class="text-muted mt-2 d-block">
<i class="bi bi-info-circle me-1"></i>
Prices exclude VAT. Monthly pricing based on 30 days.
</small>
</div>
{% else %}
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex align-items-center justify-content-center">
<div class="text-center text-muted">
<i class="bi bi-envelope me-2"></i>Contact us for pricing details
</div>
</div>
{% endif %}
<!-- Plan Action Button -->
<div class="text-center mt-auto pt-3">
<a href="#plan-order-form" class="btn {% if plan.is_best %}btn-success btn-lg px-4 py-2 shadow{% else %}btn-primary btn-lg px-4 py-2{% endif %} fw-semibold w-100" data-plan-id="{{ plan.id }}" data-plan-name="{{ plan.name }}" onclick="selectPlan(this)">
<i class="bi bi-{% if plan.is_best %}star-fill{% else %}cart{% endif %} me-2"></i>Select Plan
</a>
</div>
{% if plan.description %}
<div class="text-black mb-20">
{{ plan.description|safe }}
</div>
{% endif %}
{% if plan.pricing %}
<div class="text-black mb-20">
{{ plan.pricing|safe }}
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
<div class="alert alert-info">
<p>No plans available yet.</p>
<h4 class="mb-3">I'm interested in this offering</h4>
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Plan Order Forms -->
<div id="plan-order-form" class="pt-40" style="scroll-margin-top: 30px;">
<h4 class="fs-22 fw-semibold lh-1 mb-12">Order Your Plan</h4>
<div class="row">
<div class="col-12">
{% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
{% empty %}
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
<div class="alert alert-info">
<p>No plans available yet.</p>
<h4 class="mb-3">I'm interested in this offering</h4>
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- No Plans Available -->
@ -616,6 +272,17 @@ function selectPlan(element) {
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
</div>
{% endif %}
{% if offering.plans.exists and not pricing_data_by_group_and_service_level %}
<div id="form" class="pt-40">
<h4 class="fs-22 fw-semibold lh-1 mb-12">I'm interested in a plan</h4>
<div class="row">
<div class="col-12">
{% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>

View file

@ -150,8 +150,8 @@
<div class="card__header">
<div class="d-flex align-items-start mb-3">
<div class="me-3">
{% if offering.service.get_logo %}
<img src="{{ offering.service.get_logo.url }}"
{% if offering.service.logo %}
<img src="{{ offering.service.logo.url }}"
alt="{{ offering.service.name }}"
style="max-height: 50px; max-width: 100px; object-fit: contain;">
{% endif %}
@ -163,9 +163,9 @@
</a>
</h3>
<div class="d-flex align-items-center">
{% if offering.cloud_provider.get_logo %}
{% if offering.cloud_provider.logo %}
<a href="{{ offering.get_absolute_url }}" class="me-2">
<img src="{{ offering.cloud_provider.get_logo.url }}"
<img src="{{ offering.cloud_provider.logo.url }}"
alt="{{ offering.cloud_provider.name }}"
style="max-height: 30px; max-width: 100px; object-fit: contain;">
</a>

View file

@ -23,8 +23,8 @@
<div class="pr-lg-6">
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if partner.get_logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ partner.get_logo.url }}" alt="{{ partner.name }} logo" style="max-height: 120px; object-fit: contain;">
{% if partner.logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ partner.logo.url }}" alt="{{ partner.name }} logo" style="max-height: 120px; object-fit: contain;">
{% endif %}
</div>
@ -86,7 +86,7 @@
{% if partner.address %}
<li>
<div class="d-flex align-items-start text-gray-500 lh-32">
<div class="d-flex align-items-start text-gray-500 h-32 lh-32">
<span class="pr-10 pt-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-geo-alt-fill" viewBox="0 0 16 16">
<path d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10m0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6" fill="#9A63EC"/>
@ -99,6 +99,27 @@
</ul>
</div>
<!-- Cloud Providers -->
{% if partner.cloud_providers.exists %}
<div class="mb-40">
<h3 class="fw-semibold mb-12">Cloud Providers</h3>
<ul class="list-unstyled space-y-12 fs-19 ps-0">
{% for provider in partner.cloud_providers.all %}
<li>
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ provider.get_absolute_url }}">
<span class="pr-10">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cloud-fill" viewBox="0 0 16 16">
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383" fill="#9A63EC"/>
</svg>
</span>
<span>{{ provider.name }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Related Articles -->
{% if related_articles %}
<div class="mb-40">
@ -132,7 +153,7 @@
<h2 class="fs-50 fw-semibold lh-1 mb-12">{{ partner.name }}</h2>
</header>
<div class="fs-19 text-gray-500">
<button class="btn btn-tertiary btn-sm mr-12">{{ partner.get_category_display_badge }}</button>
<button class="btn btn-tertiary btn-sm mr-12">Servala Consulting Partner</button>
</div>
</div>
@ -147,28 +168,25 @@
<!-- Services -->
{% if services %}
<div class="pt-40">
<h3 class="fs-24 fw-semibold lh-1 mb-12" id="services" style="scroll-margin-top: 100px;">
{% if partner.category == 'TRAINING' %}
Training for Services
{% else %}
Consulting for Services
{% endif %}
</h3>
<h3 class="fs-24 fw-semibold lh-1 mb-12" id="services" style="scroll-margin-top: 100px;">Consulting for Services</h3>
<div class="row">
{% for service in services %}
<div class="col-12 col-md-6 mb-30">
<div class="card h-100 d-flex flex-column clickable-card"
onclick="cardClicked(event, '{{ service.get_absolute_url }}')">
{% if service.get_logo %}
<div class="d-flex justify-content-between mb-3">
<div class="card h-100 d-flex flex-column">
{% if service.logo %}
<div class="d-flex justify-content-between">
{% if service.logo %}
<div class="card__image flex-shrink-0">
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
<a href="{{ service.get_absolute_url }}">
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
</a>
</div>
{% endif %}
</div>
{% endif %}
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
<h3 class="card__title">{{ service.name }}</h3>
<h3 class="card__title"><a href="{{ service.get_absolute_url }}" class="text-decoration-none">{{ service.name }}</a></h3>
<p class="card__subtitle">
{% for category in service.categories.all %}
<span>{{ category.full_path }}</span>

View file

@ -94,23 +94,6 @@
</div>
</div>
<!-- Category Filter -->
<div class="pt-24 mb-24">
<div class="d-flex justify-content-between align-items-center h-33 mb-5px" role="button">
<h3 class="sidebar-title mb-0">Category</h3>
</div>
<div>
<select class="form-select" id="category" name="category" @change="submitForm()">
<option value="">All Categories</option>
{% for category_value, category_label in partner_categories %}
<option value="{{ category_value }}" {% if request.GET.category == category_value %}selected{% endif %}>
{{ category_label }}
</option>
{% endfor %}
</select>
</div>
</div>
<!-- Filter Actions -->
<div class="d-flex gap-2">
<a href="{% url 'services:partner_list' %}" class="btn btn-outline-secondary btn-sm">Clear</a>
@ -127,21 +110,17 @@
<div class="col-12 col-md-6 col-lg-4 mb-30">
<div class="card h-100 d-flex flex-column clickable-card"
onclick="cardClicked(event, '{{ partner.get_absolute_url }}')">
{% if partner.category %}
<div class="d-flex justify-content-end mb-2">
<span class="btn btn-secondary btn-sm">{{ partner.get_category_display_badge }}</span>
</div>
{% endif %}
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
{% if partner.get_logo %}
<div class="d-flex align-items-center justify-content-start" style="height: 80px; margin-bottom: 1rem; width: 100%;">
<a href="{{ partner.get_absolute_url }}" class="clickable-link" style="display: block; width: 250px; height: 100px;">
<img src="{{ partner.get_logo.url }}" alt="{{ partner.name }} logo"
style="width: 100%; height: 100%; object-fit: contain; object-position: left center; display: block;">
</a>
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
<div class="me-3">
<a href="{{ partner.get_absolute_url }}" class="clickable-link">
<img src="{{ partner.logo.url }}"
alt="{{ partner.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>
</div>
</div>
{% endif %}
<h3 class="card__title">
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none clickable-link">{{ partner.name }}</a>
</h3>
@ -156,9 +135,7 @@
{% if partner.website %}
<a href="{{ partner.website }}" class="btn btn-primary btn-sm clickable-button" target="_blank">Visit Website</a>
{% endif %}
<a href="{{ partner.get_absolute_url }}#services" class="btn btn-primary btn-sm clickable-button">
{% if partner.category == 'TRAINING' %}Available Trainings{% else %}Available Services{% endif %}
</a>
<a href="{{ partner.get_absolute_url }}#services" class="btn btn-primary btn-sm clickable-button">Available Services</a>
</div>
</div>
</div>

View file

@ -5,7 +5,7 @@
{% block title %}Complete Price List{% endblock %}
{% block extra_js %}
<script src="{% static "js/chart.umd.min.js" %}"></script>
<script src="{% static "js/chart.js" %}"></script>
{% endblock %}
{% block extra_css %}
@ -159,11 +159,65 @@
<div class="col-12">
<h1 class="mb-4">Complete Price List - All Service Variants</h1>
<!-- Pricing Model Explanation - Internal Product Manager View -->
<!-- Pricing Model Explanation -->
<div class="card mb-4">
<a href="https://vshnwiki.atlassian.net/wiki/x/BQDYGg" target="_blank">See VSHN Wiki for a detailed explanation</a>
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#pricingExplanation" aria-expanded="false" aria-controls="pricingExplanation" style="cursor: pointer;">
<h5 class="mb-0">
<i class="bi bi-info-circle me-2"></i>How Our Pricing Works
<small class="text-muted ms-2">(Click to expand)</small>
</h5>
</div>
<div class="collapse" id="pricingExplanation">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Price Components</h6>
<ul class="list-unstyled">
<li class="mb-2">
<span class="badge">Compute Plan Price</span>
<span class="ms-2">Base infrastructure cost for CPU, memory, and storage</span>
</li>
<li class="mb-2">
<span class="badge">SLA Base</span>
<span class="ms-2">Fixed cost for the service level agreement</span>
</li>
<li class="mb-2">
<span class="badge">Units × SLA Per Unit</span>
<span class="ms-2">Variable cost based on scale/usage</span>
</li>
<li class="mb-2">
<span class="badge">Mandatory Add-ons</span>
<span class="ms-2">Required additional services (backup, monitoring, etc.)</span>
</li>
</ul>
</div>
<div class="col-md-6">
<h6>Final Price Formula</h6>
<div class="bg-light p-3 rounded">
<code>
<span style="color: #0d6efd;">Compute Plan Price</span> +
<span style="color: #6f42c1;">SLA Base</span> +
<span style="color: #fd7e14;">(Units × SLA Per Unit)</span> +
<span style="color: #dc3545;">Mandatory Add-ons</span> =
<strong style="color: #198754;">Final Price</strong>
</code>
</div>
<p class="mt-3 mb-0">
<small class="text-muted">
This transparent pricing model ensures you understand exactly what you're paying for.
The table below breaks down each component for every service variant we offer.
<br><br>
<strong>Price Comparisons:</strong> When enabled, you'll see:
<br><span class="badge bg-secondary">External Providers</span> - Competitor prices from AWS, Google Cloud, etc.
<br><span class="badge bg-success">Other Servala Providers</span> - Same service specs on different cloud providers within our network
</small>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Filter Form -->
<div class="card mb-4">
<div class="card-header">
@ -172,18 +226,16 @@
<div class="card-body">
<form method="get" class="row g-3" id="filter-form">
<div class="col-md-3">
<label for="cloud_provider" class="form-label">Cloud Provider <span class="text-danger">*</span></label>
<label for="cloud_provider" class="form-label">Cloud Provider</label>
<select name="cloud_provider" id="cloud_provider" class="form-select filter-select">
<option value="">-- Select Cloud Provider --</option>
{% for provider in all_cloud_providers %}
<option value="{{ provider }}" {% if provider == filter_cloud_provider %}selected{% endif %}>{{ provider }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="service" class="form-label">Service <span class="text-danger">*</span></label>
<label for="service" class="form-label">Service</label>
<select name="service" id="service" class="form-select filter-select">
<option value="">-- Select Service --</option>
{% for service in all_services %}
<option value="{{ service }}" {% if service == filter_service %}selected{% endif %}>{{ service }}</option>
{% endfor %}
@ -243,17 +295,16 @@
{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level or show_discount_details or show_addon_details or show_price_comparison %}
<div class="alert alert-info">
<strong>Active Filters:</strong>
<ul class="mt-2 mb-4">
{% if filter_cloud_provider %}<li>Cloud Provider: {{ filter_cloud_provider }}</li>{% endif %}
{% if filter_service %}<li>Service: {{ filter_service }}</li>{% endif %}
{% if filter_compute_plan_group %}<li>Group: {{ filter_compute_plan_group }}</li>{% endif %}
{% if filter_service_level %}<li>Service Level: {{ filter_service_level }}</li>{% endif %}
{% if show_discount_details %}<li>Discount Details</li>{% endif %}
{% if show_addon_details %}<li>Addon Details</li>{% endif %}
{% if show_price_comparison %}<li>Price Comparisons</li>{% endif %}
</ul>
{% if filter_cloud_provider %}<span class="badge me-1">Cloud Provider: {{ filter_cloud_provider }}</span>{% endif %}
{% if filter_service %}<span class="badge me-1">Service: {{ filter_service }}</span>{% endif %}
{% if filter_compute_plan_group %}<span class="badge me-1">Group: {{ filter_compute_plan_group }}</span>{% endif %}
{% if filter_service_level %}<span class="badge me-1">Service Level: {{ filter_service_level }}</span>{% endif %}
{% if show_discount_details %}<span class="badge me-1">Discount Details</span>{% endif %}
{% if show_addon_details %}<span class="badge me-1">Addon Details</span>{% endif %}
{% if show_price_comparison %}<span class="badge me-1">Price Comparisons</span>{% endif %}
</div>
{% endif %}
{% if pricing_data_by_group_and_service_level %}
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
<div class="mb-5 border rounded p-3">
@ -262,11 +313,18 @@
{# Display group description and node_label from first available plan #}
{% for service_level, pricing_data in service_levels.items %}
{% if pricing_data and forloop.first %}
{% with pricing_data.0 as representative_plan %}
{% with pricing_data.0 as representative_plan %}
{% if representative_plan.compute_plan_group_description %}
<p class="text-muted mb-2"><strong>Description:</strong> {{ representative_plan.compute_plan_group_description }}</p>
{% endif %}
{% if representative_plan.compute_plan_group_node_label %}
<p class="text-muted mb-3"><strong>Node Label:</strong> <code>{{ representative_plan.compute_plan_group_node_label }}</code></p>
{% endif %}
{# Display storage pricing for this cloud provider #}
{% if representative_plan.storage_plans %}
<div class="mb-3">
<p class="text-muted mb-2"><strong>Storage Options</strong></p>
<p class="text-muted mb-2"><strong>Storage Options:</strong></p>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-secondary">
@ -307,19 +365,30 @@
{% if pricing_data %}
{# Display common values for this service level #}
{% with pricing_data.0 as first_row %}
<div class="mb-3">
<ul class="list-unstyled">
<li><strong>Cloud Provider:</strong> {{ first_row.cloud_provider }}</li>
<li><strong>Service:</strong> {{ first_row.service }}</li>
<li><strong>CPU/Memory Ratio:</strong> {{ first_row.cpu_mem_ratio }}</li>
<li><strong>Variable Unit:</strong> {{ first_row.variable_unit }}</li>
<li><strong>Replica Enforce:</strong> {{ first_row.replica_enforce }}</li>
</ul>
<div class="row mb-3">
<div class="col-md-2">
<strong>Cloud Provider:</strong> {{ first_row.cloud_provider }}
</div>
<div class="col-md-2">
<strong>Service:</strong> {{ first_row.service }}
</div>
<div class="col-md-2">
<strong>CPU/Memory Ratio:</strong> {{ first_row.cpu_mem_ratio }}
</div>
<div class="col-md-2">
<strong>Variable Unit:</strong> {{ first_row.variable_unit }}
</div>
<div class="col-md-2">
<strong>Replica Enforce:</strong> {{ first_row.replica_enforce }}
</div>
</div>
{# Display add-on summary #}
{% if show_addon_details and first_row.mandatory_addons or first_row.optional_addons %}
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Available Add-ons for {{ first_row.service }}</h6>
</div>
<div class="card-body">
{% if first_row.mandatory_addons %}
<div class="mb-3">
@ -613,9 +682,9 @@
<td class="fw-bold">
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
{% if comparison.difference > 0 %}
(+{{ comparison.difference|floatformat:2 }}, +{% widthratio comparison.difference row.final_price 100 %}%)
<span class="badge bg-success ms-1">+{{ comparison.difference|floatformat:2 }}</span>
{% elif comparison.difference < 0 %}
({{ comparison.difference|floatformat:2 }}, {% widthratio comparison.difference row.final_price 100 %}%)
<span class="badge bg-danger ms-1">{{ comparison.difference|floatformat:2 }}</span>
{% endif %}
</td>
</tr>
@ -661,11 +730,11 @@
<td class="fw-bold">
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
{% if comparison.difference > 0 %}
(+{{ comparison.difference|floatformat:2 }}, +{% widthratio comparison.difference row.final_price 100 %}%)
<span class="badge bg-danger ms-1">+{{ comparison.difference|floatformat:2 }}</span>
{% elif comparison.difference < 0 %}
({{ comparison.difference|floatformat:2 }}, {% widthratio comparison.difference row.final_price 100 %}%)
<span class="badge bg-success ms-1">{{ comparison.difference|floatformat:2 }}</span>
{% elif comparison.difference == 0 %}
(Same)
<span class="badge bg-info ms-1">Same</span>
{% endif %}
</td>
</tr>
@ -703,7 +772,7 @@
{% else %}
<div class="alert alert-info">
<h4>No pricing data available</h4>
<p>{% if not filter_cloud_provider and not filter_service %}Please select both a <strong>Cloud Provider</strong> and <strong>Service</strong> from the filters above to view pricing data.{% elif not filter_cloud_provider %}Please select a <strong>Cloud Provider</strong> from the filters above.{% elif not filter_service %}Please select a <strong>Service</strong> from the filters above.{% elif filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}No data matches the selected filters. Try adjusting your filter criteria.{% else %}Please ensure you have active compute plans with prices and VSHNAppCat price configurations.{% endif %}</p>
<p>{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}No data matches the selected filters. Try adjusting your filter criteria.{% else %}Please ensure you have active compute plans with prices and VSHNAppCat price configurations.{% endif %}</p>
</div>
{% endif %}
</div>

View file

@ -23,8 +23,8 @@
<div class="pr-lg-6">
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if provider.get_logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ provider.get_logo.url }}" alt="{{ provider.name }} logo" style="max-height: 120px; object-fit: contain;">
{% if provider.logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ provider.logo.url }}" alt="{{ provider.name }} logo" style="max-height: 120px; object-fit: contain;">
{% endif %}
</div>
@ -173,12 +173,12 @@
{% for offering in ordered_offerings %}
<div class="col-12 col-md-6 mb-30">
<div class="card h-100 d-flex flex-column">
{% if offering.service.get_logo or offering.service.is_featured %}
{% if offering.service.logo or offering.service.is_featured %}
<div class="d-flex justify-content-between">
{% if offering.service.get_logo %}
{% if offering.service.logo %}
<div class="card__image flex-shrink-0">
<a href="{{ offering.get_absolute_url }}">
<img src="{{ offering.service.get_logo.url }}" alt="{{ offering.service.name }} logo" class="img-fluid">
<img src="{{ offering.service.logo.url }}" alt="{{ offering.service.name }} logo" class="img-fluid">
</a>
</div>
{% endif %}

View file

@ -98,8 +98,8 @@
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ provider.get_absolute_url }}" class="clickable-link">
{% if provider.get_logo %}
<img src="{{ provider.get_logo.url }}"
{% if provider.logo %}
<img src="{{ provider.logo.url }}"
alt="{{ provider.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>

View file

@ -22,8 +22,8 @@
<div class="pr-lg-6">
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if service.get_logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" style="max-height: 120px; object-fit: contain;">
{% if service.logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ service.logo.url }}" alt="{{ service.name }} logo" style="max-height: 120px; object-fit: contain;">
{% endif %}
</div>
@ -51,59 +51,26 @@
{% endif %}
<!-- Consulting Partners -->
{% with consulting_partners=service.consulting_partners.all|dictsort:"order" %}
{% regroup consulting_partners by category as partners_by_category %}
{% for category_group in partners_by_category %}
{% if category_group.grouper == "CONSULTING" and category_group.list %}
<div class="mb-40">
<h3 class="fw-semibold mb-12">Consulting Partners</h3>
<p>If you want to get the most out of your {{ service.name }} service, our consulting partners can help you optimize your setup and application:</p>
<ul class="list-unstyled space-y-12 fs-19 ps-0">
{% for partner in category_group.list %}
<li>
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ partner.get_absolute_url }}">
<span class="pr-10">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people-fill" viewBox="0 0 16 16">
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5" fill="#9A63EC"/>
</svg>
</span>
<span>{{ partner.name }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endfor %}
{% endwith %}
<!-- Training Partners -->
{% with training_partners=service.consulting_partners.all|dictsort:"order" %}
{% regroup training_partners by category as partners_by_category %}
{% for category_group in partners_by_category %}
{% if category_group.grouper == "TRAINING" and category_group.list %}
<div class="mb-40">
<h3 class="fw-semibold mb-12">Training Partners</h3>
<p>Looking to upskill your team on {{ service.name }}? Our training partners offer comprehensive courses and workshops:</p>
<ul class="list-unstyled space-y-12 fs-19 ps-0">
{% for partner in category_group.list %}
<li>
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ partner.get_absolute_url }}">
<span class="pr-10">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-mortarboard-fill" viewBox="0 0 16 16">
<path d="M8.211 2.047a.5.5 0 0 0-.422 0l-7.5 3.5a.5.5 0 0 0 .025.917l7.5 3a.5.5 0 0 0 .372 0L14 7.14V13a1 1 0 0 0-1 1v2h3v-2a1 1 0 0 0-1-1V6.739l.686-.275a.5.5 0 0 0 .025-.917l-7.5-3.5Z" fill="#9A63EC"/>
<path d="M4.176 9.032a.5.5 0 0 0-.656.327l-.5 1.7a.5.5 0 0 0 .294.605l4.5 1.8a.5.5 0 0 0 .372 0l4.5-1.8a.5.5 0 0 0 .294-.605l-.5-1.7a.5.5 0 0 0-.656-.327L8 10.466 4.176 9.032Z" fill="#9A63EC"/>
</svg>
</span>
<span>{{ partner.name }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endfor %}
{% endwith %}
{% if service.consulting_partners.exists %}
<div class="mb-40">
<h3 class="fw-semibold mb-12">Consulting Partners</h3>
<p>If you want to get the most out of your {{ service.name }}, our consulting partners can help you optimize your setup and application:</p>
<ul class="list-unstyled space-y-12 fs-19 ps-0">
{% for partner in service.consulting_partners.all %}
<li>
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ partner.get_absolute_url }}">
<span class="pr-10">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people-fill" viewBox="0 0 16 16">
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5" fill="#9A63EC"/>
</svg>
</span>
<span>{{ partner.name }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- External Links -->
{% if service.external_links.exists %}
@ -215,9 +182,9 @@
class="text-decoration-none" style="display: block;">
<div class="card h-100 clickable-card">
<div class="card-body text-center">
{% if offering.cloud_provider.get_logo %}
{% if offering.cloud_provider.logo %}
<div class="mb-3 d-flex align-items-center justify-content-center" style="height: 80px;">
<img src="{{ offering.cloud_provider.get_logo.url }}" alt="{{ offering.cloud_provider.name }} logo"
<img src="{{ offering.cloud_provider.logo.url }}" alt="{{ offering.cloud_provider.name }} logo"
class="img-fluid" style="max-height: 60px; object-fit: contain;">
</div>
{% else %}

View file

@ -152,11 +152,11 @@
<div class="col-12 col-md-6 col-lg-4 mb-30">
<div class="card {% if service.is_featured %}card-featured{% endif %} h-100 d-flex flex-column clickable-card"
onclick="cardClicked(event, '{% if request.GET.cloud_provider %}{% for offering in service.offerings.all %}{% if offering.cloud_provider.id|stringformat:"i" == request.GET.cloud_provider %}{% url "services:offering_detail" offering.cloud_provider.slug service.slug %}{% endif %}{% endfor %}{% else %}{{ service.get_absolute_url }}{% endif %}')">
{% if service.get_logo or service.is_featured or service.is_coming_soon %}
{% if service.logo or service.is_featured or service.is_coming_soon %}
<div class="d-flex justify-content-between mb-3">
{% if service.get_logo %}
{% if service.logo %}
<div class="card__image flex-shrink-0">
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
</div>
{% endif %}
{% if service.is_featured %}

View file

@ -1,141 +0,0 @@
from django import template
from django.utils.safestring import mark_safe
from django.utils.html import format_html
from ..models.images import ImageLibrary
register = template.Library()
@register.simple_tag
def image_library_img(slug_or_id, css_class="", alt_text="", width=None, height=None):
"""
Render an image from the image library by slug or ID.
Automatically handles SVG files with proper rendering.
Usage:
{% image_library_img "my-image-slug" css_class="img-fluid" %}
{% image_library_img image_id css_class="logo" width="100" height="100" %}
"""
try:
# Try to get by slug first, then by ID
if isinstance(slug_or_id, str):
image = ImageLibrary.objects.get(slug=slug_or_id)
else:
image = ImageLibrary.objects.get(pk=slug_or_id)
# Use provided alt_text or fall back to image's alt_text
final_alt_text = alt_text or image.alt_text
# Check if it's an SVG file
if image.is_svg():
# For SVG files, use object tag for better rendering
attrs = {
"data": image.image.url,
"type": "image/svg+xml",
"alt": final_alt_text,
}
if css_class:
attrs["class"] = css_class
if width:
attrs["width"] = width
if height:
attrs["height"] = height
# Build the object tag with img fallback
attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return format_html(
'<object {}><img src="{}" alt="{}" class="{}"/></object>',
attr_string,
image.image.url,
final_alt_text,
css_class or "",
)
else:
# For raster images, use img tag
attrs = {
"src": image.image.url,
"alt": final_alt_text,
}
if css_class:
attrs["class"] = css_class
if width:
attrs["width"] = width
if height:
attrs["height"] = height
# Build the HTML
attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return format_html("<img {}/>", attr_string)
except ImageLibrary.DoesNotExist:
# Return empty string or placeholder if image not found
return format_html(
'<img src="/static/images/placeholder.png" alt="Image not found" class="{}"/>',
css_class,
)
@register.simple_tag
def image_library_url(slug_or_id):
"""
Get the URL of an image from the image library.
Usage:
{% image_library_url "my-image-slug" %}
{% image_library_url image_id %}
"""
try:
if isinstance(slug_or_id, str):
image = ImageLibrary.objects.get(slug=slug_or_id)
else:
image = ImageLibrary.objects.get(pk=slug_or_id)
return image.image.url
except ImageLibrary.DoesNotExist:
return "/static/images/placeholder.png"
@register.simple_tag
def image_library_info(slug_or_id):
"""
Get information about an image from the image library.
Usage:
{% image_library_info "my-image-slug" as img_info %}
{{ img_info.name }} - {{ img_info.width }}x{{ img_info.height }}
"""
try:
if isinstance(slug_or_id, str):
image = ImageLibrary.objects.get(slug=slug_or_id)
else:
image = ImageLibrary.objects.get(pk=slug_or_id)
return {
"name": image.name,
"alt_text": image.alt_text,
"width": image.width,
"height": image.height,
"file_size": image.get_file_size_display(),
"category": image.get_category_display(),
"tags": image.get_tags_list(),
"url": image.image.url,
}
except ImageLibrary.DoesNotExist:
return {
"name": "Image not found",
"alt_text": "Image not found",
"width": None,
"height": None,
"file_size": "Unknown",
"category": "Unknown",
"tags": [],
"url": "/static/images/placeholder.png",
}

View file

@ -1,9 +1,7 @@
from datetime import datetime, time
# hub/services/templatetags/json_ld_tags.py
from django import template
from django.urls import resolve, Resolver404
from django.utils.safestring import mark_safe
from django.utils import timezone as django_timezone
import json
register = template.Library()
@ -53,7 +51,7 @@ def json_ld_structured_data(context):
data = {
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Servala - The Sovereign App Store",
"name": "Servala - Open Cloud Native Service Hub",
"url": base_url,
}
json_ld = json.dumps(data, indent=2)
@ -65,7 +63,7 @@ def json_ld_structured_data(context):
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Servala - The Sovereign App Store",
"name": "Servala - Open Cloud Native Service Hub",
"url": base_url,
"description": "Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
"potentialAction": {
@ -108,38 +106,29 @@ def json_ld_structured_data(context):
}
elif view_name == "service_detail" and "service" in context:
data = organization_data
service = context["service"]
service_url = request.build_absolute_uri()
# service = context["service"]
# service_url = request.build_absolute_uri()
# # Check if service has offerings with pricing
# has_offerings = hasattr(service, "offerings") and service.offerings.exists()
data = {
"@context": "https://schema.org",
"@type": "Product",
"name": service.name,
"description": service.description,
"url": service_url,
"category": "Cloud Service",
}
# if has_offerings:
# # Use Product type when we have offerings (which provide the required offers data)
# data = {
# "@context": "https://schema.org",
# "@type": "Product",
# "name": service.name,
# "description": service.description,
# "url": service_url,
# "category": "Cloud Service",
# }
# Add image if available
if hasattr(service, "logo") and service.logo:
data["image"] = request.build_absolute_uri(service.logo.url)
# # Add image if available
# if hasattr(service, "get_logo") and service.get_logo:
# data["image"] = request.build_absolute_uri(service.get_logo.url)
# # Add offerings
# data["offers"] = {
# "@type": "AggregateOffer",
# "availability": "https://schema.org/InStock",
# "offerCount": service.offerings.count(),
# }
# else:
# # Use Organization data when no offerings are available
# # This avoids Google Search Console errors for Product without required fields
# data = organization_data
# Add offerings if available
if hasattr(service, "offerings") and service.offerings.exists():
data["offers"] = {
"@type": "AggregateOffer",
"availability": "https://schema.org/InStock",
"offerCount": service.offerings.count(),
}
elif view_name == "provider_detail" and "provider" in context:
provider = context["provider"]
@ -154,8 +143,8 @@ def json_ld_structured_data(context):
}
# Add image if available
if hasattr(provider, "get_logo") and provider.get_logo:
data["logo"] = request.build_absolute_uri(provider.get_logo.url)
if hasattr(provider, "logo") and provider.logo:
data["logo"] = request.build_absolute_uri(provider.logo.url)
# Add contact information if available
contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"}
@ -190,8 +179,8 @@ def json_ld_structured_data(context):
}
# Add image if available
if hasattr(partner, "get_logo") and partner.get_logo:
data["logo"] = request.build_absolute_uri(partner.get_logo.url)
if hasattr(partner, "logo") and partner.logo:
data["logo"] = request.build_absolute_uri(partner.logo.url)
# Add contact information if available
contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"}
@ -217,151 +206,35 @@ def json_ld_structured_data(context):
offering = context["offering"]
offering_url = request.build_absolute_uri()
# Check if we have pricing data available
has_pricing_data = False
data = {
"@context": "https://schema.org",
"@type": "Product",
"name": f"{offering.service.name} on {offering.cloud_provider.name}",
"description": offering.description or offering.service.description,
"url": offering_url,
"category": "Cloud Service",
}
# Add brand (service)
data["brand"] = {"@type": "Brand", "name": offering.service.name}
# Add image if available
if hasattr(offering.service, "logo") and offering.service.logo:
data["image"] = request.build_absolute_uri(offering.service.logo.url)
# Add offers if available
if hasattr(offering, "plans") and offering.plans.exists():
# Get all plans with pricing
plans_with_prices = offering.plans.filter(
plan_prices__isnull=False
).distinct()
has_pricing_data = plans_with_prices.exists()
if has_pricing_data:
# Use Product type with complete pricing information
data = {
"@context": "https://schema.org",
"@type": "Product",
"name": f"Managed {offering.service.name} on {offering.cloud_provider.name}",
"description": offering.description or offering.service.description,
"url": offering_url,
"category": "Cloud Service",
}
# Add brand (service)
data["brand"] = {"@type": "Brand", "name": offering.service.name}
# Add image if available
if hasattr(offering.service, "get_logo") and offering.service.get_logo:
data["image"] = request.build_absolute_uri(
offering.service.get_logo.url
)
# Create individual offers for each plan with pricing
offers = []
all_prices = []
for plan in plans_with_prices:
plan_prices = plan.plan_prices.all()
if plan_prices.exists():
first_price = plan_prices.first()
all_prices.extend([p.amount for p in plan_prices])
offer = {
"@type": "Offer",
"name": plan.name,
"price": str(first_price.amount),
"priceCurrency": first_price.currency,
"availability": "https://schema.org/InStock",
"url": offering_url + "#plan-order-form",
"seller": {"@type": "Organization", "name": "VSHN"},
}
offers.append(offer)
# Add aggregate offer with all required pricing fields
if all_prices and offers:
# Use the currency from the first plan's first price
first_plan_with_prices = plans_with_prices.first()
first_currency = first_plan_with_prices.plan_prices.first().currency
data["offers"] = {
"@type": "AggregateOffer",
"availability": "https://schema.org/InStock",
"offerCount": len(offers),
"offers": offers,
"lowPrice": str(min(all_prices)),
"highPrice": str(max(all_prices)),
"priceCurrency": first_currency,
"seller": {"@type": "Organization", "name": "VSHN"},
}
# Note: aggregateRating and review fields are not included as this is a B2B
# service marketplace without a review system. These could be added in the future
# if customer reviews/ratings are implemented.
else:
# No pricing data available - use Organization data instead of Product
# to avoid Google Search Console errors for missing required Product fields
data = organization_data
elif view_name == "article_list":
data = {
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": "Servala Articles",
"url": f"{base_url}/articles/",
"description": "Read our latest articles about cloud services, best practices, and industry insights.",
"isPartOf": {"@type": "WebSite", "name": "Servala", "url": base_url},
}
elif view_name == "article_detail" and "article" in context:
article = context["article"]
article_url = request.build_absolute_uri()
data = {
"@context": "https://schema.org",
"@type": "Article",
"headline": article.title,
"description": article.excerpt,
"url": article_url,
"author": {
"@type": "Person",
"name": article.author.get_full_name() or article.author.username,
},
"publisher": {
"@type": "Organization",
"name": "Servala",
"logo": {
"@type": "ImageObject",
"url": f"{base_url}/static/img/servala-logo.png",
data["offers"] = {
"@type": "AggregateOffer",
"availability": "https://schema.org/InStock",
"offerCount": offering.plans.count(),
"seller": {
"@type": "Organization",
"name": offering.cloud_provider.name,
"url": request.build_absolute_uri(
offering.cloud_provider.get_absolute_url()
),
},
},
}
# Add publication date using article_date field
article_datetime = django_timezone.make_aware(
datetime.combine(article.article_date, time.min)
)
data["datePublished"] = article_datetime.isoformat()
# Add modification date
if article.updated_at:
data["dateModified"] = article.updated_at.isoformat()
# Add image using the model's get_image property or get_og_image
if article.get_og_image:
data["image"] = request.build_absolute_uri(article.get_og_image.url)
elif article.get_image:
data["image"] = request.build_absolute_uri(article.get_image.url)
# Add keywords from meta_keywords field
if article.meta_keywords:
data["keywords"] = article.meta_keywords
# Add main entity of page
data["mainEntityOfPage"] = {
"@type": "WebPage",
"@id": article_url,
}
# Add about field based on related entities
if article.related_consulting_partner:
data["about"] = {
"@type": "Organization",
"name": article.related_consulting_partner.name,
}
elif article.related_cloud_provider:
data["about"] = {
"@type": "Organization",
"name": article.related_cloud_provider.name,
}
else:

View file

@ -63,9 +63,9 @@ def social_meta_tags(context):
article = context["article"]
title = f"Servala - {article.title}"
description = article.excerpt
# Use OG image if available, otherwise fall back to article image, then default
if article.get_og_image:
image_url = request.build_absolute_uri(article.get_og_image.url)
# Use article image if available, otherwise default
if article.image:
image_url = request.build_absolute_uri(article.image.url)
# Determine og:type based on view
og_type = "website" # default

View file

@ -1,5 +1,6 @@
from decimal import Decimal
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.utils import timezone
from datetime import timedelta
@ -9,12 +10,16 @@ from ..models.services import Service
from ..models.pricing import (
ComputePlan,
ComputePlanPrice,
StoragePlan,
StoragePlanPrice,
ProgressiveDiscountModel,
DiscountTier,
VSHNAppCatPrice,
VSHNAppCatBaseFee,
VSHNAppCatUnitRate,
VSHNAppCatAddon,
VSHNAppCatAddonBaseFee,
VSHNAppCatAddonUnitRate,
ExternalPricePlans,
)
@ -158,8 +163,7 @@ class PricingEdgeCasesTestCase(TestCase):
)
# Should return None when price doesn't exist
# For BASE_FEE addons, service_level is required
price = addon.get_price(Currency.CHF, service_level="standard")
price = addon.get_price(Currency.CHF)
self.assertIsNone(price)
def test_compute_plan_with_validity_dates(self):

View file

@ -1,6 +1,5 @@
from django.urls import path
from . import views
from .feeds import ArticleRSSFeed
app_name = "services"
@ -20,7 +19,6 @@ urlpatterns = [
path("provider/<slug:slug>/", views.provider_detail, name="provider_detail"),
path("partner/<slug:slug>/", views.partner_detail, name="partner_detail"),
path("articles/", views.article_list, name="article_list"),
path("articles/rss/", ArticleRSSFeed(), name="article_rss"),
path("article/<slug:slug>/", views.article_detail, name="article_detail"),
path("contact/", views.leads.contact, name="contact"),
path("contact/thank-you/", views.thank_you, name="thank_you"),
@ -31,14 +29,4 @@ urlpatterns = [
views.pricelist,
name="pricelist",
),
path(
"csp-roi-calculator/",
views.csp_roi_calculator,
name="csp_roi_calculator",
),
path(
"csp-roi-calculator/help/",
views.roi_calculator_help,
name="roi_calculator_help",
),
]

View file

@ -1,243 +0,0 @@
from django.core.files.base import ContentFile
from django.utils.text import slugify
from ..models.images import ImageLibrary
import os
try:
import requests
except ImportError:
requests = None
from PIL import Image as PILImage
def create_image_from_file(
file_path, name, description="", alt_text="", category="other", tags=""
):
"""
Create an ImageLibrary entry from a local file.
Args:
file_path: Path to the image file
name: Name for the image
description: Optional description
alt_text: Alternative text for accessibility
category: Image category
tags: Comma-separated tags
Returns:
ImageLibrary instance or None if failed
"""
try:
if not os.path.exists(file_path):
print(f"File not found: {file_path}")
return None
# Generate slug
slug = slugify(name)
# Check if image already exists
if ImageLibrary.objects.filter(slug=slug).exists():
print(f"Image with slug '{slug}' already exists")
return ImageLibrary.objects.get(slug=slug)
# Create image library entry
image_lib = ImageLibrary(
name=name,
slug=slug,
description=description,
alt_text=alt_text or name,
category=category,
tags=tags,
)
# Read and save the image file
with open(file_path, "rb") as f:
image_lib.image.save(
os.path.basename(file_path), ContentFile(f.read()), save=True
)
print(f"Created image library entry: {name}")
return image_lib
except Exception as e:
print(f"Error creating image library entry: {e}")
return None
def create_image_from_url(
url, name, description="", alt_text="", category="other", tags=""
):
"""
Create an ImageLibrary entry from a URL.
Args:
url: URL to the image
name: Name for the image
description: Optional description
alt_text: Alternative text for accessibility
category: Image category
tags: Comma-separated tags
Returns:
ImageLibrary instance or None if failed
"""
if requests is None:
print("requests library is not installed. Cannot download from URL.")
return None
try:
# Generate slug
slug = slugify(name)
# Check if image already exists
if ImageLibrary.objects.filter(slug=slug).exists():
print(f"Image with slug '{slug}' already exists")
return ImageLibrary.objects.get(slug=slug)
# Download the image
response = requests.get(url)
response.raise_for_status()
# Create image library entry
image_lib = ImageLibrary(
name=name,
slug=slug,
description=description,
alt_text=alt_text or name,
category=category,
tags=tags,
)
# Save the image
filename = url.split("/")[-1]
if "?" in filename:
filename = filename.split("?")[0]
image_lib.image.save(filename, ContentFile(response.content), save=True)
print(f"Created image library entry from URL: {name}")
return image_lib
except Exception as e:
print(f"Error creating image library entry from URL: {e}")
return None
def get_image_by_slug(slug):
"""
Get an image from the library by slug.
Args:
slug: Slug of the image
Returns:
ImageLibrary instance or None if not found
"""
try:
return ImageLibrary.objects.get(slug=slug)
except ImageLibrary.DoesNotExist:
return None
def get_images_by_category(category):
"""
Get all images from a specific category.
Args:
category: Category name
Returns:
QuerySet of ImageLibrary instances
"""
return ImageLibrary.objects.filter(category=category)
def get_images_by_tags(tags):
"""
Get images that contain any of the specified tags.
Args:
tags: List of tags or comma-separated string
Returns:
QuerySet of ImageLibrary instances
"""
if isinstance(tags, str):
tags = [tag.strip() for tag in tags.split(",")]
from django.db.models import Q
query = Q()
for tag in tags:
query |= Q(tags__icontains=tag)
return ImageLibrary.objects.filter(query).distinct()
def cleanup_unused_images():
"""
Find and optionally clean up unused images from the library.
Returns:
List of ImageLibrary instances with usage_count = 0
"""
unused_images = ImageLibrary.objects.filter(usage_count=0)
print(f"Found {unused_images.count()} unused images:")
for image in unused_images:
print(f" - {image.name} ({image.slug})")
return unused_images
def optimize_image(image_library_instance, max_width=1920, max_height=1080, quality=85):
"""
Optimize an image in the library by resizing and compressing.
Args:
image_library_instance: ImageLibrary instance
max_width: Maximum width in pixels
max_height: Maximum height in pixels
quality: JPEG quality (1-100)
Returns:
bool: True if optimization was successful
"""
try:
if not image_library_instance.image:
return False
# Open the image
with PILImage.open(image_library_instance.image.path) as img:
# Calculate new dimensions while maintaining aspect ratio
ratio = min(max_width / img.width, max_height / img.height)
if ratio < 1: # Only resize if image is larger than max dimensions
new_width = int(img.width * ratio)
new_height = int(img.height * ratio)
# Resize the image
img_resized = img.resize(
(new_width, new_height), PILImage.Resampling.LANCZOS
)
# Save the optimized image
img_resized.save(
image_library_instance.image.path,
format="JPEG",
quality=quality,
optimize=True,
)
# Update the image properties
image_library_instance._update_image_properties()
print(f"Optimized image: {image_library_instance.name}")
return True
else:
print(f"Image already optimal: {image_library_instance.name}")
return True
except Exception as e:
print(f"Error optimizing image {image_library_instance.name}: {e}")
return False

View file

@ -7,4 +7,3 @@ from .services import *
from .pages import *
from .subscriptions import *
from .pricelist import *
from .calculator import *

View file

@ -23,7 +23,7 @@ def article_list(request):
# Apply filters based on request parameters
if search_query:
articles = articles.filter(
Q(title__icontains=search_query)
Q(title__icontains=search_query)
| Q(excerpt__icontains=search_query)
| Q(content__icontains=search_query)
| Q(meta_keywords__icontains=search_query)
@ -41,7 +41,7 @@ def article_list(request):
# Order articles: featured first, then by creation date (newest first)
articles = articles.order_by(
"-is_featured", # Featured first (True before False)
"-article_date", # Newest first
"-created_at", # Newest first
)
# Create base querysets for each filter type that apply all OTHER current filters
@ -51,7 +51,7 @@ def article_list(request):
service_filter_base = all_articles
if search_query:
service_filter_base = service_filter_base.filter(
Q(title__icontains=search_query)
Q(title__icontains=search_query)
| Q(excerpt__icontains=search_query)
| Q(content__icontains=search_query)
| Q(meta_keywords__icontains=search_query)
@ -69,7 +69,7 @@ def article_list(request):
cp_filter_base = all_articles
if search_query:
cp_filter_base = cp_filter_base.filter(
Q(title__icontains=search_query)
Q(title__icontains=search_query)
| Q(excerpt__icontains=search_query)
| Q(content__icontains=search_query)
| Q(meta_keywords__icontains=search_query)
@ -85,7 +85,7 @@ def article_list(request):
cloud_filter_base = all_articles
if search_query:
cloud_filter_base = cloud_filter_base.filter(
Q(title__icontains=search_query)
Q(title__icontains=search_query)
| Q(excerpt__icontains=search_query)
| Q(content__icontains=search_query)
| Q(meta_keywords__icontains=search_query)
@ -136,14 +136,16 @@ def article_detail(request, slug):
Article.objects.select_related(
"author",
"related_service",
"related_consulting_partner",
"related_cloud_provider",
"related_consulting_partner",
"related_cloud_provider"
).filter(is_published=True),
slug=slug,
)
# Get related articles (same service, partner, or provider)
related_articles = Article.objects.filter(is_published=True).exclude(id=article.id)
related_articles = Article.objects.filter(
is_published=True
).exclude(id=article.id)
if article.related_service:
related_articles = related_articles.filter(
@ -162,13 +164,13 @@ def article_detail(request, slug):
related_articles = related_articles.filter(
related_service__isnull=True,
related_consulting_partner__isnull=True,
related_cloud_provider__isnull=True,
related_cloud_provider__isnull=True
)
related_articles = related_articles.order_by("-article_date")[:3]
related_articles = related_articles.order_by("-created_at")[:3]
context = {
"article": article,
"related_articles": related_articles,
}
return render(request, "services/article_detail.html", context)
return render(request, "services/article_detail.html", context)

View file

@ -1,106 +0,0 @@
from django.shortcuts import render, redirect
from django.contrib import messages
from django.views.decorators.http import require_http_methods
from django.conf import settings
@require_http_methods(["GET", "POST"])
def csp_roi_calculator(request):
"""
CSP ROI Calculator - Protected view with password authentication
Provides a comprehensive ROI calculation tool for cloud provider investors
"""
# Handle logout
if request.method == "POST" and request.POST.get("logout"):
request.session.pop("csp_calculator_authenticated", None)
return redirect("services:csp_roi_calculator")
# Get password from Django settings
calculator_password = getattr(settings, "CSP_CALCULATOR_PASSWORD", None)
# If no password is configured, deny access
if not calculator_password:
messages.error(
request,
"Calculator is not properly configured. Please contact administrator.",
)
return render(
request, "calculator/password_form.html", {"password_error": True}
)
# Password protection - check if authenticated in session
if not request.session.get("csp_calculator_authenticated", False):
if request.method == "POST":
password = request.POST.get("password", "")
# Validate password
if password == calculator_password:
request.session["csp_calculator_authenticated"] = True
# Set session timeout (optional - expires after 24 hours of inactivity)
request.session.set_expiry(86400) # 24 hours
messages.success(request, "Access granted to CSP ROI Calculator.")
return redirect("services:csp_roi_calculator")
else:
messages.error(request, "Invalid password. Please try again.")
# Show password form
return render(request, "calculator/password_form.html")
# User is authenticated, show the calculator
context = {
"page_title": "CSP ROI Calculator",
"page_description": "Calculate potential returns from investing in Servala platform",
}
return render(request, "calculator/csp_roi_calculator.html", context)
@require_http_methods(["GET", "POST"])
def roi_calculator_help(request):
"""
ROI Calculator Help page - Protected view with same password authentication as calculator
Shows detailed information about investment models and market analysis
"""
# Handle logout
if request.method == "POST" and request.POST.get("logout"):
request.session.pop("csp_calculator_authenticated", None)
return redirect("services:roi_calculator_help")
# Get password from Django settings
calculator_password = getattr(settings, "CSP_CALCULATOR_PASSWORD", None)
# If no password is configured, deny access
if not calculator_password:
messages.error(
request,
"Calculator help is not properly configured. Please contact administrator.",
)
return render(
request, "calculator/password_form.html", {"password_error": True}
)
# Password protection - check if authenticated in session
if not request.session.get("csp_calculator_authenticated", False):
if request.method == "POST":
password = request.POST.get("password", "")
# Validate password
if password == calculator_password:
request.session["csp_calculator_authenticated"] = True
# Set session timeout (optional - expires after 24 hours of inactivity)
request.session.set_expiry(86400) # 24 hours
messages.success(request, "Access granted to CSP ROI Calculator Help.")
return redirect("services:roi_calculator_help")
else:
messages.error(request, "Invalid password. Please try again.")
# Show password form
return render(request, "calculator/password_form.html")
# User is authenticated, show the help page
context = {
"page_title": "ROI Calculator Help - Investment Models",
"page_description": "Understand Servala's Loan and Direct Investment models with detailed explanations and market analysis",
}
return render(request, "calculator/roi_calculator_help.html", context)

View file

@ -173,28 +173,15 @@ def generate_exoscale_marketplace_yaml(offering):
).strip()
# Build YAML structure
service_name = offering.service.name
# List of service names that should have "Enterprise" appended
# This concerns all services which are already available on Exoscale Marketplace or DBaaS for differentiation
# A workaround because we don't particularly have "Enterprise" services yet
enterprise_services = ["GitLab", "PostgreSQL"]
if any(
enterprise_service in service_name for enterprise_service in enterprise_services
):
service_name += " Enterprise"
title = f"{service_name} by Servala"
yaml_structure = {
yaml_key: {
"page_class": "tmpl-marketplace-product",
"html_title": title,
"meta_desc": f"Managed {offering.service.name} by Servala - a product by VSHN. Servala is the The Sovereign App Store. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
"page_header_title": title,
"html_title": f"Managed {offering.service.name} by VSHN via Servala",
"meta_desc": "Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
"page_header_title": f"Managed {offering.service.name} by VSHN via Servala",
"provider_key": "vshn",
"slug": f"{offering.service.slug}-by-servala",
"title": title,
"slug": f"servala-managed-{offering.service.slug}",
"title": f"Managed {offering.service.name} by VSHN via Servala",
"logo": f"img/servala-{offering.service.slug}.svg",
"list_display": [],
"meta": [
@ -255,221 +242,19 @@ def generate_exoscale_marketplace_yaml(offering):
def generate_pricing_data(offering):
"""Generate pricing data for a specific offering and cloud provider"""
# Fetch compute plans for this cloud provider
compute_plans = (
ComputePlan.objects.filter(active=True, cloud_provider=offering.cloud_provider)
.select_related("cloud_provider", "group")
.prefetch_related("prices")
.order_by("group__order", "group__name")
)
"""Generate pricing data for a specific offering and its plans with multi-currency support"""
# Fetch all plans for this offering
plans = offering.plans.prefetch_related("plan_prices")
# Apply natural sorting for compute plan names
compute_plans = sorted(
compute_plans,
key=lambda x: (
x.group.order if x.group else 999,
x.group.name if x.group else "ZZZ",
natural_sort_key(x.name),
),
)
pricing_data = []
for plan in plans:
for plan_price in plan.plan_prices.all():
pricing_data.append({
"plan_id": plan.id,
"plan_name": plan.name,
"description": plan.description,
"currency": plan_price.currency,
"amount": float(plan_price.amount),
})
# Fetch storage plans for this cloud provider
storage_plans = (
StoragePlan.objects.filter(cloud_provider=offering.cloud_provider)
.prefetch_related("prices")
.order_by("name")
)
# Get default storage pricing (use first available storage plan)
storage_price_data = {}
if storage_plans.exists():
default_storage_plan = storage_plans.first()
for currency in ["CHF", "EUR", "USD"]: # Add currencies as needed
price = default_storage_plan.get_price(currency)
if price is not None:
storage_price_data[currency] = price
# Fetch pricing for this specific service
try:
appcat_price = (
VSHNAppCatPrice.objects.select_related("service", "discount_model")
.prefetch_related("base_fees", "unit_rates", "discount_model__tiers")
.get(service=offering.service)
)
except VSHNAppCatPrice.DoesNotExist:
return None
pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
processed_combinations = set()
# Generate pricing combinations for each compute plan
for plan in compute_plans:
plan_currencies = set(plan.prices.values_list("currency", flat=True))
# Determine units based on variable unit type
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
units = int(plan.ram)
elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
units = int(plan.vcpus)
else:
continue
base_fee_currencies = set(
appcat_price.base_fees.values_list("currency", flat=True)
)
service_levels = appcat_price.unit_rates.values_list(
"service_level", flat=True
).distinct()
for service_level in service_levels:
unit_rate_currencies = set(
appcat_price.unit_rates.filter(service_level=service_level).values_list(
"currency", flat=True
)
)
# Find currencies that exist across all pricing components
matching_currencies = plan_currencies.intersection(
base_fee_currencies
).intersection(unit_rate_currencies)
if not matching_currencies:
continue
for currency in matching_currencies:
combination_key = (
plan.name,
service_level,
currency,
)
# Skip if combination already processed
if combination_key in processed_combinations:
continue
processed_combinations.add(combination_key)
# Get pricing components
compute_plan_price = plan.get_price(currency)
base_fee = appcat_price.get_base_fee(currency, service_level)
unit_rate = appcat_price.get_unit_rate(currency, service_level)
# Skip if any pricing component is missing
if any(
price is None for price in [compute_plan_price, base_fee, unit_rate]
):
continue
# Calculate replica enforcement based on service level
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
replica_enforce = appcat_price.ha_replica_min
else:
replica_enforce = 1
total_units = units * replica_enforce
standard_sla_price = base_fee + (total_units * unit_rate)
# Apply discount if available
if appcat_price.discount_model and appcat_price.discount_model.active:
discounted_price = appcat_price.discount_model.calculate_discount(
unit_rate, total_units
)
sla_price = base_fee + discounted_price
else:
sla_price = standard_sla_price
# Get addons information
addons = appcat_price.addons.filter(active=True)
mandatory_addons = []
optional_addons = []
# Calculate additional price from mandatory addons
addon_total = 0
for addon in addons:
addon_price = None
addon_price_per_unit = None
if addon.addon_type == "BF": # Base Fee
addon_price = addon.get_price(currency, service_level)
elif addon.addon_type == "UR": # Unit Rate
addon_price_per_unit = addon.get_price(currency, service_level)
if addon_price_per_unit:
addon_price = addon_price_per_unit * total_units
addon_info = {
"id": addon.id,
"name": addon.name,
"description": addon.description,
"commercial_description": addon.commercial_description,
"addon_type": addon.get_addon_type_display(),
"price": addon_price,
"price_per_unit": addon_price_per_unit, # Add per-unit price for frontend calculations
}
if addon.mandatory:
mandatory_addons.append(addon_info)
if addon_price:
addon_total += addon_price
sla_price += addon_price
else:
optional_addons.append(addon_info)
final_price = compute_plan_price + sla_price
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
service_level
]
group_name = plan.group.name if plan.group else "No Group"
# Add pricing data to the grouped structure
pricing_data_by_group_and_service_level[group_name][
service_level_display
].append(
{
"compute_plan": plan.name,
"compute_plan_group": group_name,
"compute_plan_group_description": (
plan.group.description if plan.group else ""
),
"vcpus": plan.vcpus,
"ram": plan.ram,
"currency": currency,
"compute_plan_price": compute_plan_price,
"sla_price": sla_price,
"final_price": final_price,
"storage_price": storage_price_data.get(currency, 0),
"ha_replica_min": appcat_price.ha_replica_min,
"ha_replica_max": appcat_price.ha_replica_max,
"variable_unit": appcat_price.variable_unit,
"units": units,
"total_units": total_units,
"mandatory_addons": mandatory_addons,
"optional_addons": optional_addons,
}
)
# Order groups correctly, placing "No Group" last
ordered_groups = {}
all_group_names = list(pricing_data_by_group_and_service_level.keys())
if "No Group" in all_group_names:
all_group_names.remove("No Group")
all_group_names.append("No Group")
for group_name_key in all_group_names:
ordered_groups[group_name_key] = pricing_data_by_group_and_service_level[
group_name_key
]
# Convert defaultdicts to regular dicts for the template
final_context_data = {}
for group_key, service_levels_dict in ordered_groups.items():
final_context_data[group_key] = {
sl_key: list(plans_list)
for sl_key, plans_list in service_levels_dict.items()
}
return final_context_data
return {"plans": pricing_data}

View file

@ -1,7 +1,6 @@
from django.shortcuts import render, get_object_or_404
from django.db.models import Q
from hub.services.models import ConsultingPartner, CloudProvider, Service
from hub.services.models.base import PartnerCategory
def partner_list(request):
@ -9,7 +8,6 @@ def partner_list(request):
search_query = request.GET.get("search", "")
service_id = request.GET.get("service", "")
cloud_provider_id = request.GET.get("cloud_provider", "")
category = request.GET.get("category", "")
# Start with all active partners
partners = ConsultingPartner.objects.filter(disable_listing=False).order_by("order")
@ -26,9 +24,6 @@ def partner_list(request):
if cloud_provider_id:
partners = partners.filter(cloud_providers__id=cloud_provider_id)
if category:
partners = partners.filter(category=category)
# Get available services from filtered partners
available_service_ids = partners.values_list("services__id", flat=True).distinct()
available_services = Service.objects.filter(
@ -73,7 +68,6 @@ def partner_list(request):
),
"available_services": available_services,
"available_cloud_providers": available_cloud_providers,
"partner_categories": PartnerCategory.choices,
}
return render(request, "services/partner_list.html", context)

View file

@ -2,19 +2,14 @@ import re
from django.shortcuts import render
from collections import defaultdict
from hub.services.models.pricing import (
ComputePlan,
StoragePlan,
ExternalPricePlans,
VSHNAppCatPrice,
)
from hub.services.models.pricing import ComputePlan, StoragePlan, ExternalPricePlans, VSHNAppCatPrice
from django.contrib.admin.views.decorators import staff_member_required
from django.db import models
def natural_sort_key(obj):
"""Extract numeric parts for natural sorting (works for any plan name)"""
name = obj.name if hasattr(obj, "name") else str(obj)
name = obj.name if hasattr(obj, 'name') else str(obj)
parts = re.split(r"(\d+)", name)
return [int(part) if part.isdigit() else part for part in parts]
@ -135,61 +130,18 @@ def pricelist(request):
filter_compute_plan_group = request.GET.get("compute_plan_group", "")
filter_service_level = request.GET.get("service_level", "")
# Get filter options for dropdowns first (needed for initial page load)
all_cloud_providers = (
ComputePlan.objects.all()
.values_list("cloud_provider__name", flat=True)
.distinct()
.order_by("cloud_provider__name")
)
all_services = (
VSHNAppCatPrice.objects.values_list("service__name", flat=True)
.distinct()
.order_by("service__name")
)
all_compute_plan_groups = list(
ComputePlan.objects.filter(group__isnull=False)
.values_list("group__name", flat=True)
.distinct()
.order_by("group__name")
)
all_compute_plan_groups.append("No Group") # Add option for plans without groups
all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices]
# Only process pricing data if both cloud provider and service are selected
if not filter_cloud_provider or not filter_service:
context = {
"pricing_data_by_group_and_service_level": {},
"show_discount_details": show_discount_details,
"show_addon_details": show_addon_details,
"show_price_comparison": show_price_comparison,
"filter_cloud_provider": filter_cloud_provider,
"filter_service": filter_service,
"filter_compute_plan_group": filter_compute_plan_group,
"filter_service_level": filter_service_level,
"all_cloud_providers": all_cloud_providers,
"all_services": all_services,
"all_compute_plan_groups": all_compute_plan_groups,
"all_service_levels": all_service_levels,
"show_empty_state": True, # Flag to show empty state message
}
return render(request, "services/pricelist.html", context)
# Fetch all compute plans (active and inactive) with related data
compute_plans_qs = ComputePlan.objects.all()
if filter_cloud_provider:
compute_plans_qs = compute_plans_qs.filter(
cloud_provider__name=filter_cloud_provider
)
compute_plans_qs = compute_plans_qs.filter(cloud_provider__name=filter_cloud_provider)
if filter_compute_plan_group:
if filter_compute_plan_group == "No Group":
compute_plans_qs = compute_plans_qs.filter(group__isnull=True)
else:
compute_plans_qs = compute_plans_qs.filter(
group__name=filter_compute_plan_group
)
compute_plans_qs = compute_plans_qs.filter(group__name=filter_compute_plan_group)
compute_plans = list(
compute_plans_qs.select_related("cloud_provider", "group")
compute_plans_qs
.select_related("cloud_provider", "group")
.prefetch_related("prices")
.order_by("group__order", "group__name", "cloud_provider__name", "name")
)
@ -234,30 +186,20 @@ def pricelist(request):
units = int(plan.vcpus)
else:
continue
base_fee_currencies = set(
appcat_price.base_fees.values_list("currency", flat=True)
)
service_levels = appcat_price.unit_rates.values_list(
"service_level", flat=True
).distinct()
base_fee_currencies = set(appcat_price.base_fees.values_list("currency", flat=True))
service_levels = appcat_price.unit_rates.values_list("service_level", flat=True).distinct()
# Apply service level filter
if filter_service_level:
service_levels = [
sl
for sl in service_levels
if dict(VSHNAppCatPrice.ServiceLevel.choices)[sl]
== filter_service_level
sl for sl in service_levels
if dict(VSHNAppCatPrice.ServiceLevel.choices)[sl] == filter_service_level
]
for service_level in service_levels:
unit_rate_currencies = set(
appcat_price.unit_rates.filter(
service_level=service_level
).values_list("currency", flat=True)
appcat_price.unit_rates.filter(service_level=service_level).values_list("currency", flat=True)
)
# Find currencies that exist across all pricing components
matching_currencies = plan_currencies.intersection(
base_fee_currencies
).intersection(unit_rate_currencies)
matching_currencies = plan_currencies.intersection(base_fee_currencies).intersection(unit_rate_currencies)
if not matching_currencies:
continue
for currency in matching_currencies:
@ -275,10 +217,7 @@ def pricelist(request):
compute_plan_price = plan.get_price(currency)
base_fee = appcat_price.get_base_fee(currency, service_level)
unit_rate = appcat_price.get_unit_rate(currency, service_level)
if any(
price is None
for price in [compute_plan_price, base_fee, unit_rate]
):
if any(price is None for price in [compute_plan_price, base_fee, unit_rate]):
continue
# Calculate replica enforcement based on service level
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
@ -289,27 +228,12 @@ def pricelist(request):
standard_sla_price = base_fee + (total_units * unit_rate)
# Apply discount if available
discount_breakdown = None
if (
appcat_price.discount_model
and appcat_price.discount_model.active
):
discounted_price = (
appcat_price.discount_model.calculate_discount(
unit_rate, total_units
)
)
if appcat_price.discount_model and appcat_price.discount_model.active:
discounted_price = appcat_price.discount_model.calculate_discount(unit_rate, total_units)
sla_price = base_fee + discounted_price
discount_savings = standard_sla_price - sla_price
discount_percentage = (
(discount_savings / standard_sla_price) * 100
if standard_sla_price > 0
else 0
)
discount_breakdown = (
appcat_price.discount_model.get_discount_breakdown(
unit_rate, total_units
)
)
discount_percentage = (discount_savings / standard_sla_price) * 100 if standard_sla_price > 0 else 0
discount_breakdown = appcat_price.discount_model.get_discount_breakdown(unit_rate, total_units)
else:
sla_price = standard_sla_price
discounted_price = total_units * unit_rate
@ -335,9 +259,7 @@ def pricelist(request):
if addon.addon_type == "BF": # Base Fee
addon_price = addon.get_price(currency, service_level)
elif addon.addon_type == "UR": # Unit Rate
addon_price_per_unit = addon.get_price(
currency, service_level
)
addon_price_per_unit = addon.get_price(currency, service_level)
if addon_price_per_unit:
addon_price = addon_price_per_unit * total_units
addon_info = {
@ -354,131 +276,89 @@ def pricelist(request):
optional_addons.append(addon_info)
service_price_with_addons = price_calculation["total_price"]
final_price = compute_plan_price + service_price_with_addons
service_level_display = dict(
VSHNAppCatPrice.ServiceLevel.choices
).get(service_level, service_level)
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices).get(service_level, service_level)
# Get external/internal price comparisons if enabled (unchanged, but could be optimized further)
external_comparisons = []
internal_comparisons = []
if show_price_comparison:
external_prices = get_external_price_comparisons(
plan, appcat_price, currency, service_level
)
external_prices = get_external_price_comparisons(plan, appcat_price, currency, service_level)
for ext_price in external_prices:
difference = ext_price.amount - final_price
ratio = (
ext_price.amount / final_price if final_price > 0 else 0
)
external_comparisons.append(
{
"plan_name": ext_price.plan_name,
"provider": ext_price.cloud_provider.name,
"description": ext_price.description,
"amount": ext_price.amount,
"currency": ext_price.currency,
"vcpus": ext_price.vcpus,
"ram": ext_price.ram,
"storage": ext_price.storage,
"replicas": ext_price.replicas,
"difference": difference,
"ratio": ratio,
"source": ext_price.source,
"date_retrieved": ext_price.date_retrieved,
"is_internal": False,
}
)
internal_price_comparisons = (
get_internal_cloud_provider_comparisons(
plan, appcat_price, currency, service_level
)
)
ratio = ext_price.amount / final_price if final_price > 0 else 0
external_comparisons.append({
"plan_name": ext_price.plan_name,
"provider": ext_price.cloud_provider.name,
"description": ext_price.description,
"amount": ext_price.amount,
"currency": ext_price.currency,
"vcpus": ext_price.vcpus,
"ram": ext_price.ram,
"storage": ext_price.storage,
"replicas": ext_price.replicas,
"difference": difference,
"ratio": ratio,
"source": ext_price.source,
"date_retrieved": ext_price.date_retrieved,
"is_internal": False,
})
internal_price_comparisons = get_internal_cloud_provider_comparisons(plan, appcat_price, currency, service_level)
for int_price in internal_price_comparisons:
difference = int_price["final_price"] - final_price
ratio = (
int_price["final_price"] / final_price
if final_price > 0
else 0
)
internal_comparisons.append(
{
"plan_name": int_price["plan_name"],
"provider": int_price["provider"],
"description": f"Same specs with {int_price['provider']}",
"amount": int_price["final_price"],
"currency": int_price["currency"],
"vcpus": int_price["vcpus"],
"ram": int_price["ram"],
"group_name": int_price["group_name"],
"compute_plan_price": int_price[
"compute_plan_price"
],
"service_price": int_price["service_price"],
"difference": difference,
"ratio": ratio,
"is_internal": True,
}
)
ratio = int_price["final_price"] / final_price if final_price > 0 else 0
internal_comparisons.append({
"plan_name": int_price["plan_name"],
"provider": int_price["provider"],
"description": f"Same specs with {int_price['provider']}",
"amount": int_price["final_price"],
"currency": int_price["currency"],
"vcpus": int_price["vcpus"],
"ram": int_price["ram"],
"group_name": int_price["group_name"],
"compute_plan_price": int_price["compute_plan_price"],
"service_price": int_price["service_price"],
"difference": difference,
"ratio": ratio,
"is_internal": True,
})
group_name = plan.group.name if plan.group else "No Group"
# Use prefetched storage plans
storage_plans = storage_plans_by_provider.get(
plan.cloud_provider_id, []
)
pricing_data_by_group_and_service_level[group_name][
service_level_display
].append(
{
"cloud_provider": plan.cloud_provider.name,
"service": appcat_price.service.name,
"compute_plan": plan.name,
"compute_plan_group": group_name,
"compute_plan_group_description": (
plan.group.description if plan.group else ""
),
"compute_plan_group_node_label": (
plan.group.node_label if plan.group else ""
),
"storage_plans": storage_plans,
"vcpus": plan.vcpus,
"ram": plan.ram,
"cpu_mem_ratio": plan.cpu_mem_ratio,
"term": plan.get_term_display(),
"currency": currency,
"compute_plan_price": compute_plan_price,
"variable_unit": appcat_price.get_variable_unit_display(),
"units": units,
"replica_enforce": replica_enforce,
"total_units": total_units,
"service_level": service_level_display,
"sla_base": base_fee,
"sla_per_unit": unit_rate,
"sla_price": service_price_with_addons,
"standard_sla_price": base_sla_price,
"discounted_sla_price": (
base_fee + discounted_price
if appcat_price.discount_model
and appcat_price.discount_model.active
else None
),
"discount_savings": discount_savings,
"discount_percentage": discount_percentage,
"discount_breakdown": discount_breakdown,
"final_price": final_price,
"discount_model": (
appcat_price.discount_model.name
if appcat_price.discount_model
else None
),
"has_discount": bool(
appcat_price.discount_model
and appcat_price.discount_model.active
),
"external_comparisons": external_comparisons,
"internal_comparisons": internal_comparisons,
"mandatory_addons": mandatory_addons,
"optional_addons": optional_addons,
"is_active": plan.active,
}
)
storage_plans = storage_plans_by_provider.get(plan.cloud_provider_id, [])
pricing_data_by_group_and_service_level[group_name][service_level_display].append({
"cloud_provider": plan.cloud_provider.name,
"service": appcat_price.service.name,
"compute_plan": plan.name,
"compute_plan_group": group_name,
"compute_plan_group_description": (plan.group.description if plan.group else ""),
"compute_plan_group_node_label": (plan.group.node_label if plan.group else ""),
"storage_plans": storage_plans,
"vcpus": plan.vcpus,
"ram": plan.ram,
"cpu_mem_ratio": plan.cpu_mem_ratio,
"term": plan.get_term_display(),
"currency": currency,
"compute_plan_price": compute_plan_price,
"variable_unit": appcat_price.get_variable_unit_display(),
"units": units,
"replica_enforce": replica_enforce,
"total_units": total_units,
"service_level": service_level_display,
"sla_base": base_fee,
"sla_per_unit": unit_rate,
"sla_price": service_price_with_addons,
"standard_sla_price": base_sla_price,
"discounted_sla_price": (base_fee + discounted_price if appcat_price.discount_model and appcat_price.discount_model.active else None),
"discount_savings": discount_savings,
"discount_percentage": discount_percentage,
"discount_breakdown": discount_breakdown,
"final_price": final_price,
"discount_model": (appcat_price.discount_model.name if appcat_price.discount_model else None),
"has_discount": bool(appcat_price.discount_model and appcat_price.discount_model.active),
"external_comparisons": external_comparisons,
"internal_comparisons": internal_comparisons,
"mandatory_addons": mandatory_addons,
"optional_addons": optional_addons,
"is_active": plan.active,
})
# Order groups correctly, placing "No Group" last
ordered_groups_intermediate = {}
all_group_names = list(pricing_data_by_group_and_service_level.keys())
@ -486,9 +366,7 @@ def pricelist(request):
all_group_names.remove("No Group")
all_group_names.append("No Group")
for group_name_key in all_group_names:
ordered_groups_intermediate[group_name_key] = (
pricing_data_by_group_and_service_level[group_name_key]
)
ordered_groups_intermediate[group_name_key] = pricing_data_by_group_and_service_level[group_name_key]
# Convert defaultdicts to regular dicts for the template
final_context_data = {}
for group_key, service_levels_dict in ordered_groups_intermediate.items():
@ -516,7 +394,11 @@ def pricelist(request):
)
all_compute_plan_groups.append("No Group") # Add option for plans without groups
all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices]
# If no filter is specified, select the first available provider/service by default
if not filter_cloud_provider and all_cloud_providers:
filter_cloud_provider = all_cloud_providers[0]
if not filter_service and all_services:
filter_service = all_services[0]
context = {
"pricing_data_by_group_and_service_level": final_context_data,
"show_discount_details": show_discount_details,

View file

@ -59,7 +59,6 @@ CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in HTTPS_HOSTS] + [
# Primary website URL
WEBSITE_URL = env.str("WEBSITE_URL", default="https://servala.com")
DISABLE_REDIRECT = env.bool("DISABLE_REDIRECT", default=False)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
@ -77,7 +76,6 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"django.contrib.sitemaps",
# 3rd party
"compressor",
"django_prose_editor",
"rest_framework",
"schema_viewer",
@ -188,25 +186,6 @@ USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = env.path("STATIC_ROOT", default=BASE_DIR / "static")
# Static files configuration
STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
"compressor.finders.CompressorFinder",
]
# Django Compressor settings
COMPRESS_ENABLED = True
COMPRESS_OFFLINE = True # Compress during build, not runtime
COMPRESS_CSS_FILTERS = [
"compressor.filters.css_default.CssAbsoluteFilter",
"compressor.filters.cssmin.rCSSMinFilter",
]
COMPRESS_JS_FILTERS = [
"compressor.filters.jsmin.rJSMinFilter",
]
COMPRESS_OUTPUT_DIR = "CACHE"
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
@ -238,9 +217,6 @@ ODOO_CONFIG = {
"mailing_list_id": env.int("ODOO_MAILING_LIST_ID", default=46),
}
# CSP ROI Calculator Configuration
CSP_CALCULATOR_PASSWORD = env.str("CSP_CALCULATOR_PASSWORD", default=None)
BROKER_USERNAME = env.str("BROKER_USERNAME", default="broker")
BROKER_PASSWORD = env.str("BROKER_PASSWORD", default="secret")
BASE_URL = "https://your-domain.com"
@ -269,7 +245,6 @@ JAZZMIN_SETTINGS = {
"new_window": True,
},
{"name": "Articles", "url": "/admin/services/article/"},
{"name": "Image Library", "url": "/admin/services/imagelibrary/"},
{"name": "FAQs", "url": "/admin/services/websitefaq/"},
],
"show_sidebar": True,
@ -280,11 +255,8 @@ JAZZMIN_SETTINGS = {
"services.ProgressiveDiscountModel": "single",
"services.VSHNAppCatPrice": "single",
"services.VSHNAppCatAddon": "single",
"services.ServiceOffering": "single",
"services.Plan": "single",
},
"related_modal_active": True,
}
IMPORT_EXPORT_FORMATS = [CSV]
X_FRAME_OPTIONS = "SAMEORIGIN"

View file

@ -5,7 +5,6 @@ from hub.services.models import (
CloudProvider,
ConsultingPartner,
ServiceOffering,
Article,
)
@ -14,12 +13,7 @@ class StaticSitemap(Sitemap):
priority = 1.0
def items(self):
return [
"services:homepage",
"services:contact",
"services:article_list",
"services:article_rss",
]
return ["services:homepage", "services:contact"]
def location(self, item):
return reverse(item)
@ -66,14 +60,3 @@ class ConsultingPartnerSitemap(Sitemap):
def lastmod(self, obj):
return obj.updated_at
class ArticleSitemap(Sitemap):
changefreq = "weekly"
priority = 0.8
def items(self):
return Article.objects.filter(is_published=True)
def lastmod(self, obj):
return obj.updated_at

Some files were not shown because too many files have changed in this diff Show more