Compare commits

..

59 commits

Author SHA1 Message Date
d680c8b093
habemus new tagline
All checks were successful
Build and Deploy / build (push) Successful in 1m6s
Django Tests / test (push) Successful in 1m15s
Build and Deploy / deploy (push) Successful in 5s
2025-08-29 10:08:42 +02:00
71d4962d18
make service card in partner listing aligned 2025-07-24 10:45:58 +02:00
45488fe0db
fix overlapping text in partner detail 2025-07-24 10:41:27 +02:00
347f3d1655
update help text with market analysis and resources - protect with password 2025-07-24 09:45:46 +02:00
469f7af7a4
remove loan from some charts 2025-07-24 09:06:23 +02:00
410aa7e112
update roi calc help page 2025-07-24 09:03:17 +02:00
86d4b6412e
more realistics numbers in the roi calculator 2025-07-24 08:50:02 +02:00
07df4336e9
redesign help page 2025-07-23 20:04:32 +02:00
82e362b532
redone investment scaling logic for incentiving higher investments 2025-07-23 19:58:21 +02:00
59c9fff27d
update help text 2025-07-23 16:10:18 +02:00
ec64dd7415
add csp revenue chart 2025-07-23 15:45:40 +02:00
5cc6b779c5
multi-currency support in roi calculator 2025-07-23 14:50:53 +02:00
adc3a6b905
improve investment input field 2025-07-23 14:38:30 +02:00
e4ba1378b6
tweak config section layout 2025-07-23 14:35:15 +02:00
491dbacda4
add core revenue to model and allow to tweak params 2025-07-23 14:29:54 +02:00
a07788cb74
redesign ROI PDF 2025-07-23 12:04:08 +02:00
c1ed95eff5
remove unneeded faqs 2025-07-23 11:48:10 +02:00
4f8fb0a448
updated help and advanced table 2025-07-23 11:42:22 +02:00
4746cfac25
show both models at the same time 2025-07-23 11:16:15 +02:00
aa57082a1b
tell claude to use context7 for docs 2025-07-23 11:09:10 +02:00
493d45bb5d
further refinement of roi calculator 2025-07-22 17:30:37 +02:00
6f6c80480f
rework investment model 2025-07-22 09:16:40 +02:00
22bea2c53d
speed up ui updating 2025-07-22 08:52:45 +02:00
afe3817395
refactor roi calc js into modular files 2025-07-22 08:50:48 +02:00
51d80364c0
fix critical issues in ROI calculator
- Replace hardcoded Django template tags in JavaScript with runtime CSRF token retrieval
- Add comprehensive error handling for Chart.js dependencies and missing DOM elements
- Enhance input validation with safe fallbacks for malformed number parsing
- Add graceful degradation when external libraries fail to load

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 08:33:54 +02:00
2981be12df
add claude code doc file 2025-07-22 08:22:31 +02:00
626badffe9
introduce investment models 2025-07-21 17:06:29 +02:00
8ab864e444
move export buttons to top right 2025-07-21 16:14:32 +02:00
25400be405
remove npv in roi calculator 2025-07-21 16:12:53 +02:00
cea00c7e8c
update chartjs usage 2025-07-21 16:04:53 +02:00
7d94501858
move roi calc js into own file 2025-07-21 15:56:21 +02:00
6bee3340a4
hardcode base url for feed 2025-07-17 11:09:34 +02:00
0bcfa53e53
only use keywords for categories 2025-07-17 11:01:54 +02:00
d740d6a8da
make read more a link 2025-07-17 11:00:20 +02:00
faf73d3ea4
add rss feed to articles 2 2025-07-17 10:53:05 +02:00
3e35d179f5
add rss feed to articles 2025-07-17 10:52:58 +02:00
edf453244d
SoftwareApplication in LD is not compatible yet 2025-07-17 09:38:15 +02:00
5ab10f2264
fix filters and nav into alpine again 2025-07-17 08:33:06 +02:00
1d39e3445e
fix ld data on offering pages 2025-07-16 16:53:53 +02:00
529fc9148a
fix navigation 2025-07-16 16:52:48 +02:00
6f39f73522
take size of investment into consideration 2025-07-16 16:41:02 +02:00
bcbfeaf53c
dont expose broker urls as this isn't used atm 2025-07-16 16:25:15 +02:00
1b07794fc9
improvements to help tooltips and local serving 2025-07-16 16:24:48 +02:00
ae130ff776
tooltips for main numbers 2025-07-16 15:59:51 +02:00
27d2d3bb7a
configure password via env var 2025-07-16 15:46:21 +02:00
0f54e411db
help text 2025-07-16 15:39:06 +02:00
0a84db25a3
allow to customize params of growth scenarios 2025-07-16 15:31:29 +02:00
1381eb8fdc
add thousand separator for better understanding 2025-07-16 15:19:04 +02:00
3688720f1b
better cope with big numbers 2025-07-16 15:15:34 +02:00
545b49ecd2
csp roi calculator 2025-07-16 15:12:25 +02:00
a4ff21cc35
keep selected plan when changing service level 2025-07-16 13:52:18 +02:00
Tobias Brunner
7f7ffd625b Merge branch 'sync-sliders' into 'main'
sync the vcpu and memory sliders when moving around

See merge request vshn/servala-frontend!7
2025-07-16 13:09:19 +02:00
9a86e023dd
sync the vcpu and memory sliders when moving around 2025-07-16 12:09:21 +02:00
a7713b46a2
much improved display of included in managed service 2025-07-16 11:53:24 +02:00
d3e0fdd941
fixes to mandatory addon display 2025-07-16 11:38:49 +02:00
27c41a6187
robustness review of price calc js 2025-07-16 11:23:53 +02:00
e7c6a53a17
fix service level selector 2025-07-16 11:12:46 +02:00
384c626adb
make selectPlan available in compressed files 2025-07-16 11:07:43 +02:00
92af0a9627
don't show included in when no mandatory addon 2025-07-16 11:00:35 +02:00
44 changed files with 7063 additions and 205 deletions

View file

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

4
.gitignore vendored
View file

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

113
CLAUDE.md Normal file
View file

@ -0,0 +1,113 @@
# 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

@ -0,0 +1,342 @@
# 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

80
hub/services/feeds.py Normal file
View file

@ -0,0 +1,80 @@
"""
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

@ -66,4 +66,13 @@
.btn-success.shadow:hover { .btn-success.shadow:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.2) !important; 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

@ -0,0 +1,949 @@
.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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 316 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

398
hub/services/static/js/jspdf.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -25,6 +25,8 @@ class DOMManager {
this.elements.addonsContainer = document.getElementById('addonsContainer'); this.elements.addonsContainer = document.getElementById('addonsContainer');
this.elements.addonPricingContainer = document.getElementById('addonPricingContainer'); this.elements.addonPricingContainer = document.getElementById('addonPricingContainer');
this.elements.managedServiceIncludesContainer = document.getElementById('managedServiceIncludesContainer'); 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 // Result display elements
this.elements.planMatchStatus = document.getElementById('planMatchStatus'); this.elements.planMatchStatus = document.getElementById('planMatchStatus');
@ -51,12 +53,16 @@ class DOMManager {
this.elements.serviceLevelGroup = document.getElementById('serviceLevelGroup'); this.elements.serviceLevelGroup = document.getElementById('serviceLevelGroup');
} }
// Get element by key // Get element by key with error handling
get(key) { get(key) {
return this.elements[key]; const element = this.elements[key];
if (!element && key !== 'addonsContainer') {
console.warn(`DOM element '${key}' not found`);
}
return element;
} }
// Check if element exists // Check if element exists and is valid
has(key) { has(key) {
return this.elements[key] && this.elements[key] !== null; return this.elements[key] && this.elements[key] !== null;
} }
@ -139,6 +145,36 @@ class DOMManager {
} }
} }
// 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 // Get current selected service level
getSelectedServiceLevel() { getSelectedServiceLevel() {
return document.querySelector('input[name="serviceLevel"]:checked')?.value; return document.querySelector('input[name="serviceLevel"]:checked')?.value;

View file

@ -46,7 +46,15 @@ class OrderManager {
messageField.value = configMessage; messageField.value = configMessage;
} }
// Store configuration details in hidden field // 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"]'); const detailsField = document.querySelector('#order-form input[name="details"]');
if (detailsField) { if (detailsField) {
detailsField.value = JSON.stringify({ detailsField.value = JSON.stringify({

View file

@ -64,7 +64,11 @@ class PlanManager {
if (!planSelect) return; if (!planSelect) return;
const serviceLevel = domManager.getSelectedServiceLevel(); const serviceLevel = domManager.getSelectedServiceLevel();
if (!serviceLevel) return; if (!serviceLevel) {
// Clear dropdown if no service level is selected
planSelect.innerHTML = '<option value="">Select a service level first</option>';
return;
}
// Clear existing options // Clear existing options
planSelect.innerHTML = '<option value="">Auto-select best matching plan</option>'; planSelect.innerHTML = '<option value="">Auto-select best matching plan</option>';
@ -72,6 +76,11 @@ class PlanManager {
// Get plans for the selected service level // Get plans for the selected service level
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel); 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 // Add plans to dropdown
availablePlans.forEach(plan => { availablePlans.forEach(plan => {
const option = document.createElement('option'); const option = document.createElement('option');

View file

@ -4,17 +4,27 @@
*/ */
class PriceCalculator { class PriceCalculator {
constructor() { constructor() {
// Initialize managers try {
this.domManager = new DOMManager(); // Initialize managers
this.currentOffering = this.extractOfferingFromURL(); this.domManager = new DOMManager();
this.pricingDataManager = new PricingDataManager(this.currentOffering); this.currentOffering = this.extractOfferingFromURL();
this.planManager = new PlanManager(this.pricingDataManager);
this.addonManager = new AddonManager(this.pricingDataManager);
this.uiManager = new UIManager();
this.orderManager = new OrderManager();
// Initialize the calculator if (!this.currentOffering) {
this.init(); 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 // Extract offering info from URL
@ -41,11 +51,24 @@ class PriceCalculator {
this.orderManager.setupOrderButton(this.domManager); this.orderManager.setupOrderButton(this.domManager);
this.updateCalculator(); this.updateCalculator();
} else { } else {
console.warn('No current offering found, calculator not initialized'); throw new Error('No current offering found');
} }
} catch (error) { } catch (error) {
console.error('Error initializing price calculator:', error); console.error('Error initializing price calculator:', error);
this.uiManager.showError(this.domManager, 'Failed to load pricing information'); 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';
} }
} }
@ -54,14 +77,20 @@ class PriceCalculator {
// Setup service levels based on available data // Setup service levels based on available data
this.uiManager.setupServiceLevels(this.domManager, this.pricingDataManager); this.uiManager.setupServiceLevels(this.domManager, this.pricingDataManager);
// Calculate and set slider maximums // Calculate and set slider maximums and ranges
this.uiManager.updateSliderMaximums(this.domManager, this.pricingDataManager); this.uiManager.updateSliderMaximums(this.domManager, this.pricingDataManager);
// Set smart default values based on available plans
this.domManager.setSmartDefaults(this.pricingDataManager);
// Populate plan dropdown // Populate plan dropdown
this.planManager.populatePlanDropdown(this.domManager); this.planManager.populatePlanDropdown(this.domManager);
// Initialize instances slider // Initialize instances slider
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager); this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
// Setup service level event listeners after UI is created
this.setupServiceLevelEventListeners();
} }
// Setup event listeners for calculator controls // Setup event listeners for calculator controls
@ -76,11 +105,21 @@ class PriceCalculator {
// Slider event listeners // Slider event listeners
cpuRange.addEventListener('input', () => { cpuRange.addEventListener('input', () => {
this.domManager.get('cpuValue').textContent = cpuRange.value; 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(); this.updatePricing();
}); });
memoryRange.addEventListener('input', () => { memoryRange.addEventListener('input', () => {
this.domManager.get('memoryValue').textContent = memoryRange.value; 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(); this.updatePricing();
}); });
@ -94,17 +133,6 @@ class PriceCalculator {
this.updatePricing(); this.updatePricing();
}); });
// Service level change listeners
const serviceLevelInputs = this.domManager.get('serviceLevelInputs');
serviceLevelInputs.forEach(input => {
input.addEventListener('change', () => {
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
this.planManager.populatePlanDropdown(this.domManager);
this.addonManager.updateAddons(this.domManager);
this.updatePricing();
});
});
// Plan selection listener // Plan selection listener
const planSelect = this.domManager.get('planSelect'); const planSelect = this.domManager.get('planSelect');
if (planSelect) { if (planSelect) {
@ -124,8 +152,8 @@ class PriceCalculator {
// Update pricing with the selected plan // Update pricing with the selected plan
this.updatePricingWithPlan(selectedPlan); this.updatePricingWithPlan(selectedPlan);
} else { } else {
// Auto-select mode - reset sliders to default values // Auto-select mode - reset sliders to smart default values
this.domManager.resetSlidersToDefaults(); this.domManager.setSmartDefaults(this.pricingDataManager);
// Auto-select mode - fade sliders back in // Auto-select mode - fade sliders back in
this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']); this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']);
@ -143,6 +171,84 @@ class PriceCalculator {
}); });
} }
// 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) // Update calculator (initial setup)
updateCalculator() { updateCalculator() {
this.addonManager.updateAddons(this.domManager); this.addonManager.updateAddons(this.domManager);
@ -246,6 +352,150 @@ class PriceCalculator {
[...addons.mandatory, ...addons.optional] [...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 // Export for use in other modules

View file

@ -13,7 +13,8 @@ class PricingDataManager {
// Load pricing data from API endpoint // Load pricing data from API endpoint
async loadPricingData() { async loadPricingData() {
try { try {
const response = await fetch(`/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`); const url = `/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`;
const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load pricing data: ${response.status} ${response.statusText}`); throw new Error(`Failed to load pricing data: ${response.status} ${response.statusText}`);
@ -21,8 +22,17 @@ class PricingDataManager {
const data = await response.json(); const data = await response.json();
if (!data || typeof data !== 'object') {
throw new Error('Invalid pricing data received from server');
}
this.pricingData = data.pricing || data; 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 // Extract addons data from the plans - addons are embedded in each plan
this.extractAddonsData(); this.extractAddonsData();
@ -140,6 +150,31 @@ class PricingDataManager {
return { maxCpus, maxMemory }; 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 // Get all plans for a specific service level
getPlansForServiceLevel(serviceLevel) { getPlansForServiceLevel(serviceLevel) {
if (!this.pricingData || !serviceLevel) return []; if (!this.pricingData || !serviceLevel) return [];

View file

@ -85,19 +85,29 @@ class UIManager {
// Update addon pricing display in the results panel // Update addon pricing display in the results panel
updateAddonPricingDisplay(domManager, mandatoryAddons, selectedOptionalAddons) { updateAddonPricingDisplay(domManager, mandatoryAddons, selectedOptionalAddons) {
// Update mandatory addons in the managed service includes container // Get references to the managed service includes elements
const managedServiceIncludesContainer = domManager.get('managedServiceIncludesContainer'); const managedServiceIncludesContainer = domManager.get('managedServiceIncludesContainer');
if (managedServiceIncludesContainer) { if (managedServiceIncludesContainer) {
// Clear existing content // Clear existing content
managedServiceIncludesContainer.innerHTML = ''; 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 // Add mandatory addons to the managed service includes section
if (mandatoryAddons && mandatoryAddons.length > 0) { if (mandatoryAddons && mandatoryAddons.length > 0) {
mandatoryAddons.forEach(addon => { mandatoryAddons.forEach(addon => {
const addonRow = document.createElement('div'); const addonRow = document.createElement('div');
addonRow.className = 'd-flex justify-content-between small text-muted mb-1'; addonRow.className = 'd-flex justify-content-between small text-muted mb-1';
addonRow.innerHTML = ` addonRow.innerHTML = `
<span><i class="bi bi-check-circle text-success me-1"></i>${addon.name}</span> <span>Add-on: ${addon.name}</span>
<span>CHF ${addon.price}</span> <span>CHF ${addon.price}</span>
`; `;
managedServiceIncludesContainer.appendChild(addonRow); managedServiceIncludesContainer.appendChild(addonRow);
@ -116,10 +126,10 @@ class UIManager {
if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
selectedOptionalAddons.forEach(addon => { selectedOptionalAddons.forEach(addon => {
const addonRow = document.createElement('div'); const addonRow = document.createElement('div');
addonRow.className = 'd-flex justify-content-between mb-2'; addonRow.className = 'd-flex justify-content-between align-items-center mb-2';
addonRow.innerHTML = ` addonRow.innerHTML = `
<span>Add-on: ${addon.name}</span> <span class="text-nowrap flex-shrink-1" style="min-width: 0;">Add-on: ${addon.name}</span>
<span class="fw-bold">CHF ${addon.price}</span> <span class="fw-bold text-nowrap flex-shrink-0" style="min-width: 110px; text-align: right;">CHF ${addon.price}</span>
`; `;
addonPricingContainer.appendChild(addonRow); addonPricingContainer.appendChild(addonRow);
}); });
@ -204,6 +214,29 @@ class UIManager {
// Update the serviceLevelInputs reference // Update the serviceLevelInputs reference
domManager.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); 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 // Update slider maximums based on pricing data
@ -213,23 +246,51 @@ class UIManager {
if (!cpuRange || !memoryRange) return; if (!cpuRange || !memoryRange) return;
const { maxCpus, maxMemory } = pricingDataManager.getSliderMaximums(); const { cpuValues, memoryValues } = pricingDataManager.getAvailableSliderValues();
// Set slider maximums with some padding // Set CPU slider range based on available plan values
if (maxCpus > 0) { if (cpuValues.length > 0) {
cpuRange.min = "0.25"; cpuRange.min = Math.min(...cpuValues);
cpuRange.max = Math.ceil(maxCpus); cpuRange.max = Math.max(...cpuValues);
// Calculate step size - use the smallest difference between consecutive values
const cpuStep = this.calculateOptimalStep(cpuValues);
cpuRange.step = cpuStep;
} }
if (maxMemory > 0) { // Set Memory slider range based on available plan values
memoryRange.min = "0.25"; if (memoryValues.length > 0) {
memoryRange.max = Math.ceil(maxMemory); 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 // Update display values after changing min/max
domManager.updateSliderDisplayValues(); 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 // Update instances slider based on service level and replica info
updateInstancesSlider(domManager, pricingDataManager) { updateInstancesSlider(domManager, pricingDataManager) {
const instancesRange = domManager.get('instancesRange'); const instancesRange = domManager.get('instancesRange');

View file

@ -0,0 +1,105 @@
/**
* 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

@ -0,0 +1,34 @@
# 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

@ -0,0 +1,452 @@
/**
* 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

@ -0,0 +1,452 @@
/**
* 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

@ -0,0 +1,599 @@
/**
* 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

@ -0,0 +1,176 @@
/**
* 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

@ -0,0 +1,634 @@
/**
* 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

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

View file

@ -0,0 +1,749 @@
{% 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

@ -0,0 +1,46 @@
{% 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

@ -0,0 +1,755 @@
{% 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 static %}
{% load contact_tags %} {% load contact_tags %}
{% block title %}About Open Cloud Native Services Hub{% endblock %} {% block title %}About The Sovereign App Store{% endblock %}
{% block content %} {% block content %}
<section class="section bg-primary-subtle"> <section class="section bg-primary-subtle">
@ -10,7 +10,7 @@
<header class="section-primary__header text-center"> <header class="section-primary__header text-center">
<h1 class="section-h1 fs-40 fs-lg-64 mb-24">About Servala</h1> <h1 class="section-h1 fs-40 fs-lg-64 mb-24">About Servala</h1>
<div class="text-gray-300 w-lg-37 mx-auto"> <div class="text-gray-300 w-lg-37 mx-auto">
<p class="mb-0">Open Cloud Native Service Hub. Unlock the Power of Cloud Native Applications.</p> <p class="mb-0">The Sovereign App Store. Unlock the Power of Sovereign Managed Applications.</p>
</div> </div>
</header> </header>
</div> </div>
@ -238,7 +238,7 @@
</div> </div>
<div class="w-lg-30"> <div class="w-lg-30">
<div class="page-header__image-wrapper"> <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 - Open Cloud Native Service Hub" 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 - The Sovereign App Store" title="Sir Vala - Mascot of Servala">
</div> </div>
</div> </div>
</div> </div>
@ -251,4 +251,4 @@
</div> </div>
</div> </div>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -1,7 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% block title %}Open Cloud Native Services Hub{% endblock %} {% block title %}The Sovereign App Store{% endblock %}
{% block content %} {% block content %}
<section class="section section-hero bg-primary-subtle"> <section class="section section-hero bg-primary-subtle">
@ -9,9 +9,9 @@
<div class="section-hero-mask"></div> <div class="section-hero-mask"></div>
<div class="px-3 px-lg-0 pt-80 pb-120 position-relative"> <div class="px-3 px-lg-0 pt-80 pb-120 position-relative">
<header class="section-hero__header"> <header class="section-hero__header">
<h1 class="section-h1 fs-40 fs-lg-64">Servala - Open Cloud Native Service Hub</h1> <h1 class="section-h1 fs-40 fs-lg-64">Sovereign App Store</h1>
<div class="section-hero__desc"> <div class="section-hero__desc">
<p>Unlock the Power of Cloud Native Applications.</p> <p>Unlock the Power of Sovereign Managed 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> <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>
<div> <div>
@ -41,7 +41,7 @@
<div class="row"> <div class="row">
{% for service in featured_services %} {% for service in featured_services %}
<div class="col-12 col-md-6 col-lg-3 mb-20 mb-lg-0"> <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 }}')"> onclick="cardClicked(event, '{{ service.get_absolute_url }}')">
<div class="card__content d-flex flex-column flex-grow-1"> <div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header"> <div class="card__header">
@ -49,8 +49,8 @@
<div class="me-3 d-flex align-items-center" style="height: 100%;"> <div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ service.get_absolute_url }}" class="clickable-link"> <a href="{{ service.get_absolute_url }}" class="clickable-link">
{% if service.get_logo %} {% if service.get_logo %}
<img src="{{ service.get_logo.url }}" <img src="{{ service.get_logo.url }}"
alt="{{ service.name }}" alt="{{ service.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;"> style="max-height: 100px; max-width: 250px; object-fit: contain;">
{% else %} {% 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;"> <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;">
@ -104,7 +104,7 @@
<div class="row"> <div class="row">
{% for provider in featured_providers %} {% for provider in featured_providers %}
<div class="col-12 col-md-6 col-lg-3 mb-20 mb-lg-0"> <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 }}')"> onclick="cardClicked(event, '{{ provider.get_absolute_url }}')">
<div class="card__content d-flex flex-column flex-grow-1"> <div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header"> <div class="card__header">
@ -112,8 +112,8 @@
{% if provider.get_logo %} {% if provider.get_logo %}
<div class="me-3 d-flex align-items-center" style="height: 100%;"> <div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ provider.get_absolute_url }}" class="clickable-link"> <a href="{{ provider.get_absolute_url }}" class="clickable-link">
<img src="{{ provider.get_logo.url }}" <img src="{{ provider.get_logo.url }}"
alt="{{ provider.name }}" alt="{{ provider.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;"> style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a> </a>
</div> </div>
@ -160,7 +160,7 @@
<div class="row"> <div class="row">
{% for partner in featured_partners %} {% for partner in featured_partners %}
<div class="col-12 col-md-6 col-lg-3 mb-20 mb-lg-0"> <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 }}')"> onclick="cardClicked(event, '{{ partner.get_absolute_url }}')">
<div class="card__content d-flex flex-column flex-grow-1"> <div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header"> <div class="card__header">
@ -168,8 +168,8 @@
{% if partner.get_logo %} {% if partner.get_logo %}
<div class="me-3 d-flex align-items-center" style="height: 100%;"> <div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ partner.get_absolute_url }}" class="clickable-link"> <a href="{{ partner.get_absolute_url }}" class="clickable-link">
<img src="{{ partner.get_logo.url }}" <img src="{{ partner.get_logo.url }}"
alt="{{ partner.name }}" alt="{{ partner.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;"> style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a> </a>
</div> </div>
@ -208,7 +208,7 @@
</div> </div>
<div class="col-12 col-lg-8"> <div class="col-12 col-lg-8">
<header class="section-primary__header"> <header class="section-primary__header">
<h2 class="section-h1 fs-40 fs-lg-60">Servala - Open Cloud Native Service Hub</h2> <h2 class="section-h1 fs-40 fs-lg-60">Servala - The Sovereign App Store</h2>
<div class="section-primary__desc"> <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>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> <p>Discover:</p>
@ -257,4 +257,4 @@
</div> </div>
</div> </div>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -6,6 +6,11 @@
{% block meta_description %}{{ article.excerpt }}{% endblock %} {% block meta_description %}{{ article.excerpt }}{% endblock %}
{% block meta_keywords %}{{ article.meta_keywords }}{% 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 %} {% block content %}
<section class="section bg-primary-subtle"> <section class="section bg-primary-subtle">
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60"> <div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">

View file

@ -5,13 +5,29 @@
{% block title %}Articles{% endblock %} {% block title %}Articles{% endblock %}
{% block meta_description %}Explore all articles on Servala, covering cloud services, consulting partners, and cloud provider insights.{% 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 %} {% block content %}
<section class="section bg-primary-subtle"> <section class="section bg-primary-subtle">
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60"> <div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
<header class="section-primary__header text-center"> <header class="section-primary__header text-center">
<h1 class="section-h1 fs-40 fs-lg-64 mb-24">Articles</h1> <h1 class="section-h1 fs-40 fs-lg-64 mb-24">Articles</h1>
<div class="text-gray-300 w-lg-37 mx-auto"> <div class="text-gray-300 w-lg-37 mx-auto">
<p class="mb-0">Discover insights, guides, and updates about cloud services, consulting partners, and technology trends.</p> <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>
</div> </div>
</header> </header>
</div> </div>

View file

@ -9,7 +9,47 @@
{% block extra_js %} {% block extra_js %}
{% if debug %} {% if debug %}
<!-- Development: Load individual modules for easier debugging --> <!-- Development: Load individual modules for easier debugging -->
<script defer src="{% static 'js/price-calculator.js' %}"></script> <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 %} {% else %}
<!-- Production: Load compressed bundle --> <!-- Production: Load compressed bundle -->
{% compress js %} {% compress js %}
@ -25,9 +65,34 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Check if we're on a page that needs the price calculator // Check if we're on a page that needs the price calculator
if (document.getElementById('cpuRange')) { if (document.getElementById('cpuRange')) {
window.priceCalculator = new PriceCalculator(); 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> </script>
{% endcompress %} {% endcompress %}
{% endif %} {% endif %}
@ -375,22 +440,18 @@ document.addEventListener('DOMContentLoaded', () => {
<!-- Managed Service Section --> <!-- Managed Service Section -->
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center flex-shrink-1" style="min-width: 0; max-width: calc(100% - 120px);">
<span>Managed Service (incl. Compute)</span> <span class="text-nowrap me-1">Managed Service</span>
<button class="btn btn-link btn-sm p-0 ms-2 text-muted" type="button" data-bs-toggle="collapse" data-bs-target="#managedServiceIncludes" aria-expanded="false" aria-controls="managedServiceIncludes" title="Show what's included"> <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> <i class="bi bi-info-circle" id="managedServiceToggleIcon"></i>
</button> </button>
</div> </div>
<span class="fw-bold">CHF <span id="managedServicePrice">0.00</span></span> <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> </div>
<!-- What's included in managed service (collapsible) --> <!-- What's included in managed service (collapsible) -->
<div class="collapse" id="managedServiceIncludes"> <div class="collapse" id="managedServiceIncludes">
<div class="ps-3 border-start border-2 border-success-subtle"> <div class="ps-3 border-start border-2 border-subtle">
<div class="small text-muted mb-2">
<i class="bi bi-check-circle-fill text-success me-1"></i>
<em>Included in managed service price:</em>
</div>
<div id="managedServiceIncludesContainer"> <div id="managedServiceIncludesContainer">
<!-- Required add-ons will be dynamically added here --> <!-- Required add-ons will be dynamically added here -->
</div> </div>
@ -399,9 +460,9 @@ document.addEventListener('DOMContentLoaded', () => {
</div> </div>
<!-- Storage - separate billable item --> <!-- Storage - separate billable item -->
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<span>Storage - <span id="storageAmount">20</span> GB</span> <span class="text-nowrap flex-shrink-1" style="min-width: 0;">Storage - <span id="storageAmount">20</span> GB</span>
<span class="fw-bold">CHF <span id="storagePrice">0.00</span></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> </div>
<!-- Optional Addons Pricing --> <!-- Optional Addons Pricing -->
@ -410,9 +471,9 @@ document.addEventListener('DOMContentLoaded', () => {
</div> </div>
<hr> <hr>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between align-items-center">
<span class="fs-5 fw-bold">Total Monthly Price</span> <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">CHF <span id="totalPrice">0.00</span></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> </div>
<small class="text-muted mt-2 d-block"> <small class="text-muted mt-2 d-block">
<i class="bi bi-info-circle me-1"></i> <i class="bi bi-info-circle me-1"></i>

View file

@ -86,7 +86,7 @@
{% if partner.address %} {% if partner.address %}
<li> <li>
<div class="d-flex align-items-start text-gray-500 h-32 lh-32"> <div class="d-flex align-items-start text-gray-500 lh-32">
<span class="pr-10 pt-1"> <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"> <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"/> <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"/>
@ -157,18 +157,18 @@
<div class="row"> <div class="row">
{% for service in services %} {% for service in services %}
<div class="col-12 col-md-6 mb-30"> <div class="col-12 col-md-6 mb-30">
<div class="card h-100 d-flex flex-column"> <div class="card h-100 d-flex flex-column clickable-card"
<div class="card__content d-flex flex-column flex-grow-1"> onclick="cardClicked(event, '{{ service.get_absolute_url }}')">
{% if service.get_logo %} {% if service.get_logo %}
<div class="d-flex align-items-center justify-content-start" style="height: 60px; margin-bottom: 1rem; width: 100%;"> <div class="d-flex justify-content-between mb-3">
<a href="{{ service.get_absolute_url }}" class="clickable-link" style="display: block; width: 120px; height: 60px;"> <div class="card__image flex-shrink-0">
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" <img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
style="width: 100%; height: 100%; object-fit: contain; object-position: left center; display: block;">
</a>
</div> </div>
{% endif %} </div>
{% endif %}
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header"> <div class="card__header">
<h3 class="card__title"><a href="{{ service.get_absolute_url }}" class="text-decoration-none">{{ service.name }}</a></h3> <h3 class="card__title">{{ service.name }}</h3>
<p class="card__subtitle"> <p class="card__subtitle">
{% for category in service.categories.all %} {% for category in service.categories.all %}
<span>{{ category.full_path }}</span> <span>{{ category.full_path }}</span>

View file

@ -5,7 +5,7 @@
{% block title %}Complete Price List{% endblock %} {% block title %}Complete Price List{% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="{% static "js/chart.js" %}"></script> <script src="{% static "js/chart.umd.min.js" %}"></script>
{% endblock %} {% endblock %}
{% block extra_css %} {% block extra_css %}

View file

@ -53,7 +53,7 @@ def json_ld_structured_data(context):
data = { data = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
"name": "Servala - Open Cloud Native Service Hub", "name": "Servala - The Sovereign App Store",
"url": base_url, "url": base_url,
} }
json_ld = json.dumps(data, indent=2) json_ld = json.dumps(data, indent=2)
@ -65,7 +65,7 @@ def json_ld_structured_data(context):
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
"name": "Servala - Open Cloud Native Service Hub", "name": "Servala - The Sovereign App Store",
"url": base_url, "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.", "description": "Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
"potentialAction": { "potentialAction": {
@ -217,87 +217,80 @@ def json_ld_structured_data(context):
offering = context["offering"] offering = context["offering"]
offering_url = request.build_absolute_uri() offering_url = request.build_absolute_uri()
data = { # Check if we have pricing data available
"@context": "https://schema.org", has_pricing_data = False
"@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)
# Add offers if available
if hasattr(offering, "plans") and offering.plans.exists(): if hasattr(offering, "plans") and offering.plans.exists():
# Get all plans with pricing # Get all plans with pricing
plans_with_prices = offering.plans.filter( plans_with_prices = offering.plans.filter(
plan_prices__isnull=False plan_prices__isnull=False
).distinct() ).distinct()
has_pricing_data = plans_with_prices.exists()
if plans_with_prices.exists(): if has_pricing_data:
# Create individual offers for each plan # Use Product type with complete pricing information
offers = [] data = {
all_prices = [] "@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",
}
for plan in plans_with_prices: # Add brand (service)
plan_prices = plan.plan_prices.all() data["brand"] = {"@type": "Brand", "name": offering.service.name}
if plan_prices.exists():
first_price = plan_prices.first()
all_prices.extend([p.amount for p in plan_prices])
offer = { # Add image if available
"@type": "Offer", if hasattr(offering.service, "get_logo") and offering.service.get_logo:
"name": plan.name, data["image"] = request.build_absolute_uri(
"price": str(first_price.amount), offering.service.get_logo.url
"priceCurrency": first_price.currency, )
"availability": "https://schema.org/InStock",
"url": offering_url + "#plan-order-form", # Create individual offers for each plan with pricing
"seller": {"@type": "Organization", "name": "VSHN"}, offers = []
} all_prices = []
offers.append(offer)
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
# Add aggregate offer with all individual offers
data["offers"] = { data["offers"] = {
"@type": "AggregateOffer", "@type": "AggregateOffer",
"availability": "https://schema.org/InStock", "availability": "https://schema.org/InStock",
"offerCount": len(offers), "offerCount": len(offers),
"offers": offers, "offers": offers,
"lowPrice": str(min(all_prices)),
"highPrice": str(max(all_prices)),
"priceCurrency": first_currency,
"seller": {"@type": "Organization", "name": "VSHN"}, "seller": {"@type": "Organization", "name": "VSHN"},
} }
# Add lowPrice, highPrice and priceCurrency if we have prices
if all_prices:
data["offers"]["lowPrice"] = str(min(all_prices))
data["offers"]["highPrice"] = str(max(all_prices))
# 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"]["priceCurrency"] = first_currency
# Note: aggregateRating and review fields are not included as this is a B2B # 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 # service marketplace without a review system. These could be added in the future
# if customer reviews/ratings are implemented. # if customer reviews/ratings are implemented.
# Example structure for future implementation: else:
# if hasattr(offering, 'reviews') and offering.reviews.exists(): # No pricing data available - use Organization data instead of Product
# data["aggregateRating"] = { # to avoid Google Search Console errors for missing required Product fields
# "@type": "AggregateRating", data = organization_data
# "ratingValue": "4.5",
# "reviewCount": "10"
# }
else:
# No pricing available, just basic offer info
data["offers"] = {
"@type": "AggregateOffer",
"availability": "https://schema.org/InStock",
"offerCount": offering.plans.count(),
"seller": {"@type": "Organization", "name": "VSHN"},
}
elif view_name == "article_list": elif view_name == "article_list":
data = { data = {
@ -360,12 +353,7 @@ def json_ld_structured_data(context):
} }
# Add about field based on related entities # Add about field based on related entities
if article.related_service: if article.related_consulting_partner:
data["about"] = {
"@type": "SoftwareApplication",
"name": article.related_service.name,
}
elif article.related_consulting_partner:
data["about"] = { data["about"] = {
"@type": "Organization", "@type": "Organization",
"name": article.related_consulting_partner.name, "name": article.related_consulting_partner.name,

View file

@ -1,5 +1,6 @@
from django.urls import path from django.urls import path
from . import views from . import views
from .feeds import ArticleRSSFeed
app_name = "services" app_name = "services"
@ -19,6 +20,7 @@ urlpatterns = [
path("provider/<slug:slug>/", views.provider_detail, name="provider_detail"), path("provider/<slug:slug>/", views.provider_detail, name="provider_detail"),
path("partner/<slug:slug>/", views.partner_detail, name="partner_detail"), path("partner/<slug:slug>/", views.partner_detail, name="partner_detail"),
path("articles/", views.article_list, name="article_list"), 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("article/<slug:slug>/", views.article_detail, name="article_detail"),
path("contact/", views.leads.contact, name="contact"), path("contact/", views.leads.contact, name="contact"),
path("contact/thank-you/", views.thank_you, name="thank_you"), path("contact/thank-you/", views.thank_you, name="thank_you"),
@ -29,4 +31,14 @@ urlpatterns = [
views.pricelist, views.pricelist,
name="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

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

View file

@ -0,0 +1,106 @@
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

@ -190,7 +190,7 @@ def generate_exoscale_marketplace_yaml(offering):
yaml_key: { yaml_key: {
"page_class": "tmpl-marketplace-product", "page_class": "tmpl-marketplace-product",
"html_title": title, "html_title": title,
"meta_desc": f"Managed {offering.service.name} by Servala - a product by VSHN. 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.", "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, "page_header_title": title,
"provider_key": "vshn", "provider_key": "vshn",
"slug": f"{offering.service.slug}-by-servala", "slug": f"{offering.service.slug}-by-servala",

View file

@ -238,6 +238,9 @@ ODOO_CONFIG = {
"mailing_list_id": env.int("ODOO_MAILING_LIST_ID", default=46), "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_USERNAME = env.str("BROKER_USERNAME", default="broker")
BROKER_PASSWORD = env.str("BROKER_PASSWORD", default="secret") BROKER_PASSWORD = env.str("BROKER_PASSWORD", default="secret")
BASE_URL = "https://your-domain.com" BASE_URL = "https://your-domain.com"

View file

@ -5,6 +5,7 @@ from hub.services.models import (
CloudProvider, CloudProvider,
ConsultingPartner, ConsultingPartner,
ServiceOffering, ServiceOffering,
Article,
) )
@ -13,7 +14,12 @@ class StaticSitemap(Sitemap):
priority = 1.0 priority = 1.0
def items(self): def items(self):
return ["services:homepage", "services:contact"] return [
"services:homepage",
"services:contact",
"services:article_list",
"services:article_rss",
]
def location(self, item): def location(self, item):
return reverse(item) return reverse(item)
@ -60,3 +66,14 @@ class ConsultingPartnerSitemap(Sitemap):
def lastmod(self, obj): def lastmod(self, obj):
return obj.updated_at 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

View file

@ -16,6 +16,7 @@ from .sitemaps import (
OfferingSitemap, OfferingSitemap,
CloudProviderSitemap, CloudProviderSitemap,
ConsultingPartnerSitemap, ConsultingPartnerSitemap,
ArticleSitemap,
) )
@ -37,12 +38,13 @@ sitemaps = {
"offerings": OfferingSitemap, "offerings": OfferingSitemap,
"providers": CloudProviderSitemap, "providers": CloudProviderSitemap,
"partners": ConsultingPartnerSitemap, "partners": ConsultingPartnerSitemap,
"articles": ArticleSitemap,
} }
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("", include("hub.services.urls")), path("", include("hub.services.urls")),
path("broker/", include("hub.broker.urls", namespace="broker")), # path("broker/", include("hub.broker.urls", namespace="broker")),
path( path(
"sitemap.xml", "sitemap.xml",
sitemap, sitemap,