Compare commits
1 commit
main
...
plan-prici
| Author | SHA1 | Date | |
|---|---|---|---|
| 61cabd1b1e |
103 changed files with 1655 additions and 12430 deletions
|
|
@ -5,7 +5,6 @@ ODOO_USERNAME=CHANGEME
|
|||
ODOO_PASSWORD=CHANGEME
|
||||
BROKER_USERNAME=broker
|
||||
BROKER_PASSWORD=CHANGEME
|
||||
CSP_CALCULATOR_PASSWORD=servala2025
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
SECRET_KEY="django-insecure-CHANGEME"
|
||||
ODOO_LEAD_CAMPAIGN_ID=6
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ name: Django Tests
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ["*"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
|
@ -31,4 +31,4 @@ jobs:
|
|||
-w /app \
|
||||
-e SECRET_KEY=dummysecretkey \
|
||||
website:test \
|
||||
sh -c 'uv run --extra dev manage.py migrate --noinput && uv run --extra dev manage.py test hub.services.tests --verbosity=2'
|
||||
sh -c 'uv run --extra dev manage.py migrate --noinput && uv run --extra dev manage.py test hub.services.tests --verbosity=2'
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -14,5 +14,4 @@ wheels/
|
|||
*.sqlite3
|
||||
media/
|
||||
deployment/secret.yaml
|
||||
/*.json
|
||||
/static/
|
||||
*.json
|
||||
|
|
|
|||
113
CLAUDE.md
113
CLAUDE.md
|
|
@ -1,113 +0,0 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a Django website for servala.com, built with Python 3.13+ using uv for dependency management. The project structure follows Django conventions with a main `hub` application containing multiple services.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Local Development Setup
|
||||
```bash
|
||||
cp .env.example .env
|
||||
source .env
|
||||
uv run --extra dev manage.py migrate
|
||||
uv run --extra dev manage.py runserver
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
```bash
|
||||
uv run --extra dev manage.py migrate
|
||||
uv run --extra dev manage.py makemigrations
|
||||
uv run --extra dev manage.py createsuperuser
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run --extra dev manage.py test
|
||||
|
||||
# Run specific pricing tests (comprehensive suite available)
|
||||
./run_pricing_tests.sh
|
||||
|
||||
# Run specific test modules
|
||||
uv run --extra dev manage.py test hub.services.tests.test_pricing --verbosity=2
|
||||
uv run --extra dev manage.py test hub.services.tests.test_pricing_edge_cases --verbosity=2
|
||||
uv run --extra dev manage.py test hub.services.tests.test_pricing_integration --verbosity=2
|
||||
```
|
||||
|
||||
### Asset Management
|
||||
```bash
|
||||
uv run --extra dev manage.py build_assets
|
||||
uv run --extra dev manage.py collectstatic
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Core Django App Structure
|
||||
- `hub/` - Main Django application
|
||||
- `services/` - Core business logic with multiple domains:
|
||||
- `models/` - Database models organized by domain (articles, pricing, providers, services, etc.)
|
||||
- `views/` - View logic organized by feature
|
||||
- `forms/` - Form classes for user input
|
||||
- `admin/` - Django admin customizations
|
||||
- `tests/` - Comprehensive test suite, especially for pricing logic
|
||||
- `broker/` - Separate app for broker-related functionality
|
||||
- `middleware.py` - Custom middleware
|
||||
- `settings.py` - Django configuration using environs for environment variables
|
||||
|
||||
### Key Features
|
||||
- **Pricing Engine**: Complex pricing calculations with multiple models (ComputePlan, StoragePlan, etc.)
|
||||
- **Content Management**: Articles, services, providers with image library support
|
||||
- **Lead Generation**: Contact forms and lead management
|
||||
- **Partner System**: Cloud providers and consulting partners
|
||||
- **Price Calculator**: Interactive frontend calculator with ROI calculations
|
||||
|
||||
### Frontend Assets
|
||||
- Static files in `hub/services/static/`
|
||||
- JavaScript organized by feature:
|
||||
- `price-calculator/` - Modular price calculator components
|
||||
- `roi-calculator/` - Modular ROI calculator with separate concerns (core, UI, charts, exports)
|
||||
- CSS using Bootstrap 5 with custom styling
|
||||
- Chart.js for data visualization
|
||||
|
||||
### Database
|
||||
- Uses Django ORM with extensive migrations in `hub/services/migrations/`
|
||||
- SQLite for development and production (`db.sqlite3`)
|
||||
- Media files stored in `media/` with organized subdirectories
|
||||
|
||||
### Deployment
|
||||
- Docker specific code is in the folder docker/
|
||||
- Kubernetes deployment specific files in deployment/
|
||||
- GitLab CI is used as the main CI/CD system
|
||||
- Forgejo Actions is the secondary CI/CD system
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Environment Configuration
|
||||
- Uses `environs` library for environment variable management
|
||||
- Requires `.env` file for local development (copy from `.env.example`)
|
||||
- Key settings: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`
|
||||
|
||||
### Testing Strategy
|
||||
- Extensive pricing test suite with edge cases and integration tests
|
||||
- Use `--keepdb` flag for faster test runs during development
|
||||
- Dedicated test runner script for pricing functionality
|
||||
|
||||
### Asset Pipeline
|
||||
- Django Compressor for CSS/JS optimization
|
||||
- Static files collection required for production
|
||||
- Custom management commands for asset building
|
||||
|
||||
### Image Management
|
||||
- Comprehensive image library system with SVG support
|
||||
- Organized media directories for different content types
|
||||
- Custom template tags for image handling
|
||||
|
||||
### Django specifics
|
||||
- Use function-based views and follow the Django conventions for naming and structuring views
|
||||
- Templates use the Django template language
|
||||
|
||||
### Claude Code specific
|
||||
- Use context7 to get up-to-date documentation about Bootstrap, Python and Django
|
||||
|
|
@ -35,6 +35,6 @@ RUN uv sync --frozen \
|
|||
&& chgrp -R 0 /app \
|
||||
&& chmod -R g=u /app \
|
||||
&& chmod g+w /app/config/caddy/Caddyfile \
|
||||
&& SECRET_KEY=dummy python -m hub build_assets --force
|
||||
&& SECRET_KEY= python -m hub collectstatic --noinput
|
||||
|
||||
CMD ["/usr/local/bin/runhub.sh"]
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
# Image Library Migration Status
|
||||
|
||||
## ✅ COMPLETED (First Production Rollout) - UPDATED
|
||||
|
||||
### Models Updated
|
||||
- **Article**: Now inherits from `ImageReference`, with `image_library` field for new images and original `image` field temporarily
|
||||
- **CloudProvider**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
|
||||
- **ConsultingPartner**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
|
||||
- **Service**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
|
||||
|
||||
### New Properties Added
|
||||
- `Article.get_image()` - Returns image from library or falls back to original field
|
||||
- `CloudProvider.get_logo()` - Returns logo from library or falls back to original field
|
||||
- `ConsultingPartner.get_logo()` - Returns logo from library or falls back to original field
|
||||
- `Service.get_logo()` - Returns logo from library or falls back to original field
|
||||
|
||||
### Templates Updated
|
||||
- ✅ `pages/homepage.html` - Updated service, provider, and partner image references
|
||||
- ✅ `services/article_list.html` - Updated article image references
|
||||
- ✅ `services/article_detail.html` - Updated related service/provider/partner logos
|
||||
- ✅ `services/offering_list.html` - Updated service and provider logos
|
||||
- ✅ `services/offering_detail.html` - Updated service and provider logos
|
||||
- ✅ `services/lead_form.html` - Updated service logo
|
||||
- ✅ `services/partner_detail.html` - Updated partner and service logos
|
||||
- ✅ `services/partner_list.html` - Updated partner logos
|
||||
- ✅ `services/provider_list.html` - Updated provider logos
|
||||
- ✅ `services/provider_detail.html` - Updated provider and service logos
|
||||
- ✅ `services/service_detail.html` - Updated service and provider logos
|
||||
|
||||
### Admin Interface Updated
|
||||
- ✅ `ArticleAdmin` - Updated image_preview to use get_image property
|
||||
- ✅ `ServiceAdmin` - Updated logo_preview to use get_logo property
|
||||
- ✅ `CloudProviderAdmin` - Updated logo_preview to use get_logo property
|
||||
- ✅ `ConsultingPartnerAdmin` - Updated logo_preview to use get_logo property
|
||||
|
||||
### JSON-LD Template Tags Updated
|
||||
- ✅ Updated structured data generation to use new image properties
|
||||
- ✅ Updated logo references for services, providers, and partners
|
||||
|
||||
### Database Migration
|
||||
- ✅ Migration `0041_add_image_library_references` successfully applied
|
||||
- ✅ Migration `0042_fix_image_library_field_name` successfully applied
|
||||
- ✅ All models now have `image_library` foreign key fields to ImageLibrary
|
||||
- ✅ Original image fields preserved for backward compatibility
|
||||
- ✅ Fixed field name conflicts using `%(class)s_references` related_name pattern
|
||||
|
||||
### Admin Interface Enhanced
|
||||
- ✅ **ArticleAdmin**: Added fieldsets with `image_library` field visible in "Images" section
|
||||
- ✅ **ServiceAdmin**: Added fieldsets with `image_library` field visible in "Images" section
|
||||
- ✅ **CloudProviderAdmin**: Added fieldsets with `image_library` field visible in "Images" section
|
||||
- ✅ **ConsultingPartnerAdmin**: Added fieldsets with `image_library` field visible in "Images" section
|
||||
- ✅ All admin interfaces show both new and legacy fields during transition
|
||||
- ✅ Clear descriptions guide users to use Image Library for new images
|
||||
|
||||
## Current Status
|
||||
The system is now ready for production with dual image support:
|
||||
- **New images**: Can be added through the Image Library
|
||||
- **Legacy images**: Still work through the original fields
|
||||
- **Templates**: Use the new `get_image/get_logo` properties that automatically fall back
|
||||
|
||||
## Next Steps (Future Cleanup)
|
||||
1. **Data Migration**: Create script to migrate existing images to ImageLibrary
|
||||
2. **Admin Updates**: Update admin interfaces to use ImageLibrary selection
|
||||
3. **Template Validation**: Add null checks to remaining templates
|
||||
4. **Field Removal**: Remove legacy image fields after migration is complete
|
||||
5. **Storage Cleanup**: Remove old image files from media directories
|
||||
|
||||
## Benefits Achieved
|
||||
- ✅ Centralized image management through ImageLibrary
|
||||
- ✅ Usage tracking for images
|
||||
- ✅ Backward compatibility maintained
|
||||
- ✅ Enhanced admin experience ready
|
||||
- ✅ Consistent image handling across all models
|
||||
- ✅ Proper fallback mechanisms in place
|
||||
|
||||
## Safety Measures
|
||||
- ✅ Original image fields preserved
|
||||
- ✅ Gradual migration approach
|
||||
- ✅ Fallback properties ensure no broken images
|
||||
- ✅ Database migration tested and applied
|
||||
- ✅ Admin interface maintains functionality
|
||||
|
|
@ -1,342 +0,0 @@
|
|||
# Servala Investment Models: Comprehensive Guide
|
||||
|
||||
This document provides detailed information about Servala's two investment models for Cloud Service Providers (CSPs) looking to expand their service portfolio through partnership with Servala's managed services platform.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Servala offers two distinct investment approaches for CSPs:
|
||||
|
||||
1. **Loan Model**: Low-risk, predictable returns through lending to Servala
|
||||
2. **Direct Investment Model**: Higher potential returns through performance-based revenue sharing
|
||||
|
||||
Both models enable CSPs to capitalize on the growing demand for managed cloud services while supporting Servala's platform expansion.
|
||||
|
||||
---
|
||||
|
||||
## Investment Model #1: Loan Model
|
||||
|
||||
### Overview
|
||||
The Loan Model provides CSPs with a traditional lending arrangement where they provide capital to Servala at a fixed interest rate, receiving guaranteed monthly payments regardless of business performance.
|
||||
|
||||
### Key Characteristics
|
||||
|
||||
#### Financial Structure
|
||||
- **Investment Range**: CHF 100,000 - CHF 2,000,000
|
||||
- **Interest Rates**: Typically 3-7% annually (negotiable)
|
||||
- **Payment Schedule**: Fixed monthly payments using standard amortization
|
||||
- **Term Length**: 1-5 years (customizable)
|
||||
|
||||
#### Payment Calculation
|
||||
Monthly payments are calculated using the standard amortization formula:
|
||||
```
|
||||
Monthly Payment = P × [r(1+r)^n] / [(1+r)^n - 1]
|
||||
Where:
|
||||
- P = Principal loan amount
|
||||
- r = Monthly interest rate (annual rate ÷ 12)
|
||||
- n = Total number of payments (years × 12)
|
||||
```
|
||||
|
||||
#### Revenue Distribution
|
||||
- **CSP**: 100% of monthly loan payments
|
||||
- **Servala**: 0% (receives loan capital for operations)
|
||||
|
||||
#### Break-Even Analysis
|
||||
- **Break-even occurs**: When cumulative payments received equal the original loan amount
|
||||
- **Typical timeline**: 12-18 months depending on interest rate and term
|
||||
- **Risk level**: Very low - payments are contractually guaranteed
|
||||
|
||||
### Advantages for CSPs
|
||||
- **Predictable cash flow**: Fixed monthly income regardless of market conditions
|
||||
- **Low risk**: Not dependent on Servala's business performance
|
||||
- **Simple structure**: Traditional lending arrangement with clear terms
|
||||
- **Principal protection**: Loan principal is protected by Servala's assets and business
|
||||
|
||||
### Considerations
|
||||
- **Limited upside**: Returns are capped at the agreed interest rate
|
||||
- **No performance benefits**: CSP doesn't benefit from exceptional business growth
|
||||
- **Market rate dependency**: Returns may lag behind high-performing investment alternatives
|
||||
|
||||
---
|
||||
|
||||
## Investment Model #2: Direct Investment Model
|
||||
|
||||
### Overview
|
||||
The Direct Investment Model allows CSPs to invest directly in Servala's platform operations, earning returns through revenue sharing that scales with business performance and their active involvement in sales.
|
||||
|
||||
### Key Characteristics
|
||||
|
||||
#### Financial Structure
|
||||
- **Investment Range**: CHF 100,000 - CHF 2,000,000
|
||||
- **Revenue Sharing**: 15-35% to Servala (remainder to CSP)
|
||||
- **Grace Period**: 6+ months of 100% revenue retention for CSP
|
||||
- **Performance Bonuses**: Up to 15% additional revenue share for high performers
|
||||
|
||||
#### Investment Scaling Benefits
|
||||
Direct investments unlock operational advantages through progressive scaling:
|
||||
|
||||
| Investment Amount | Scaling Factor | Customer Acquisition | Churn Reduction |
|
||||
|------------------|----------------|----------------------|-----------------|
|
||||
| CHF 500,000 | 1.0x | Baseline | 0% |
|
||||
| CHF 750,000 | 1.25x | +25% vs baseline | 10% |
|
||||
| CHF 1,000,000 | 1.5x | +50% vs baseline | 20% |
|
||||
| CHF 1,500,000 | 1.75x | +75% vs baseline | 30% |
|
||||
| CHF 2,000,000 | 2.0x | +100% vs baseline | 40% |
|
||||
|
||||
#### Dynamic Grace Period
|
||||
Grace periods extend with larger investments, providing longer periods of 100% revenue retention:
|
||||
|
||||
- **Base Grace Period**: 6 months
|
||||
- **Extension Formula**: +1 month per CHF 250,000 invested above CHF 500,000
|
||||
- **Maximum Grace Period**: 50% of total investment timeframe
|
||||
|
||||
**Examples:**
|
||||
- CHF 500,000 investment → 6 months grace period
|
||||
- CHF 1,000,000 investment → 8 months grace period
|
||||
- CHF 2,000,000 investment → 12 months grace period
|
||||
|
||||
#### Performance-Based Revenue Split
|
||||
CSPs who exceed baseline performance expectations receive enhanced revenue sharing:
|
||||
|
||||
- **Performance Threshold**: 110% of baseline instance growth
|
||||
- **Bonus Calculation**: Up to 15% reduction in Servala's revenue share
|
||||
- **Bonus Formula**: `Bonus = min(15%, (Performance Ratio - 1.1) × 30%)`
|
||||
- **Minimum Servala Share**: 10% (maximum bonus protection)
|
||||
|
||||
### Revenue Distribution Phases
|
||||
|
||||
#### Phase 1: Grace Period (Months 1-N)
|
||||
- **CSP**: 100% of monthly revenue
|
||||
- **Servala**: 0%
|
||||
- **Duration**: Based on investment amount (6-12 months)
|
||||
|
||||
#### Phase 2: Standard Revenue Sharing (Post-Grace Period)
|
||||
- **CSP**: 65-90% of monthly revenue (depending on negotiated split)
|
||||
- **Servala**: 10-35% of monthly revenue
|
||||
- **Performance bonuses**: Applied based on CSP sales results
|
||||
|
||||
#### Phase 3: Performance Enhancement (Ongoing)
|
||||
- **Additional bonuses**: Available for CSPs exceeding 110% of baseline performance
|
||||
- **Reduced Servala share**: Performance bonuses reduce Servala's percentage
|
||||
- **Incentive alignment**: Rewards active sales participation by CSP
|
||||
|
||||
### Break-Even Analysis
|
||||
Break-even for direct investment depends on multiple factors:
|
||||
|
||||
#### Conservative Scenario (2% monthly churn)
|
||||
- **Typical break-even**: 18-24 months
|
||||
- **Factors**: Lower customer acquisition, baseline performance
|
||||
- **Risk level**: Moderate
|
||||
|
||||
#### Moderate Scenario (3% monthly churn)
|
||||
- **Typical break-even**: 15-20 months
|
||||
- **Factors**: Balanced growth and churn rates
|
||||
- **Risk level**: Moderate-High
|
||||
|
||||
#### Aggressive Scenario (5% monthly churn)
|
||||
- **Typical break-even**: 12-18 months (if performance targets met)
|
||||
- **Factors**: High growth potential, requires active CSP involvement
|
||||
- **Risk level**: Higher, but potentially higher returns
|
||||
|
||||
### Advantages for CSPs
|
||||
- **Unlimited upside potential**: Returns scale with business success
|
||||
- **Performance incentives**: Bonuses for exceeding expectations
|
||||
- **Extended grace periods**: Larger investments get longer 100% revenue periods
|
||||
- **Operational scaling**: Investment size directly improves business outcomes
|
||||
- **Market expansion**: Direct involvement in growing managed services market
|
||||
|
||||
### Considerations
|
||||
- **Performance dependency**: Returns vary based on business performance
|
||||
- **Active participation beneficial**: Better results when CSP actively promotes services
|
||||
- **Market risk**: Subject to cloud services market fluctuations
|
||||
- **Longer break-even periods**: Typically requires 15-24 months to break even
|
||||
|
||||
---
|
||||
|
||||
## Comparative Analysis
|
||||
|
||||
### When to Choose Loan Model
|
||||
|
||||
**Ideal for CSPs who:**
|
||||
- Prioritize predictable, guaranteed returns
|
||||
- Have limited capacity for active sales involvement
|
||||
- Prefer traditional lending relationships
|
||||
- Want to minimize investment risk
|
||||
- Need steady cash flow for other operations
|
||||
|
||||
**Financial Profile:**
|
||||
- Risk tolerance: Low
|
||||
- Return expectations: 3-7% annually
|
||||
- Time horizon: 1-3 years
|
||||
- Involvement level: Passive
|
||||
|
||||
### When to Choose Direct Investment Model
|
||||
|
||||
**Ideal for CSPs who:**
|
||||
- Want to maximize return potential
|
||||
- Can actively promote and sell managed services
|
||||
- Are comfortable with performance-based returns
|
||||
- Seek strategic partnership opportunities
|
||||
- Have longer investment horizons
|
||||
|
||||
**Financial Profile:**
|
||||
- Risk tolerance: Moderate to High
|
||||
- Return expectations: 15-40% annually (performance dependent)
|
||||
- Time horizon: 2-5 years
|
||||
- Involvement level: Active
|
||||
|
||||
### ROI Comparison Examples
|
||||
|
||||
#### CHF 1,000,000 Investment Over 3 Years
|
||||
|
||||
**Loan Model (5% annual rate):**
|
||||
- Monthly payment: ~CHF 30,000
|
||||
- Total return: CHF 1,080,000
|
||||
- Net profit: CHF 80,000
|
||||
- ROI: 8% over 3 years
|
||||
|
||||
**Direct Investment Model (Moderate Scenario):**
|
||||
- Year 1: CHF 120,000 (with 8-month grace period)
|
||||
- Year 2: CHF 180,000 (with performance bonuses)
|
||||
- Year 3: CHF 240,000 (mature customer base)
|
||||
- Total return: CHF 540,000
|
||||
- Net profit: CHF 540,000 - CHF 1,000,000 = break-even in month 22
|
||||
- ROI: 35% over 3 years (if targets met)
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Loan Model Risks
|
||||
- **Interest rate risk**: Fixed rates may underperform market
|
||||
- **Opportunity cost**: Missing higher-return investment opportunities
|
||||
- **Inflation risk**: Fixed payments lose value over time
|
||||
- **Credit risk**: Minimal, backed by Servala's business assets
|
||||
|
||||
**Risk Mitigation:**
|
||||
- Due diligence on Servala's financial stability
|
||||
- Legal documentation ensuring payment priority
|
||||
- Regular financial monitoring and reporting
|
||||
|
||||
### Direct Investment Model Risks
|
||||
- **Performance risk**: Returns depend on business success
|
||||
- **Market risk**: Cloud services market volatility
|
||||
- **Execution risk**: Depends on CSP's sales capabilities
|
||||
- **Competition risk**: New competitors may impact growth
|
||||
|
||||
**Risk Mitigation:**
|
||||
- Diversification across multiple growth scenarios
|
||||
- Performance bonuses aligned with CSP incentives
|
||||
- Regular monitoring and adjustment capabilities
|
||||
- Grace periods provide initial return protection
|
||||
|
||||
---
|
||||
|
||||
## Investment Process
|
||||
|
||||
### Step 1: Initial Assessment
|
||||
1. **Investment capacity evaluation**: Determine available capital
|
||||
2. **Risk tolerance assessment**: Choose appropriate model
|
||||
3. **Strategic alignment review**: Evaluate fit with CSP goals
|
||||
4. **Market analysis**: Understand local demand for managed services
|
||||
|
||||
### Step 2: Due Diligence
|
||||
1. **Financial review**: Examine Servala's financial statements
|
||||
2. **Technical assessment**: Understand platform capabilities
|
||||
3. **Market validation**: Verify demand in target regions
|
||||
4. **Reference checks**: Speak with existing partners
|
||||
|
||||
### Step 3: Terms Negotiation
|
||||
1. **Investment amount**: Determine capital commitment
|
||||
2. **Model selection**: Choose loan vs. direct investment
|
||||
3. **Terms customization**: Negotiate rates, periods, splits
|
||||
4. **Performance metrics**: Define success criteria and bonuses
|
||||
|
||||
### Step 4: Legal Documentation
|
||||
1. **Investment agreement**: Comprehensive terms and conditions
|
||||
2. **Operational guidelines**: Define roles and responsibilities
|
||||
3. **Reporting requirements**: Establish monitoring and reporting
|
||||
4. **Exit clauses**: Plan for investment conclusion
|
||||
|
||||
### Step 5: Implementation
|
||||
1. **Capital deployment**: Transfer investment funds
|
||||
2. **Systems integration**: Implement monitoring and reporting
|
||||
3. **Sales enablement**: Train CSP teams (for direct investment)
|
||||
4. **Performance tracking**: Begin monitoring key metrics
|
||||
|
||||
---
|
||||
|
||||
## Key Performance Indicators (KPIs)
|
||||
|
||||
### Financial Metrics
|
||||
- **Monthly Recurring Revenue (MRR)**: Total monthly income from services
|
||||
- **Customer Acquisition Cost (CAC)**: Cost to acquire new customers
|
||||
- **Customer Lifetime Value (CLV)**: Total revenue per customer
|
||||
- **Monthly churn rate**: Percentage of customers lost per month
|
||||
- **Net Revenue Retention**: Revenue growth from existing customers
|
||||
|
||||
### Investment-Specific Metrics
|
||||
- **Break-even timeline**: Months to recover initial investment
|
||||
- **Return on Investment (ROI)**: Total return percentage
|
||||
- **Performance bonus earnings**: Additional income from exceeding targets
|
||||
- **Grace period utilization**: Revenue during 100% retention period
|
||||
|
||||
### Operational Metrics
|
||||
- **Instance deployment rate**: New services launched monthly
|
||||
- **Customer satisfaction scores**: Service quality indicators
|
||||
- **Support ticket resolution time**: Operational efficiency
|
||||
- **Platform uptime**: Technical reliability metrics
|
||||
|
||||
---
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### General Questions
|
||||
|
||||
**Q: Can I switch between investment models during the term?**
|
||||
A: Model changes require mutual agreement and may involve restructuring fees. Generally, changes are evaluated at renewal periods.
|
||||
|
||||
**Q: What happens if Servala's business underperforms?**
|
||||
A: Loan Model investors are protected through contractual guarantees. Direct Investment returns will reflect actual performance, but grace periods provide initial protection.
|
||||
|
||||
**Q: How are disputes resolved?**
|
||||
A: Investment agreements include arbitration clauses and dispute resolution procedures. Regular communication helps prevent conflicts.
|
||||
|
||||
### Loan Model Questions
|
||||
|
||||
**Q: Are loan payments guaranteed?**
|
||||
A: Yes, loan payments are contractually guaranteed and backed by Servala's business assets and cash flow.
|
||||
|
||||
**Q: Can I prepay or extend the loan term?**
|
||||
A: Terms can be modified by mutual agreement. Prepayment may involve early payment discounts or penalties as specified in the agreement.
|
||||
|
||||
### Direct Investment Questions
|
||||
|
||||
**Q: How is performance measured for bonus calculations?**
|
||||
A: Performance is measured against baseline growth scenarios using actual vs. projected customer instance growth over rolling 6-month periods.
|
||||
|
||||
**Q: What constitutes "active sales participation"?**
|
||||
A: This includes promoting Servala services to existing customers, participating in joint sales activities, and meeting agreed-upon referral targets.
|
||||
|
||||
**Q: How often are performance bonuses calculated and paid?**
|
||||
A: Performance bonuses are calculated monthly and applied to that month's revenue sharing. Payments follow the standard monthly distribution schedule.
|
||||
|
||||
---
|
||||
|
||||
## Contact and Next Steps
|
||||
|
||||
For detailed discussions about investment opportunities:
|
||||
|
||||
1. **Schedule consultation**: Review your specific requirements and objectives
|
||||
2. **Receive custom proposal**: Get tailored investment terms and projections
|
||||
3. **Complete due diligence**: Access detailed financial and operational data
|
||||
4. **Finalize agreement**: Execute legal documentation and begin partnership
|
||||
|
||||
This investment represents an opportunity to participate in the rapidly growing managed cloud services market while supporting innovation in cloud infrastructure automation and management.
|
||||
|
||||
---
|
||||
|
||||
*This document is for informational purposes only and does not constitute financial advice. All investment decisions should be made after careful consideration of your individual circumstances and consultation with appropriate financial and legal advisors.*
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: December 2024
|
||||
**Document Owner**: Servala Investment Relations Team
|
||||
176
SVG_SUPPORT.md
176
SVG_SUPPORT.md
|
|
@ -1,176 +0,0 @@
|
|||
# SVG Support in Image Library
|
||||
|
||||
## Overview
|
||||
|
||||
The Image Library now supports SVG (Scalable Vector Graphics) files alongside traditional raster image formats. This enhancement allows you to upload, manage, and display SVG images with the same ease as JPEG, PNG, and other image formats.
|
||||
|
||||
## Supported Formats
|
||||
|
||||
The Image Library now supports:
|
||||
- **Raster Images**: JPEG, PNG, GIF, WebP, BMP, TIFF
|
||||
- **Vector Images**: SVG
|
||||
|
||||
## Features
|
||||
|
||||
### 1. SVG File Validation
|
||||
- SVG files are validated to ensure they contain valid XML structure
|
||||
- File size limits apply (max 1MB by default)
|
||||
- MIME type detection for proper handling
|
||||
|
||||
### 2. Automatic Dimension Detection
|
||||
- SVG dimensions are extracted from `width` and `height` attributes
|
||||
- Falls back to `viewBox` if width/height attributes are missing
|
||||
- Provides sensible defaults (100x100) if dimensions cannot be determined
|
||||
|
||||
### 3. Proper Rendering
|
||||
- **Template Tags**: SVG images use `<object>` tags for optimal rendering
|
||||
- **Admin Interface**: SVG thumbnails display correctly in the admin
|
||||
- **Form Widgets**: SVG previews work in form interfaces
|
||||
|
||||
### 4. Template Tag Support
|
||||
The `image_library_img` template tag automatically handles SVG files:
|
||||
|
||||
```html
|
||||
<!-- This will render an SVG using <object> tag or <img> tag for raster images -->
|
||||
{% image_library_img "my-svg-logo" css_class="logo" %}
|
||||
|
||||
<!-- Output for SVG files -->
|
||||
<object data="/media/image_library/my-svg-logo.svg" type="image/svg+xml" alt="My SVG Logo" class="logo">
|
||||
<img src="/media/image_library/my-svg-logo.svg" alt="My SVG Logo" class="logo"/>
|
||||
</object>
|
||||
|
||||
<!-- Output for raster images -->
|
||||
<img src="/media/image_library/my-raster-image.jpg" alt="My Raster Image" class="logo"/>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Uploading SVG Images
|
||||
|
||||
1. Go to the Django admin interface
|
||||
2. Navigate to **Services > Image Library**
|
||||
3. Click **Add Image**
|
||||
4. Choose your SVG file in the image field
|
||||
5. Fill in the required fields (name, alt text, etc.)
|
||||
6. Save the image
|
||||
|
||||
### 2. Using SVG Images in Templates
|
||||
|
||||
```html
|
||||
<!-- Load the template tag -->
|
||||
{% load image_library %}
|
||||
|
||||
<!-- Use SVG images just like any other image -->
|
||||
{% image_library_img "my-logo" css_class="img-fluid" %}
|
||||
{% image_library_img "my-icon" css_class="icon" width="24" height="24" %}
|
||||
|
||||
<!-- Get SVG URL -->
|
||||
{% image_library_url "my-logo" %}
|
||||
```
|
||||
|
||||
### 3. Checking if an Image is SVG
|
||||
|
||||
In Python code:
|
||||
```python
|
||||
from hub.services.models.images import ImageLibrary
|
||||
|
||||
image = ImageLibrary.objects.get(slug="my-image")
|
||||
if image.is_svg():
|
||||
print("This is an SVG image")
|
||||
print(f"MIME type: {image.get_mime_type()}")
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
### Existing Images
|
||||
All existing raster images continue to work without any changes. The migration is backward compatible.
|
||||
|
||||
### Updating Image Properties
|
||||
If you want to update image properties for all existing images:
|
||||
|
||||
```bash
|
||||
uv run --extra dev manage.py update_image_properties
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Scalability**: SVG images scale perfectly at any size
|
||||
2. **Performance**: SVG files are typically smaller than high-resolution raster images
|
||||
3. **Flexibility**: SVG images can be styled with CSS
|
||||
4. **Accessibility**: SVG images provide better accessibility options
|
||||
5. **Consistency**: All images are managed through the same unified interface
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Model Changes
|
||||
- Changed `ImageField` to `FileField` with custom validation
|
||||
- Added `is_svg()` method to check if file is SVG
|
||||
- Added `get_mime_type()` method to determine file type
|
||||
- Enhanced `_update_image_properties()` to handle SVG dimensions
|
||||
|
||||
### Validation
|
||||
- SVG files are validated as valid XML
|
||||
- File size limits are enforced
|
||||
- MIME type checking ensures only valid image/SVG files are accepted
|
||||
|
||||
### Rendering
|
||||
- SVG files use `<object>` tags for optimal rendering
|
||||
- Fallback `<img>` tags are provided for compatibility
|
||||
- Admin interface properly displays SVG thumbnails
|
||||
|
||||
## File Size Considerations
|
||||
|
||||
SVG files are typically much smaller than equivalent raster images:
|
||||
- **Simple logos**: 1-5KB
|
||||
- **Complex illustrations**: 10-50KB
|
||||
- **Very complex graphics**: 100KB+
|
||||
|
||||
This makes SVG files ideal for:
|
||||
- Logos and brand assets
|
||||
- Icons and symbols
|
||||
- Simple illustrations
|
||||
- Graphics that need to scale
|
||||
|
||||
## Browser Support
|
||||
|
||||
SVG support is excellent across all modern browsers:
|
||||
- Chrome: Full support
|
||||
- Firefox: Full support
|
||||
- Safari: Full support
|
||||
- Edge: Full support
|
||||
- Internet Explorer 9+: Full support
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use SVG for**:
|
||||
- Logos and brand assets
|
||||
- Icons and symbols
|
||||
- Simple illustrations
|
||||
- Graphics that need to scale
|
||||
|
||||
2. **Use raster images for**:
|
||||
- Photographs
|
||||
- Complex graphics with many colors
|
||||
- Images from external sources
|
||||
|
||||
3. **Optimize SVG files**:
|
||||
- Remove unnecessary metadata
|
||||
- Use simple shapes when possible
|
||||
- Consider using SVG optimization tools
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### SVG Not Displaying
|
||||
- Check that the SVG file is valid XML
|
||||
- Ensure the file has proper width/height or viewBox attributes
|
||||
- Verify the file size is under the limit (1MB)
|
||||
|
||||
### Wrong Dimensions
|
||||
- SVG dimensions are extracted from width/height attributes or viewBox
|
||||
- If dimensions appear wrong, check the SVG source
|
||||
- Use the `update_image_properties` command to refresh dimensions
|
||||
|
||||
### Upload Errors
|
||||
- Ensure the file is valid SVG format
|
||||
- Check file size limits
|
||||
- Verify the file contains valid XML structure
|
||||
|
|
@ -6,11 +6,12 @@ from urllib.parse import urlparse
|
|||
class PrimaryDomainRedirectMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
# Parse the primary hostname from WEBSITE_URL
|
||||
self.primary_host = urlparse(settings.WEBSITE_URL).netloc
|
||||
self.disable_redirect = settings.DISABLE_REDIRECT
|
||||
|
||||
def __call__(self, request):
|
||||
if settings.DEBUG or self.disable_redirect:
|
||||
# Skip redirects in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
return self.get_response(request)
|
||||
|
||||
# Check if the host is different from the primary host
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@
|
|||
from .articles import *
|
||||
from .base import *
|
||||
from .content import *
|
||||
from .images import *
|
||||
from .leads import *
|
||||
from .pricing import *
|
||||
from .providers import *
|
||||
from .widgets import *
|
||||
from .services import *
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ from django.utils.html import format_html
|
|||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
from ..models import Article
|
||||
from .widgets import ImageLibraryWidget
|
||||
|
||||
|
||||
class ArticleAdminForm(forms.ModelForm):
|
||||
|
|
@ -17,9 +17,6 @@ class ArticleAdminForm(forms.ModelForm):
|
|||
class Meta:
|
||||
model = Article
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"image_library": ImageLibraryWidget(),
|
||||
}
|
||||
|
||||
def clean_title(self):
|
||||
"""Validate title length"""
|
||||
|
|
@ -48,7 +45,7 @@ class ArticleAdmin(admin.ModelAdmin):
|
|||
"image_preview",
|
||||
"is_published",
|
||||
"is_featured",
|
||||
"article_date",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = (
|
||||
"is_published",
|
||||
|
|
@ -57,51 +54,18 @@ class ArticleAdmin(admin.ModelAdmin):
|
|||
"related_service",
|
||||
"related_consulting_partner",
|
||||
"related_cloud_provider",
|
||||
"article_date",
|
||||
"created_at",
|
||||
)
|
||||
search_fields = ("title", "excerpt", "content", "meta_keywords")
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
ordering = ("-article_date",)
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("title", "slug", "excerpt", "content", "meta_keywords")}),
|
||||
(
|
||||
"Images",
|
||||
{
|
||||
"fields": ("image_library", "og_image"),
|
||||
"description": "Select an image from the Image Library and optionally upload a specific Open Graph image for social sharing.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Publishing",
|
||||
{"fields": ("author", "article_date", "is_published", "is_featured")},
|
||||
),
|
||||
(
|
||||
"Relations",
|
||||
{
|
||||
"fields": (
|
||||
"related_service",
|
||||
"related_consulting_partner",
|
||||
"related_cloud_provider",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def image_preview(self, obj):
|
||||
"""Display image preview in admin list view"""
|
||||
image = obj.get_image
|
||||
if image:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', image.url)
|
||||
if obj.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.image.url
|
||||
)
|
||||
return "No image"
|
||||
|
||||
image_preview.short_description = "Image"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class PlanInline(admin.StackedInline):
|
|||
model = Plan
|
||||
extra = 1
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "description", "pricing", "plan_description")}),
|
||||
(None, {"fields": ("name", "description", "plan_description")}),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from ..models.images import ImageLibrary
|
||||
|
||||
|
||||
@admin.register(ImageLibrary)
|
||||
class ImageLibraryAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for the Image Library.
|
||||
"""
|
||||
|
||||
list_display = [
|
||||
"image_thumbnail",
|
||||
"name",
|
||||
"category",
|
||||
"get_dimensions",
|
||||
"get_file_size_display",
|
||||
"usage_count",
|
||||
"uploaded_by",
|
||||
"uploaded_at",
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
"category",
|
||||
"uploaded_at",
|
||||
"uploaded_by",
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"name",
|
||||
"description",
|
||||
"alt_text",
|
||||
"tags",
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
"width",
|
||||
"height",
|
||||
"file_size",
|
||||
"usage_count",
|
||||
"uploaded_at",
|
||||
"updated_at",
|
||||
"image_preview",
|
||||
]
|
||||
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
|
||||
fieldsets = (
|
||||
("Image Information", {"fields": ("name", "slug", "description", "alt_text")}),
|
||||
("Image File", {"fields": ("image", "image_preview")}),
|
||||
("Categorization", {"fields": ("category", "tags")}),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("width", "height", "file_size", "usage_count"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{
|
||||
"fields": ("uploaded_by", "uploaded_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def image_thumbnail(self, obj):
|
||||
"""
|
||||
Display small thumbnail in list view.
|
||||
"""
|
||||
if obj.image:
|
||||
# Use img tag for all images in list view to maintain clickability
|
||||
# SVG files will still display correctly with img tag
|
||||
return format_html(
|
||||
'<img src="{}" width="50" height="50" style="object-fit: cover; border-radius: 4px;" />',
|
||||
obj.image.url,
|
||||
)
|
||||
return "No Image"
|
||||
|
||||
image_thumbnail.short_description = "Thumbnail"
|
||||
|
||||
def image_preview(self, obj):
|
||||
"""
|
||||
Display larger preview in detail view.
|
||||
"""
|
||||
if obj.image:
|
||||
if obj.is_svg():
|
||||
# For SVG files in detail view, use object tag for better rendering
|
||||
# This is only for display, not for clickable elements
|
||||
return format_html(
|
||||
'<div style="pointer-events: none;">'
|
||||
'<object data="{}" type="image/svg+xml" style="max-width: 300px; max-height: 300px; border-radius: 4px; background: #f5f5f5;">'
|
||||
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />'
|
||||
"</object>"
|
||||
"</div>",
|
||||
obj.image.url,
|
||||
obj.image.url,
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />',
|
||||
obj.image.url,
|
||||
)
|
||||
return "No Image"
|
||||
|
||||
image_preview.short_description = "Preview"
|
||||
|
||||
def get_dimensions(self, obj):
|
||||
"""
|
||||
Display image dimensions.
|
||||
"""
|
||||
if obj.width and obj.height:
|
||||
return f"{obj.width} × {obj.height}"
|
||||
return "Unknown"
|
||||
|
||||
get_dimensions.short_description = "Dimensions"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""
|
||||
Set uploaded_by field to current user if not already set.
|
||||
"""
|
||||
if not change: # Only set on creation
|
||||
obj.uploaded_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
class Media:
|
||||
css = {"all": ("admin/css/image_library.css",)}
|
||||
|
|
@ -322,26 +322,10 @@ class DiscountTierInline(admin.TabularInline):
|
|||
class ProgressiveDiscountModelAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for ProgressiveDiscountModel"""
|
||||
|
||||
list_display = ("name", "description", "active", "admin_display_discount_tiers")
|
||||
list_display = ("name", "description", "active")
|
||||
search_fields = ("name", "description")
|
||||
inlines = [DiscountTierInline]
|
||||
|
||||
def admin_display_discount_tiers(self, obj):
|
||||
"""Display discount tiers in admin list view"""
|
||||
tiers = obj.tiers.all().order_by("min_units")
|
||||
if not tiers:
|
||||
return "No discount tiers"
|
||||
return format_html(
|
||||
"<br>".join(
|
||||
[
|
||||
f"{tier.min_units}-{tier.max_units if tier.max_units else '∞'} units: {tier.discount_percent}%"
|
||||
for tier in tiers
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
admin_display_discount_tiers.short_description = "Discount Tiers"
|
||||
|
||||
|
||||
@admin.register(VSHNAppCatPrice)
|
||||
class VSHNAppCatPriceAdmin(admin.ModelAdmin):
|
||||
|
|
@ -366,12 +350,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
|
|||
if not fees:
|
||||
return "No base fees"
|
||||
return format_html(
|
||||
"<br>".join(
|
||||
[
|
||||
f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})"
|
||||
for fee in fees
|
||||
]
|
||||
)
|
||||
"<br>".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees])
|
||||
)
|
||||
|
||||
admin_display_base_fees.short_description = "Base Fees"
|
||||
|
|
@ -639,12 +618,7 @@ class VSHNAppCatAddonAdmin(admin.ModelAdmin):
|
|||
if not fees:
|
||||
return "No base fees set"
|
||||
return format_html(
|
||||
"<br>".join(
|
||||
[
|
||||
f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})"
|
||||
for fee in fees
|
||||
]
|
||||
)
|
||||
"<br>".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees])
|
||||
)
|
||||
elif obj.addon_type == "UR": # Unit Rate
|
||||
rates = obj.unit_rates.all()
|
||||
|
|
|
|||
|
|
@ -4,33 +4,9 @@ Admin classes for cloud providers and consulting partners
|
|||
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django import forms
|
||||
from adminsortable2.admin import SortableAdminMixin
|
||||
|
||||
from ..models import CloudProvider, ConsultingPartner, ServiceOffering
|
||||
from .widgets import ImageLibraryWidget
|
||||
|
||||
|
||||
class CloudProviderAdminForm(forms.ModelForm):
|
||||
"""Custom form for CloudProvider admin with image widget"""
|
||||
|
||||
class Meta:
|
||||
model = CloudProvider
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"image_library": ImageLibraryWidget(),
|
||||
}
|
||||
|
||||
|
||||
class ConsultingPartnerAdminForm(forms.ModelForm):
|
||||
"""Custom form for ConsultingPartner admin with image widget"""
|
||||
|
||||
class Meta:
|
||||
model = ConsultingPartner
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"image_library": ImageLibraryWidget(),
|
||||
}
|
||||
|
||||
|
||||
class OfferingInline(admin.StackedInline):
|
||||
|
|
@ -58,8 +34,6 @@ class OfferingInline(admin.StackedInline):
|
|||
class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
"""Admin configuration for CloudProvider model"""
|
||||
|
||||
form = CloudProviderAdminForm
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"slug",
|
||||
|
|
@ -73,27 +47,12 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
|
|||
inlines = [OfferingInline]
|
||||
ordering = ("order",)
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "slug", "description", "order")}),
|
||||
(
|
||||
"Images",
|
||||
{
|
||||
"fields": ("image_library",),
|
||||
"description": "Select an image from the Image Library.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Contact Information",
|
||||
{"fields": ("website", "linkedin", "phone", "email", "address")},
|
||||
),
|
||||
("Settings", {"fields": ("is_featured", "disable_listing")}),
|
||||
)
|
||||
|
||||
def logo_preview(self, obj):
|
||||
"""Display logo preview in admin list view"""
|
||||
logo = obj.get_logo
|
||||
if logo:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
|
||||
if obj.logo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
return "No logo"
|
||||
|
||||
logo_preview.short_description = "Logo"
|
||||
|
|
@ -103,11 +62,8 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
|
|||
class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
"""Admin configuration for ConsultingPartner model"""
|
||||
|
||||
form = ConsultingPartnerAdminForm
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"category",
|
||||
"website",
|
||||
"logo_preview",
|
||||
"disable_listing",
|
||||
|
|
@ -115,36 +71,16 @@ class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin):
|
|||
"order",
|
||||
)
|
||||
search_fields = ("name", "description")
|
||||
list_filter = ("category", "is_featured", "disable_listing")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
filter_horizontal = ("services", "cloud_providers")
|
||||
ordering = ("order",)
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "slug", "description", "category", "order")}),
|
||||
(
|
||||
"Images",
|
||||
{
|
||||
"fields": ("image_library",),
|
||||
"description": "Select an image from the Image Library.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Contact Information",
|
||||
{"fields": ("website", "linkedin", "phone", "email", "address")},
|
||||
),
|
||||
(
|
||||
"Relations",
|
||||
{"fields": ("services", "cloud_providers"), "classes": ("collapse",)},
|
||||
),
|
||||
("Settings", {"fields": ("is_featured", "disable_listing")}),
|
||||
)
|
||||
|
||||
def logo_preview(self, obj):
|
||||
"""Display logo preview in admin list view"""
|
||||
logo = obj.get_logo
|
||||
if logo:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
|
||||
if obj.logo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
return "No logo"
|
||||
|
||||
logo_preview.short_description = "Logo"
|
||||
|
|
|
|||
|
|
@ -4,28 +4,8 @@ Admin classes for services and service offerings
|
|||
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django import forms
|
||||
|
||||
from ..models import (
|
||||
Service,
|
||||
ServiceOffering,
|
||||
ExternalLink,
|
||||
ExternalLinkOffering,
|
||||
Plan,
|
||||
PlanPrice,
|
||||
)
|
||||
from .widgets import ImageLibraryWidget
|
||||
|
||||
|
||||
class ServiceAdminForm(forms.ModelForm):
|
||||
"""Custom form for Service admin with image widget"""
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"image_library": ImageLibraryWidget(),
|
||||
}
|
||||
from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan, PlanPrice
|
||||
|
||||
|
||||
class ExternalLinkInline(admin.TabularInline):
|
||||
|
|
@ -46,25 +26,14 @@ class ExternalLinkOfferingInline(admin.TabularInline):
|
|||
ordering = ("order", "description")
|
||||
|
||||
|
||||
class PlanPriceInline(admin.TabularInline):
|
||||
"""Inline admin for PlanPrice model"""
|
||||
|
||||
model = PlanPrice
|
||||
extra = 1
|
||||
fields = ("currency", "amount")
|
||||
ordering = ("currency",)
|
||||
|
||||
|
||||
class PlanInline(admin.StackedInline):
|
||||
"""Inline admin for Plan model with sortable ordering"""
|
||||
"""Inline admin for Plan model"""
|
||||
|
||||
model = Plan
|
||||
extra = 1
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "description", "plan_description")}),
|
||||
("Display Options", {"fields": ("is_best", "order")}),
|
||||
)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class OfferingInline(admin.StackedInline):
|
||||
|
|
@ -88,12 +57,22 @@ class OfferingInline(admin.StackedInline):
|
|||
show_change_link = True
|
||||
|
||||
|
||||
class PlanPriceInline(admin.TabularInline):
|
||||
model = PlanPrice
|
||||
extra = 1
|
||||
|
||||
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
inlines = [PlanPriceInline]
|
||||
list_display = ("name", "offering")
|
||||
search_fields = ("name",)
|
||||
list_filter = ("offering",)
|
||||
|
||||
|
||||
@admin.register(Service)
|
||||
class ServiceAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for Service model"""
|
||||
|
||||
form = ServiceAdminForm
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"logo_preview",
|
||||
|
|
@ -108,34 +87,12 @@ class ServiceAdmin(admin.ModelAdmin):
|
|||
filter_horizontal = ("categories",)
|
||||
inlines = [ExternalLinkInline, OfferingInline]
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "slug", "description", "tagline")}),
|
||||
(
|
||||
"Images",
|
||||
{
|
||||
"fields": ("image_library",),
|
||||
"description": "Select an image from the Image Library.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Configuration",
|
||||
{
|
||||
"fields": (
|
||||
"categories",
|
||||
"features",
|
||||
"is_featured",
|
||||
"is_coming_soon",
|
||||
"disable_listing",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def logo_preview(self, obj):
|
||||
"""Display logo preview in admin list view"""
|
||||
logo = obj.get_logo
|
||||
if logo:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
|
||||
if obj.logo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
return "No logo"
|
||||
|
||||
logo_preview.short_description = "Logo"
|
||||
|
|
@ -157,59 +114,11 @@ class ServiceAdmin(admin.ModelAdmin):
|
|||
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for ServiceOffering model"""
|
||||
|
||||
list_display = ("service", "cloud_provider", "plan_count", "total_prices")
|
||||
list_display = ("service", "cloud_provider")
|
||||
list_filter = ("service", "cloud_provider")
|
||||
search_fields = ("service__name", "cloud_provider__name", "description")
|
||||
inlines = [ExternalLinkOfferingInline, PlanInline]
|
||||
|
||||
def plan_count(self, obj):
|
||||
"""Display number of plans for this offering"""
|
||||
return obj.plans.count()
|
||||
|
||||
plan_count.short_description = "Plans"
|
||||
|
||||
def total_prices(self, obj):
|
||||
"""Display total number of plan prices for this offering"""
|
||||
total = sum(plan.plan_prices.count() for plan in obj.plans.all())
|
||||
return f"{total} prices"
|
||||
|
||||
total_prices.short_description = "Total Prices"
|
||||
|
||||
|
||||
@admin.register(Plan)
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for Plan model with sortable ordering"""
|
||||
|
||||
list_display = ("name", "offering", "is_best", "price_summary", "order")
|
||||
list_filter = ("offering__service", "offering__cloud_provider", "is_best")
|
||||
search_fields = ("name", "description", "offering__service__name")
|
||||
list_editable = ("is_best",)
|
||||
inlines = [PlanPriceInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "offering", "description", "plan_description")}),
|
||||
("Display Options", {"fields": ("is_best", "order")}),
|
||||
)
|
||||
|
||||
def price_summary(self, obj):
|
||||
"""Display a summary of prices for this plan"""
|
||||
prices = obj.plan_prices.all()
|
||||
if prices:
|
||||
price_strs = [f"{price.amount} {price.currency}" for price in prices]
|
||||
return ", ".join(price_strs)
|
||||
return "No prices set"
|
||||
|
||||
price_summary.short_description = "Prices"
|
||||
|
||||
|
||||
@admin.register(PlanPrice)
|
||||
class PlanPriceAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for PlanPrice model"""
|
||||
|
||||
list_display = ("plan", "currency", "amount")
|
||||
list_filter = (
|
||||
"currency",
|
||||
"plan__offering__service",
|
||||
"plan__offering__cloud_provider",
|
||||
)
|
||||
search_fields = ("plan__name", "plan__offering__service__name")
|
||||
ordering = ("plan__offering__service__name", "plan__name", "currency")
|
||||
admin.site.register(Plan, PlanAdmin)
|
||||
admin.site.register(PlanPrice)
|
||||
|
|
|
|||
|
|
@ -1,232 +0,0 @@
|
|||
"""
|
||||
Custom widgets for Django admin interface
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from ..models import ImageLibrary
|
||||
|
||||
|
||||
class ImageLibraryWidget(forms.Select):
|
||||
"""Custom widget for selecting images from the library with visual preview"""
|
||||
|
||||
def __init__(self, attrs=None):
|
||||
super().__init__(attrs)
|
||||
self.attrs.update(
|
||||
{
|
||||
"class": "image-library-select",
|
||||
"style": "display: none;", # Hide the original select
|
||||
}
|
||||
)
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
"""Render the widget with image previews"""
|
||||
# Get the original select element
|
||||
original_select = super().render(name, value, attrs, renderer)
|
||||
|
||||
# Get all images from the library
|
||||
images = ImageLibrary.objects.all().order_by("-uploaded_at")
|
||||
|
||||
# Create the visual interface
|
||||
html_parts = [
|
||||
'<div class="image-library-widget">',
|
||||
original_select, # Keep the original select for form submission
|
||||
'<div class="image-library-grid">',
|
||||
]
|
||||
|
||||
# Add "No image" option
|
||||
no_image_selected = "selected" if not value else ""
|
||||
html_parts.append(
|
||||
f"""
|
||||
<div class="image-option {no_image_selected}" data-value="">
|
||||
<div class="image-preview no-image">
|
||||
<i class="fas fa-ban"></i>
|
||||
<span>No image</span>
|
||||
</div>
|
||||
<div class="image-info">
|
||||
<span class="image-name">No image selected</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
# Add each image as an option
|
||||
for image in images:
|
||||
selected = "selected" if str(image.pk) == str(value) else ""
|
||||
image_url = image.image.url if image.image else ""
|
||||
|
||||
# Use img tag for all images in widget to maintain clickability
|
||||
# SVG files will still display correctly with img tag
|
||||
preview_html = (
|
||||
f'<img src="{image_url}" alt="{image.alt_text}" loading="lazy">'
|
||||
)
|
||||
|
||||
html_parts.append(
|
||||
f"""
|
||||
<div class="image-option {selected}" data-value="{image.pk}">
|
||||
<div class="image-preview">
|
||||
{preview_html}
|
||||
</div>
|
||||
<div class="image-info">
|
||||
<span class="image-name">{image.name}</span>
|
||||
<span class="image-category">{image.get_category_display()}</span>
|
||||
<span class="image-size">{image.width}x{image.height}</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
html_parts.extend(
|
||||
[
|
||||
"</div>",
|
||||
"</div>",
|
||||
self._get_styles(),
|
||||
self._get_javascript(),
|
||||
]
|
||||
)
|
||||
|
||||
return mark_safe("".join(html_parts))
|
||||
|
||||
def _get_styles(self):
|
||||
"""Return CSS styles for the widget"""
|
||||
return """
|
||||
<style>
|
||||
.image-library-widget {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.image-library-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
max-height: 800px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.image-option {
|
||||
background: white;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-option:hover {
|
||||
border-color: #007cba;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.image-option.selected {
|
||||
border-color: #007cba;
|
||||
background: #e3f2fd;
|
||||
box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.2);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.image-preview.no-image {
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.image-preview.no-image i {
|
||||
font-size: 24px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
margin-bottom: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-category {
|
||||
display: inline-block;
|
||||
background: #e0e0e0;
|
||||
color: #666;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.image-size {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
def _get_javascript(self):
|
||||
"""Return JavaScript for the widget functionality"""
|
||||
return """
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle image selection
|
||||
document.querySelectorAll('.image-option').forEach(function(option) {
|
||||
option.addEventListener('click', function() {
|
||||
const widget = this.closest('.image-library-widget');
|
||||
const select = widget.querySelector('.image-library-select');
|
||||
const value = this.dataset.value;
|
||||
|
||||
// Update the hidden select
|
||||
select.value = value;
|
||||
|
||||
// Update visual selection
|
||||
widget.querySelectorAll('.image-option').forEach(function(opt) {
|
||||
opt.classList.remove('selected');
|
||||
});
|
||||
this.classList.add('selected');
|
||||
|
||||
// Trigger change event
|
||||
select.dispatchEvent(new Event('change'));
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
"""
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
"all": (
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css",
|
||||
)
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
"""
|
||||
RSS Feeds for the Servala website
|
||||
"""
|
||||
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.urls import reverse
|
||||
from django.utils.feedgenerator import Rss201rev2Feed
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
from .models import Article
|
||||
|
||||
|
||||
class ArticleRSSFeed(Feed):
|
||||
"""RSS feed for published articles"""
|
||||
|
||||
title = "Servala Articles"
|
||||
link = "/articles/"
|
||||
description = "Latest articles about cloud services, consulting partners, and technology insights from Servala"
|
||||
feed_type = Rss201rev2Feed
|
||||
|
||||
def items(self):
|
||||
"""Return the latest 20 published articles"""
|
||||
return Article.objects.filter(is_published=True).order_by("-article_date")[:20]
|
||||
|
||||
def item_title(self, item):
|
||||
"""Return the article title"""
|
||||
return item.title
|
||||
|
||||
def item_description(self, item):
|
||||
"""Return the article excerpt with 'Read more' link"""
|
||||
base_url = "https://servala.com"
|
||||
|
||||
# Use the excerpt and add a proper HTML read more link
|
||||
excerpt = strip_tags(item.excerpt)
|
||||
article_url = f"{base_url}{item.get_absolute_url()}"
|
||||
|
||||
# Return HTML content for the RSS description
|
||||
return f'{excerpt} <a href="{article_url}">Read more...</a>'
|
||||
|
||||
def item_link(self, item):
|
||||
"""Return the link to the article detail page"""
|
||||
return item.get_absolute_url()
|
||||
|
||||
def item_guid(self, item):
|
||||
"""Return a unique identifier for the item"""
|
||||
return f"article-{item.id}"
|
||||
|
||||
def item_pubdate(self, item):
|
||||
"""Return the publication date"""
|
||||
# Convert date to datetime for RSS compatibility
|
||||
from datetime import datetime, time
|
||||
from django.utils import timezone
|
||||
|
||||
# Combine the date with midnight time and make it timezone-aware
|
||||
dt = datetime.combine(item.article_date, time.min)
|
||||
return timezone.make_aware(dt, timezone.get_current_timezone())
|
||||
|
||||
def item_author_name(self, item):
|
||||
"""Return the author name"""
|
||||
return item.author.get_full_name() or item.author.username
|
||||
|
||||
def item_categories(self, item):
|
||||
"""Return categories for the article"""
|
||||
categories = []
|
||||
|
||||
# Add related entity as category
|
||||
if item.related_service:
|
||||
categories.append(f"Service: {item.related_service.name}")
|
||||
if item.related_consulting_partner:
|
||||
categories.append(f"Partner: {item.related_consulting_partner.name}")
|
||||
if item.related_cloud_provider:
|
||||
categories.append(f"Provider: {item.related_cloud_provider.name}")
|
||||
|
||||
# Add meta keywords as categories if available
|
||||
if item.meta_keywords:
|
||||
keywords = [keyword.strip() for keyword in item.meta_keywords.split(",")]
|
||||
categories.extend(keywords)
|
||||
|
||||
return categories
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from django import forms
|
||||
from ..models import Lead
|
||||
from .models import Lead, Plan
|
||||
|
||||
|
||||
class LeadForm(forms.ModelForm):
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
from .lead import LeadForm
|
||||
from .image_library import ImageLibraryField, ImageLibraryWidget
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
from django import forms
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from ..models.images import ImageLibrary
|
||||
|
||||
|
||||
class ImageLibraryWidget(forms.Select):
|
||||
"""
|
||||
Custom widget for selecting images from the library with thumbnails.
|
||||
"""
|
||||
|
||||
def __init__(self, attrs=None, choices=(), show_thumbnails=True):
|
||||
self.show_thumbnails = show_thumbnails
|
||||
super().__init__(attrs, choices)
|
||||
|
||||
def format_value(self, value):
|
||||
"""
|
||||
Format the selected value for display.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
"""
|
||||
Render the widget with thumbnails.
|
||||
"""
|
||||
if attrs is None:
|
||||
attrs = {}
|
||||
|
||||
# Add CSS class for styling
|
||||
attrs["class"] = attrs.get("class", "") + " image-library-select"
|
||||
|
||||
# Get all images for the select options
|
||||
images = ImageLibrary.objects.all().order_by("name")
|
||||
|
||||
# Build choices with thumbnails
|
||||
choices = [("", "--- Select an image ---")]
|
||||
for image in images:
|
||||
thumbnail_html = ""
|
||||
if self.show_thumbnails and image.image:
|
||||
# Use img tag for all images in dropdowns to maintain functionality
|
||||
# SVG files will still display correctly with img tag
|
||||
thumbnail_html = format_html(
|
||||
' <img src="{}" style="width: 20px; height: 20px; object-fit: cover; margin-left: 5px; vertical-align: middle;" />',
|
||||
image.image.url,
|
||||
)
|
||||
|
||||
choice_text = (
|
||||
f"{image.name} ({image.get_category_display()}){thumbnail_html}"
|
||||
)
|
||||
choices.append((image.pk, choice_text))
|
||||
|
||||
# Build the select element
|
||||
select_html = format_html(
|
||||
'<select name="{}" id="{}"{}>{}</select>',
|
||||
name,
|
||||
attrs.get("id", ""),
|
||||
self._build_attrs_string(attrs),
|
||||
self._build_options(choices, value),
|
||||
)
|
||||
|
||||
# Add preview area
|
||||
preview_html = ""
|
||||
if value:
|
||||
try:
|
||||
image = ImageLibrary.objects.get(pk=value)
|
||||
|
||||
# Use img tag for all images in preview for consistency
|
||||
# Add SVG indicator in the text if it's an SVG file
|
||||
svg_indicator = " (SVG)" if image.is_svg() else ""
|
||||
preview_html = format_html(
|
||||
'<div class="image-preview" style="margin-top: 10px;">'
|
||||
'<img src="{}" style="max-width: 200px; max-height: 200px; border: 1px solid #ddd; border-radius: 4px;" />'
|
||||
'<p style="margin-top: 5px; font-size: 12px; color: #666;">{} - {}x{} - {}{}</p>'
|
||||
"</div>",
|
||||
image.image.url,
|
||||
image.name,
|
||||
image.width or "?",
|
||||
image.height or "?",
|
||||
image.get_file_size_display(),
|
||||
svg_indicator,
|
||||
)
|
||||
except ImageLibrary.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Add JavaScript for preview updates
|
||||
js_html = format_html(
|
||||
"<script>"
|
||||
'document.addEventListener("DOMContentLoaded", function() {{'
|
||||
' const select = document.getElementById("{}");\n'
|
||||
' const previewDiv = select.parentNode.querySelector(".image-preview");\n'
|
||||
' select.addEventListener("change", function() {{'
|
||||
" const imageId = this.value;\n"
|
||||
" if (imageId) {{"
|
||||
' fetch("/admin/services/imagelibrary/" + imageId + "/preview/")'
|
||||
" .then(response => response.json())"
|
||||
" .then(data => {{"
|
||||
" if (previewDiv) {{"
|
||||
" previewDiv.innerHTML = data.html;\n"
|
||||
" }}"
|
||||
" }});\n"
|
||||
" }} else {{"
|
||||
" if (previewDiv) {{"
|
||||
' previewDiv.innerHTML = "";\n'
|
||||
" }}"
|
||||
" }}"
|
||||
" }});\n"
|
||||
"}});\n"
|
||||
"</script>",
|
||||
attrs.get("id", ""),
|
||||
)
|
||||
|
||||
return mark_safe(select_html + preview_html + js_html)
|
||||
|
||||
def _build_attrs_string(self, attrs):
|
||||
"""
|
||||
Build HTML attributes string.
|
||||
"""
|
||||
attr_parts = []
|
||||
for key, value in attrs.items():
|
||||
if key != "id": # id is handled separately
|
||||
attr_parts.append(f'{key}="{value}"')
|
||||
return " " + " ".join(attr_parts) if attr_parts else ""
|
||||
|
||||
def _build_options(self, choices, selected_value):
|
||||
"""
|
||||
Build option elements for the select.
|
||||
"""
|
||||
options = []
|
||||
for value, text in choices:
|
||||
selected = "selected" if str(value) == str(selected_value) else ""
|
||||
options.append(f'<option value="{value}" {selected}>{text}</option>')
|
||||
return "".join(options)
|
||||
|
||||
|
||||
class ImageLibraryField(forms.ModelChoiceField):
|
||||
"""
|
||||
Custom form field for selecting images from the library.
|
||||
"""
|
||||
|
||||
def __init__(self, queryset=None, widget=None, show_thumbnails=True, **kwargs):
|
||||
if queryset is None:
|
||||
queryset = ImageLibrary.objects.all()
|
||||
|
||||
if widget is None:
|
||||
widget = ImageLibraryWidget(show_thumbnails=show_thumbnails)
|
||||
|
||||
super().__init__(queryset=queryset, widget=widget, **kwargs)
|
||||
|
||||
def label_from_instance(self, obj):
|
||||
"""
|
||||
Return the label for an image instance.
|
||||
"""
|
||||
return f"{obj.name} ({obj.get_category_display()})"
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.core.management import call_command
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Build and compress static assets for production"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force compression even if files exist",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("Building static assets...")
|
||||
|
||||
# Compress CSS and JS files
|
||||
self.stdout.write("Compressing CSS and JavaScript...")
|
||||
call_command(
|
||||
"compress",
|
||||
force=options.get("force", False),
|
||||
verbosity=options.get("verbosity", 1),
|
||||
)
|
||||
|
||||
# Collect all static files
|
||||
self.stdout.write("Collecting static files...")
|
||||
call_command(
|
||||
"collectstatic", interactive=False, verbosity=options.get("verbosity", 1)
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully built static assets"))
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.text import slugify
|
||||
from hub.services.models import (
|
||||
ImageLibrary,
|
||||
Service,
|
||||
CloudProvider,
|
||||
ConsultingPartner,
|
||||
Article,
|
||||
)
|
||||
import os
|
||||
import shutil
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Migrate existing images to the Image Library"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be migrated without actually doing it",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force migration even if images already exist in library",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Main command handler to migrate existing images to the library.
|
||||
"""
|
||||
dry_run = options["dry_run"]
|
||||
force = options["force"]
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Starting image migration {"(DRY RUN)" if dry_run else ""}'
|
||||
)
|
||||
)
|
||||
|
||||
# Migrate different types of images
|
||||
self.migrate_service_logos(dry_run, force)
|
||||
self.migrate_cloud_provider_logos(dry_run, force)
|
||||
self.migrate_partner_logos(dry_run, force)
|
||||
self.migrate_article_images(dry_run, force)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Image migration completed {"(DRY RUN)" if dry_run else ""}'
|
||||
)
|
||||
)
|
||||
|
||||
def migrate_service_logos(self, dry_run, force):
|
||||
"""
|
||||
Migrate service logos to the image library.
|
||||
"""
|
||||
self.stdout.write("Migrating service logos...")
|
||||
|
||||
services = Service.objects.filter(logo__isnull=False).exclude(logo="")
|
||||
|
||||
for service in services:
|
||||
if not service.logo:
|
||||
continue
|
||||
|
||||
# Check if image already exists in library
|
||||
existing_image = ImageLibrary.objects.filter(
|
||||
name=f"{service.name} Logo"
|
||||
).first()
|
||||
|
||||
if existing_image and not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" - Skipping {service.name} logo (already exists)"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Would migrate: {service.name} logo")
|
||||
)
|
||||
continue
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=f"{service.name} Logo",
|
||||
slug=slugify(f"{service.name}-logo"),
|
||||
description=f"Logo for {service.name} service",
|
||||
alt_text=f"{service.name} logo",
|
||||
category="logo",
|
||||
tags=f"service, logo, {service.name.lower()}",
|
||||
)
|
||||
|
||||
# Copy the image file
|
||||
if service.logo and os.path.exists(service.logo.path):
|
||||
with open(service.logo.path, "rb") as f:
|
||||
image_lib.image.save(
|
||||
os.path.basename(service.logo.name),
|
||||
ContentFile(f.read()),
|
||||
save=True,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Migrated: {service.name} logo")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" - Failed to migrate: {service.name} logo (file not found)"
|
||||
)
|
||||
)
|
||||
|
||||
def migrate_cloud_provider_logos(self, dry_run, force):
|
||||
"""
|
||||
Migrate cloud provider logos to the image library.
|
||||
"""
|
||||
self.stdout.write("Migrating cloud provider logos...")
|
||||
|
||||
providers = CloudProvider.objects.filter(logo__isnull=False).exclude(logo="")
|
||||
|
||||
for provider in providers:
|
||||
if not provider.logo:
|
||||
continue
|
||||
|
||||
# Check if image already exists in library
|
||||
existing_image = ImageLibrary.objects.filter(
|
||||
name=f"{provider.name} Logo"
|
||||
).first()
|
||||
|
||||
if existing_image and not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" - Skipping {provider.name} logo (already exists)"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Would migrate: {provider.name} logo")
|
||||
)
|
||||
continue
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=f"{provider.name} Logo",
|
||||
slug=slugify(f"{provider.name}-logo"),
|
||||
description=f"Logo for {provider.name} cloud provider",
|
||||
alt_text=f"{provider.name} logo",
|
||||
category="logo",
|
||||
tags=f"cloud, provider, logo, {provider.name.lower()}",
|
||||
)
|
||||
|
||||
# Copy the image file
|
||||
if provider.logo and os.path.exists(provider.logo.path):
|
||||
with open(provider.logo.path, "rb") as f:
|
||||
image_lib.image.save(
|
||||
os.path.basename(provider.logo.name),
|
||||
ContentFile(f.read()),
|
||||
save=True,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Migrated: {provider.name} logo")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" - Failed to migrate: {provider.name} logo (file not found)"
|
||||
)
|
||||
)
|
||||
|
||||
def migrate_partner_logos(self, dry_run, force):
|
||||
"""
|
||||
Migrate consulting partner logos to the image library.
|
||||
"""
|
||||
self.stdout.write("Migrating consulting partner logos...")
|
||||
|
||||
partners = ConsultingPartner.objects.filter(logo__isnull=False).exclude(logo="")
|
||||
|
||||
for partner in partners:
|
||||
if not partner.logo:
|
||||
continue
|
||||
|
||||
# Check if image already exists in library
|
||||
existing_image = ImageLibrary.objects.filter(
|
||||
name=f"{partner.name} Logo"
|
||||
).first()
|
||||
|
||||
if existing_image and not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" - Skipping {partner.name} logo (already exists)"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Would migrate: {partner.name} logo")
|
||||
)
|
||||
continue
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=f"{partner.name} Logo",
|
||||
slug=slugify(f"{partner.name}-logo"),
|
||||
description=f"Logo for {partner.name} consulting partner",
|
||||
alt_text=f"{partner.name} logo",
|
||||
category="logo",
|
||||
tags=f"consulting, partner, logo, {partner.name.lower()}",
|
||||
)
|
||||
|
||||
# Copy the image file
|
||||
if partner.logo and os.path.exists(partner.logo.path):
|
||||
with open(partner.logo.path, "rb") as f:
|
||||
image_lib.image.save(
|
||||
os.path.basename(partner.logo.name),
|
||||
ContentFile(f.read()),
|
||||
save=True,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Migrated: {partner.name} logo")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" - Failed to migrate: {partner.name} logo (file not found)"
|
||||
)
|
||||
)
|
||||
|
||||
def migrate_article_images(self, dry_run, force):
|
||||
"""
|
||||
Migrate article images to the image library.
|
||||
"""
|
||||
self.stdout.write("Migrating article images...")
|
||||
|
||||
articles = Article.objects.filter(image__isnull=False).exclude(image="")
|
||||
|
||||
for article in articles:
|
||||
if not article.image:
|
||||
continue
|
||||
|
||||
# Check if image already exists in library
|
||||
existing_image = ImageLibrary.objects.filter(
|
||||
name=f"{article.title} Image"
|
||||
).first()
|
||||
|
||||
if existing_image and not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" - Skipping {article.title} image (already exists)"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Would migrate: {article.title} image")
|
||||
)
|
||||
continue
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=f"{article.title} Image",
|
||||
slug=slugify(f"{article.title}-image"),
|
||||
description=f"Feature image for article: {article.title}",
|
||||
alt_text=f"{article.title} feature image",
|
||||
category="article",
|
||||
tags=f"article, {article.title.lower()}",
|
||||
)
|
||||
|
||||
# Copy the image file
|
||||
if article.image and os.path.exists(article.image.path):
|
||||
with open(article.image.path, "rb") as f:
|
||||
image_lib.image.save(
|
||||
os.path.basename(article.image.name),
|
||||
ContentFile(f.read()),
|
||||
save=True,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Migrated: {article.title} image")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" - Failed to migrate: {article.title} image (file not found)"
|
||||
)
|
||||
)
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from hub.services.models.images import ImageLibrary
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Update image properties for existing images in the library"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Update image properties for all images in the library.
|
||||
This is especially useful after adding SVG support.
|
||||
"""
|
||||
images = ImageLibrary.objects.all()
|
||||
updated_count = 0
|
||||
error_count = 0
|
||||
|
||||
self.stdout.write(f"Updating properties for {images.count()} images...")
|
||||
|
||||
for image in images:
|
||||
try:
|
||||
# Force update of image properties
|
||||
image._update_image_properties()
|
||||
updated_count += 1
|
||||
|
||||
# Show progress
|
||||
if updated_count % 10 == 0:
|
||||
self.stdout.write(f"Updated {updated_count} images...")
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"Error updating {image.name}: {str(e)}")
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Successfully updated {updated_count} images. "
|
||||
f"Errors: {error_count}"
|
||||
)
|
||||
)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated by Django 5.2 on 2025-06-23 07:58
|
||||
# Generated by Django 5.2 on 2025-06-20 15:28
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-06-23 10:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0037_remove_plan_pricing_planprice"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="plan",
|
||||
options={"ordering": ["order", "name"]},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="plan",
|
||||
name="is_best",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Mark this plan as the best/recommended option"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="plan",
|
||||
name="order",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Order of this plan in the offering (lower numbers appear first)",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-04 13:48
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0038_add_plan_ordering_and_best"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="article",
|
||||
name="article_date",
|
||||
field=models.DateField(
|
||||
default=django.utils.timezone.now,
|
||||
help_text="Date of the article publishing",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-04 14:19
|
||||
|
||||
import django.db.models.deletion
|
||||
import hub.services.models.base
|
||||
import hub.services.models.images
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0039_article_article_date"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ImageLibrary",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
help_text="Descriptive name for the image", max_length=200
|
||||
),
|
||||
),
|
||||
(
|
||||
"slug",
|
||||
models.SlugField(
|
||||
help_text="URL-friendly version of the name",
|
||||
max_length=250,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True, help_text="Optional description of the image"
|
||||
),
|
||||
),
|
||||
(
|
||||
"alt_text",
|
||||
models.CharField(
|
||||
help_text="Alternative text for accessibility", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"image",
|
||||
models.ImageField(
|
||||
help_text="Upload image file (max 1MB)",
|
||||
upload_to=hub.services.models.images.get_image_upload_path,
|
||||
validators=[hub.services.models.base.validate_image_size],
|
||||
),
|
||||
),
|
||||
(
|
||||
"width",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, help_text="Image width in pixels", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"height",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, help_text="Image height in pixels", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"file_size",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, help_text="File size in bytes", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"category",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("logo", "Logo"),
|
||||
("article", "Article Image"),
|
||||
("banner", "Banner"),
|
||||
("icon", "Icon"),
|
||||
("screenshot", "Screenshot"),
|
||||
("photo", "Photo"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="other",
|
||||
help_text="Category of the image",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"tags",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Comma-separated tags for searching",
|
||||
max_length=500,
|
||||
),
|
||||
),
|
||||
(
|
||||
"uploaded_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="Date and time when image was uploaded",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="Date and time when image was last updated",
|
||||
),
|
||||
),
|
||||
(
|
||||
"usage_count",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of times this image is referenced"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uploaded_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who uploaded the image",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Image",
|
||||
"verbose_name_plural": "Image Library",
|
||||
"ordering": ["-uploaded_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-04 15:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0040_add_image_library"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="cloudprovider",
|
||||
name="image",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="consultingpartner",
|
||||
name="image",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="service",
|
||||
name="image",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="article",
|
||||
name="image",
|
||||
field=models.ImageField(
|
||||
blank=True,
|
||||
help_text="Title picture for the article",
|
||||
null=True,
|
||||
upload_to="article_images/",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-04 15:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0041_add_image_library_references"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="cloudprovider",
|
||||
name="image",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="consultingpartner",
|
||||
name="image",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="service",
|
||||
name="image",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="article",
|
||||
name="image_library",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_references",
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cloudprovider",
|
||||
name="image_library",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_references",
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="consultingpartner",
|
||||
name="image_library",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_references",
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="service",
|
||||
name="image_library",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_references",
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-08 09:37
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0042_fix_image_library_field_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="article",
|
||||
name="image",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="cloudprovider",
|
||||
name="logo",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="consultingpartner",
|
||||
name="logo",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="service",
|
||||
name="logo",
|
||||
),
|
||||
]
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-08 10:51
|
||||
|
||||
import hub.services.models.base
|
||||
import hub.services.models.images
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0043_remove_article_image_remove_cloudprovider_logo_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="imagelibrary",
|
||||
name="image",
|
||||
field=models.FileField(
|
||||
help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG",
|
||||
upload_to=hub.services.models.images.get_image_upload_path,
|
||||
validators=[hub.services.models.base.validate_image_or_svg],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-08 13:53
|
||||
|
||||
import hub.services.models.base
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0044_add_svg_support"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="article",
|
||||
name="og_image",
|
||||
field=models.ImageField(
|
||||
blank=True,
|
||||
help_text="Optional Open Graph image for social sharing (max 1MB). If not provided, the article's main image will be used.",
|
||||
null=True,
|
||||
upload_to="article_og_images/",
|
||||
validators=[hub.services.models.base.validate_image_size],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-11 08:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0045_add_og_image_to_article"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="consultingpartner",
|
||||
name="category",
|
||||
field=models.CharField(
|
||||
choices=[("CONSULTING", "Consulting"), ("TRAINING", "Training")],
|
||||
default="CONSULTING",
|
||||
help_text="Category of the consulting partner",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
from .articles import *
|
||||
from .base import *
|
||||
from .content import *
|
||||
from .images import *
|
||||
from .leads import *
|
||||
from .pricing import *
|
||||
from .providers import *
|
||||
|
|
|
|||
|
|
@ -2,27 +2,27 @@ from django.db import models
|
|||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from .base import validate_image_size, get_prose_editor_field
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
from .base import validate_image_size
|
||||
from .services import Service
|
||||
from .providers import CloudProvider, ConsultingPartner
|
||||
from .images import ImageReference
|
||||
|
||||
|
||||
class Article(ImageReference):
|
||||
class Article(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=250, unique=True)
|
||||
excerpt = models.TextField(
|
||||
max_length=500, help_text="Brief description of the article"
|
||||
)
|
||||
content = get_prose_editor_field()
|
||||
content = ProseEditorField()
|
||||
meta_keywords = models.CharField(
|
||||
max_length=255, blank=True, help_text="SEO keywords separated by commas"
|
||||
)
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")
|
||||
article_date = models.DateField(
|
||||
default=timezone.now, help_text="Date of the article publishing"
|
||||
image = models.ImageField(
|
||||
upload_to="article_images/",
|
||||
help_text="Title picture for the article",
|
||||
)
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")
|
||||
|
||||
# Relations to other models
|
||||
related_service = models.ForeignKey(
|
||||
|
|
@ -50,15 +50,6 @@ class Article(ImageReference):
|
|||
help_text="Link this article to a cloud provider",
|
||||
)
|
||||
|
||||
# Open Graph image for social sharing
|
||||
og_image = models.ImageField(
|
||||
upload_to="article_og_images/",
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[validate_image_size],
|
||||
help_text="Optional Open Graph image for social sharing (max 1MB). If not provided, the article's main image will be used.",
|
||||
)
|
||||
|
||||
# Publishing controls
|
||||
is_published = models.BooleanField(
|
||||
default=False, help_text="Only published articles are visible to users"
|
||||
|
|
@ -91,22 +82,6 @@ class Article(ImageReference):
|
|||
def get_absolute_url(self):
|
||||
return reverse("services:article_detail", kwargs={"slug": self.slug})
|
||||
|
||||
@property
|
||||
def get_image(self):
|
||||
"""Returns the image from the library"""
|
||||
if self.image_library and self.image_library.image:
|
||||
return self.image_library.image
|
||||
return None
|
||||
|
||||
@property
|
||||
def get_og_image(self):
|
||||
"""Returns the Open Graph image for social sharing"""
|
||||
# Use specific OG image if available
|
||||
if self.og_image:
|
||||
return self.og_image
|
||||
# Fall back to main article image
|
||||
return self.get_image
|
||||
|
||||
@property
|
||||
def related_to(self):
|
||||
"""Returns a string describing what this article is related to"""
|
||||
|
|
|
|||
|
|
@ -2,89 +2,12 @@ from django.db import models
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.utils.text import slugify
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
import mimetypes
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
# Centralized ProseEditorField configuration
|
||||
PROSE_EDITOR_CONFIG = {
|
||||
"extensions": {
|
||||
"Bold": True,
|
||||
"Italic": True,
|
||||
"Strike": True,
|
||||
"Underline": True,
|
||||
"HardBreak": True,
|
||||
"Heading": {"levels": [1, 2, 3, 4, 5, 6]},
|
||||
"BulletList": True,
|
||||
"OrderedList": True,
|
||||
"Blockquote": True,
|
||||
"Link": True,
|
||||
"Table": True,
|
||||
"History": True,
|
||||
"HTML": True,
|
||||
"Typographic": True,
|
||||
},
|
||||
"sanitize": True,
|
||||
}
|
||||
|
||||
|
||||
def get_prose_editor_field(**kwargs):
|
||||
"""
|
||||
Returns a ProseEditorField with the standard configuration.
|
||||
Additional kwargs can be passed to override or add field options.
|
||||
"""
|
||||
config = PROSE_EDITOR_CONFIG.copy()
|
||||
config.update(kwargs)
|
||||
return ProseEditorField(**config)
|
||||
|
||||
|
||||
def validate_image_size(value, mb=1):
|
||||
def validate_image_size(value):
|
||||
filesize = value.size
|
||||
if filesize > mb * 1024 * 1024:
|
||||
raise ValidationError(f"Maximum file size is {mb} MB")
|
||||
|
||||
|
||||
def validate_image_or_svg(value):
|
||||
"""
|
||||
Validate that the uploaded file is either a valid image or SVG file.
|
||||
"""
|
||||
# Check file size first
|
||||
validate_image_size(value)
|
||||
|
||||
# Get the file extension and MIME type
|
||||
filename = value.name.lower()
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
|
||||
# List of allowed image formats
|
||||
allowed_image_types = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/svg+xml",
|
||||
]
|
||||
|
||||
# Check if it's an SVG file
|
||||
if filename.endswith(".svg") or mime_type == "image/svg+xml":
|
||||
try:
|
||||
# Reset file pointer and read content
|
||||
value.seek(0)
|
||||
content = value.read()
|
||||
value.seek(0) # Reset for later use
|
||||
|
||||
# Try to parse as XML to ensure it's valid SVG
|
||||
ET.fromstring(content)
|
||||
return # Valid SVG
|
||||
except ET.ParseError:
|
||||
raise ValidationError("Invalid SVG file format")
|
||||
|
||||
# For non-SVG files, check MIME type
|
||||
if mime_type not in allowed_image_types:
|
||||
raise ValidationError(
|
||||
f"Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, BMP, TIFF, SVG"
|
||||
)
|
||||
if filesize > 1 * 1024 * 1024: # 1MB
|
||||
raise ValidationError("Maximum file size is 1MB")
|
||||
|
||||
|
||||
class Currency(models.TextChoices):
|
||||
|
|
@ -106,11 +29,6 @@ class Unit(models.TextChoices):
|
|||
CPU = "CPU", "vCPU"
|
||||
|
||||
|
||||
class PartnerCategory(models.TextChoices):
|
||||
CONSULTING = "CONSULTING", "Consulting"
|
||||
TRAINING = "TRAINING", "Training"
|
||||
|
||||
|
||||
# This should be a relation, but for now this is good enough :TM:
|
||||
class ManagedServiceProvider(models.TextChoices):
|
||||
VS = "VS", "VSHN"
|
||||
|
|
@ -125,7 +43,7 @@ class ReusableText(models.Model):
|
|||
blank=True,
|
||||
related_name="children",
|
||||
)
|
||||
text = get_prose_editor_field()
|
||||
text = ProseEditorField()
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from django.db import models
|
||||
from .base import get_prose_editor_field
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
|
||||
|
||||
class WebsiteFaq(models.Model):
|
||||
question = models.CharField(max_length=200)
|
||||
answer = get_prose_editor_field()
|
||||
answer = ProseEditorField()
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -1,320 +0,0 @@
|
|||
import os
|
||||
import mimetypes
|
||||
import xml.etree.ElementTree as ET
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.text import slugify
|
||||
from PIL import Image as PILImage
|
||||
from .base import validate_image_or_svg
|
||||
|
||||
|
||||
def get_image_upload_path(instance, filename):
|
||||
"""
|
||||
Generate upload path for images based on the image library structure.
|
||||
"""
|
||||
return f"image_library/{filename}"
|
||||
|
||||
|
||||
class ImageLibrary(models.Model):
|
||||
"""
|
||||
Generic image library model that can be referenced by other models
|
||||
to avoid duplicate uploads and provide centralized image management.
|
||||
"""
|
||||
|
||||
# Image metadata
|
||||
name = models.CharField(max_length=200, help_text="Descriptive name for the image")
|
||||
slug = models.SlugField(
|
||||
max_length=250, unique=True, help_text="URL-friendly version of the name"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True, help_text="Optional description of the image"
|
||||
)
|
||||
alt_text = models.CharField(
|
||||
max_length=255, help_text="Alternative text for accessibility"
|
||||
)
|
||||
|
||||
# Image file
|
||||
image = models.FileField(
|
||||
upload_to=get_image_upload_path,
|
||||
validators=[validate_image_or_svg],
|
||||
help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG",
|
||||
)
|
||||
|
||||
# Image properties (automatically populated)
|
||||
width = models.PositiveIntegerField(
|
||||
null=True, blank=True, help_text="Image width in pixels"
|
||||
)
|
||||
height = models.PositiveIntegerField(
|
||||
null=True, blank=True, help_text="Image height in pixels"
|
||||
)
|
||||
file_size = models.PositiveIntegerField(
|
||||
null=True, blank=True, help_text="File size in bytes"
|
||||
)
|
||||
|
||||
# Categorization
|
||||
CATEGORY_CHOICES = [
|
||||
("logo", "Logo"),
|
||||
("article", "Article Image"),
|
||||
("banner", "Banner"),
|
||||
("icon", "Icon"),
|
||||
("screenshot", "Screenshot"),
|
||||
("photo", "Photo"),
|
||||
("other", "Other"),
|
||||
]
|
||||
category = models.CharField(
|
||||
max_length=20,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default="other",
|
||||
help_text="Category of the image",
|
||||
)
|
||||
|
||||
# Tags for easier searching
|
||||
tags = models.CharField(
|
||||
max_length=500, blank=True, help_text="Comma-separated tags for searching"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
uploaded_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="User who uploaded the image",
|
||||
)
|
||||
uploaded_at = models.DateTimeField(
|
||||
auto_now_add=True, help_text="Date and time when image was uploaded"
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True, help_text="Date and time when image was last updated"
|
||||
)
|
||||
|
||||
# Usage tracking
|
||||
usage_count = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of times this image is referenced"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-uploaded_at"]
|
||||
verbose_name = "Image"
|
||||
verbose_name_plural = "Image Library"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save to automatically populate image properties and slug.
|
||||
"""
|
||||
# Generate slug if not provided
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
# Save the model first to get the image file
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update image properties if image exists
|
||||
if self.image:
|
||||
self._update_image_properties()
|
||||
|
||||
def _update_image_properties(self):
|
||||
"""
|
||||
Update image properties like width, height, and file size.
|
||||
"""
|
||||
try:
|
||||
# Get file size
|
||||
self.file_size = self.image.size
|
||||
|
||||
# Check if it's an SVG file
|
||||
filename = self.image.name.lower()
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
|
||||
if filename.endswith(".svg") or mime_type == "image/svg+xml":
|
||||
# For SVG files, try to extract dimensions from the SVG content
|
||||
try:
|
||||
with open(self.image.path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse the SVG to extract width and height
|
||||
root = ET.fromstring(content)
|
||||
|
||||
# Get width and height attributes
|
||||
width = root.get("width")
|
||||
height = root.get("height")
|
||||
|
||||
# Extract numeric values if they exist
|
||||
if width and height:
|
||||
# Remove units like 'px', 'em', etc. and convert to int
|
||||
try:
|
||||
width_val = int(
|
||||
float(
|
||||
width.replace("px", "")
|
||||
.replace("em", "")
|
||||
.replace("pt", "")
|
||||
)
|
||||
)
|
||||
height_val = int(
|
||||
float(
|
||||
height.replace("px", "")
|
||||
.replace("em", "")
|
||||
.replace("pt", "")
|
||||
)
|
||||
)
|
||||
self.width = width_val
|
||||
self.height = height_val
|
||||
except (ValueError, TypeError):
|
||||
# If we can't parse dimensions, try viewBox
|
||||
viewbox = root.get("viewBox")
|
||||
if viewbox:
|
||||
try:
|
||||
viewbox_parts = viewbox.split()
|
||||
if len(viewbox_parts) >= 4:
|
||||
self.width = int(float(viewbox_parts[2]))
|
||||
self.height = int(float(viewbox_parts[3]))
|
||||
except (ValueError, TypeError):
|
||||
# Default SVG dimensions if we can't parse
|
||||
self.width = 100
|
||||
self.height = 100
|
||||
else:
|
||||
# Check for viewBox if width/height attributes don't exist
|
||||
viewbox = root.get("viewBox")
|
||||
if viewbox:
|
||||
try:
|
||||
viewbox_parts = viewbox.split()
|
||||
if len(viewbox_parts) >= 4:
|
||||
self.width = int(float(viewbox_parts[2]))
|
||||
self.height = int(float(viewbox_parts[3]))
|
||||
except (ValueError, TypeError):
|
||||
self.width = 100
|
||||
self.height = 100
|
||||
else:
|
||||
# Default SVG dimensions
|
||||
self.width = 100
|
||||
self.height = 100
|
||||
|
||||
except (ET.ParseError, FileNotFoundError, UnicodeDecodeError):
|
||||
# If SVG parsing fails, set default dimensions
|
||||
self.width = 100
|
||||
self.height = 100
|
||||
else:
|
||||
# For raster images, use PIL
|
||||
with PILImage.open(self.image.path) as img:
|
||||
self.width = img.width
|
||||
self.height = img.height
|
||||
|
||||
# Save without calling the full save method to avoid recursion
|
||||
ImageLibrary.objects.filter(pk=self.pk).update(
|
||||
width=self.width, height=self.height, file_size=self.file_size
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the save
|
||||
print(f"Error updating image properties: {e}")
|
||||
|
||||
def get_file_size_display(self):
|
||||
"""
|
||||
Return human-readable file size.
|
||||
"""
|
||||
if not self.file_size:
|
||||
return "Unknown"
|
||||
|
||||
size = self.file_size
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
def get_tags_list(self):
|
||||
"""
|
||||
Return tags as a list.
|
||||
"""
|
||||
if not self.tags:
|
||||
return []
|
||||
return [tag.strip() for tag in self.tags.split(",") if tag.strip()]
|
||||
|
||||
def increment_usage(self):
|
||||
"""
|
||||
Increment usage count when image is referenced.
|
||||
"""
|
||||
self.usage_count += 1
|
||||
self.save(update_fields=["usage_count"])
|
||||
|
||||
def decrement_usage(self):
|
||||
"""
|
||||
Decrement usage count when reference is removed.
|
||||
"""
|
||||
if self.usage_count > 0:
|
||||
self.usage_count -= 1
|
||||
self.save(update_fields=["usage_count"])
|
||||
|
||||
def is_svg(self):
|
||||
"""
|
||||
Check if the uploaded file is an SVG.
|
||||
"""
|
||||
if not self.image:
|
||||
return False
|
||||
|
||||
filename = self.image.name.lower()
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
|
||||
return filename.endswith(".svg") or mime_type == "image/svg+xml"
|
||||
|
||||
def get_mime_type(self):
|
||||
"""
|
||||
Return the MIME type of the image file.
|
||||
"""
|
||||
if not self.image:
|
||||
return None
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(self.image.name)
|
||||
return mime_type
|
||||
|
||||
|
||||
class ImageReference(models.Model):
|
||||
"""
|
||||
Abstract base class for models that want to reference images from the library.
|
||||
This helps track usage and provides a consistent interface.
|
||||
"""
|
||||
|
||||
image_library = models.ForeignKey(
|
||||
ImageLibrary,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
related_name="%(class)s_references",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save to update usage count.
|
||||
"""
|
||||
# Track if image changed
|
||||
old_image = None
|
||||
if self.pk:
|
||||
try:
|
||||
old_instance = self.__class__.objects.get(pk=self.pk)
|
||||
old_image = old_instance.image_library
|
||||
except self.__class__.DoesNotExist:
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update usage counts
|
||||
if old_image and old_image != self.image_library:
|
||||
old_image.decrement_usage()
|
||||
|
||||
if self.image_library and self.image_library != old_image:
|
||||
self.image_library.increment_usage()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""
|
||||
Override delete to update usage count.
|
||||
"""
|
||||
if self.image_library:
|
||||
self.image_library.decrement_usage()
|
||||
super().delete(*args, **kwargs)
|
||||
|
|
@ -1,20 +1,26 @@
|
|||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
|
||||
from .base import validate_image_size, get_prose_editor_field, PartnerCategory
|
||||
from .images import ImageReference
|
||||
from .base import validate_image_size
|
||||
|
||||
|
||||
class CloudProvider(ImageReference):
|
||||
class CloudProvider(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = get_prose_editor_field()
|
||||
description = ProseEditorField()
|
||||
website = models.URLField()
|
||||
linkedin = models.URLField(blank=True)
|
||||
phone = models.CharField(max_length=25, blank=True, null=True)
|
||||
email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
address = models.TextField(max_length=250, blank=True, null=True)
|
||||
logo = models.ImageField(
|
||||
upload_to="cloud_provider_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
order = models.IntegerField(default=0)
|
||||
is_featured = models.BooleanField(default=False)
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
|
@ -33,32 +39,23 @@ class CloudProvider(ImageReference):
|
|||
def get_absolute_url(self):
|
||||
return reverse("services:provider_detail", kwargs={"slug": self.slug})
|
||||
|
||||
@property
|
||||
def get_logo(self):
|
||||
"""Returns the logo from the library"""
|
||||
if self.image_library and self.image_library.image:
|
||||
return self.image_library.image
|
||||
return None
|
||||
|
||||
|
||||
class ConsultingPartner(ImageReference):
|
||||
class ConsultingPartner(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = get_prose_editor_field()
|
||||
description = ProseEditorField()
|
||||
logo = models.ImageField(
|
||||
upload_to="partner_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
website = models.URLField(blank=True)
|
||||
linkedin = models.URLField(blank=True)
|
||||
phone = models.CharField(max_length=25, blank=True, null=True)
|
||||
email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
address = models.TextField(max_length=250, blank=True, null=True)
|
||||
|
||||
# Partner category (hardcoded choices as requested)
|
||||
category = models.CharField(
|
||||
max_length=20,
|
||||
choices=PartnerCategory.choices,
|
||||
default=PartnerCategory.CONSULTING,
|
||||
help_text="Category of the partner",
|
||||
)
|
||||
|
||||
services = models.ManyToManyField(
|
||||
"services.Service", related_name="consulting_partners", blank=True
|
||||
)
|
||||
|
|
@ -77,10 +74,7 @@ class ConsultingPartner(ImageReference):
|
|||
ordering = ["order"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_category_display()})"
|
||||
|
||||
def get_category_display_badge(self):
|
||||
return f"Servala {self.get_category_display()} Partner"
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
|
|
@ -89,10 +83,3 @@ class ConsultingPartner(ImageReference):
|
|||
|
||||
def get_absolute_url(self):
|
||||
return reverse("services:partner_detail", kwargs={"slug": self.slug})
|
||||
|
||||
@property
|
||||
def get_logo(self):
|
||||
"""Returns the logo from the library"""
|
||||
if self.image_library and self.image_library.image:
|
||||
return self.image_library.image
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -4,26 +4,28 @@ from django.core.validators import URLValidator
|
|||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from .base import (
|
||||
Category,
|
||||
ReusableText,
|
||||
ManagedServiceProvider,
|
||||
validate_image_size,
|
||||
Currency,
|
||||
get_prose_editor_field,
|
||||
)
|
||||
from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size, Currency
|
||||
from .providers import CloudProvider
|
||||
from .images import ImageReference
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .services import PlanPrice
|
||||
|
||||
|
||||
class Service(ImageReference):
|
||||
class Service(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=250, unique=True)
|
||||
description = get_prose_editor_field()
|
||||
description = ProseEditorField()
|
||||
tagline = models.TextField(max_length=500, blank=True, null=True)
|
||||
logo = models.ImageField(
|
||||
upload_to="service_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
categories = models.ManyToManyField(Category, related_name="services")
|
||||
features = get_prose_editor_field()
|
||||
features = ProseEditorField()
|
||||
is_featured = models.BooleanField(default=False)
|
||||
is_coming_soon = models.BooleanField(default=False)
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
|
@ -54,13 +56,6 @@ class Service(ImageReference):
|
|||
def get_absolute_url(self):
|
||||
return reverse("services:service_detail", kwargs={"slug": self.slug})
|
||||
|
||||
@property
|
||||
def get_logo(self):
|
||||
"""Returns the logo from the library"""
|
||||
if self.image_library and self.image_library.image:
|
||||
return self.image_library.image
|
||||
return None
|
||||
|
||||
|
||||
class ServiceOffering(models.Model):
|
||||
service = models.ForeignKey(
|
||||
|
|
@ -75,7 +70,7 @@ class ServiceOffering(models.Model):
|
|||
cloud_provider = models.ForeignKey(
|
||||
CloudProvider, on_delete=models.CASCADE, related_name="offerings"
|
||||
)
|
||||
description = get_prose_editor_field(blank=True, null=True)
|
||||
description = ProseEditorField(blank=True, null=True)
|
||||
offer_description = models.ForeignKey(
|
||||
ReusableText,
|
||||
on_delete=models.PROTECT,
|
||||
|
|
@ -108,7 +103,7 @@ class ServiceOffering(models.Model):
|
|||
|
||||
class PlanPrice(models.Model):
|
||||
plan = models.ForeignKey(
|
||||
"Plan", on_delete=models.CASCADE, related_name="plan_prices"
|
||||
'Plan', on_delete=models.CASCADE, related_name='plan_prices'
|
||||
)
|
||||
currency = models.CharField(
|
||||
max_length=3,
|
||||
|
|
@ -130,7 +125,7 @@ class PlanPrice(models.Model):
|
|||
|
||||
class Plan(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
description = get_prose_editor_field(blank=True, null=True)
|
||||
description = ProseEditorField(blank=True, null=True)
|
||||
plan_description = models.ForeignKey(
|
||||
ReusableText,
|
||||
on_delete=models.PROTECT,
|
||||
|
|
@ -142,43 +137,18 @@ class Plan(models.Model):
|
|||
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
|
||||
)
|
||||
|
||||
# Ordering and highlighting fields
|
||||
order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Order of this plan in the offering (lower numbers appear first)",
|
||||
)
|
||||
is_best = models.BooleanField(
|
||||
default=False, help_text="Mark this plan as the best/recommended option"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "name"]
|
||||
ordering = ["name"]
|
||||
unique_together = [["offering", "name"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.offering} - {self.name}"
|
||||
|
||||
def clean(self):
|
||||
# Ensure only one plan per offering can be marked as "best"
|
||||
if self.is_best:
|
||||
existing_best = Plan.objects.filter(
|
||||
offering=self.offering, is_best=True
|
||||
).exclude(pk=self.pk)
|
||||
if existing_best.exists():
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
raise ValidationError(
|
||||
"Only one plan per offering can be marked as the best option."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_price(self, currency_code: str):
|
||||
def get_price(self, currency_code: str) -> Optional[float]:
|
||||
from hub.services.models.services import PlanPrice
|
||||
price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
|
||||
if price_obj:
|
||||
return price_obj.amount
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
/* CSS for Image Library Admin */
|
||||
|
||||
/* Thumbnail styling in list view */
|
||||
.image-thumbnail {
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Preview styling in detail view */
|
||||
.image-preview {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Form styling */
|
||||
.image-library-form .form-row {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.image-library-form .help {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Usage count styling */
|
||||
.usage-count {
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.usage-count.high {
|
||||
color: #cc0000;
|
||||
}
|
||||
|
||||
/* Category badges */
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.category-badge.logo {
|
||||
background-color: #e8f4f8;
|
||||
color: #2c6e92;
|
||||
}
|
||||
|
||||
.category-badge.article {
|
||||
background-color: #f0f8e8;
|
||||
color: #5a7c3a;
|
||||
}
|
||||
|
||||
.category-badge.banner {
|
||||
background-color: #fef4e8;
|
||||
color: #d2691e;
|
||||
}
|
||||
|
||||
.category-badge.icon {
|
||||
background-color: #f8e8f8;
|
||||
color: #8b4c8b;
|
||||
}
|
||||
|
||||
.category-badge.screenshot {
|
||||
background-color: #e8f8f4;
|
||||
color: #3a7c5a;
|
||||
}
|
||||
|
||||
.category-badge.photo {
|
||||
background-color: #f4e8f8;
|
||||
color: #923c92;
|
||||
}
|
||||
|
||||
.category-badge.other {
|
||||
background-color: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* SVG support */
|
||||
.svg-preview {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.svg-preview object {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* SVG thumbnails in admin */
|
||||
.image-thumbnail object {
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.image-preview object {
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Category badges */
|
||||
.category-badge.svg {
|
||||
background-color: #f3e8ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
|
@ -33,46 +33,4 @@
|
|||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Subtle styling for the best plan */
|
||||
.card.border-success.border-2 {
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.1) !important;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card.border-success.border-2:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(25, 135, 84, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Best choice badge styling */
|
||||
.badge.bg-success {
|
||||
border: 2px solid white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.151);
|
||||
color: rgb(255, 255, 255);
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/* Subtle enhancement for best plan button */
|
||||
.btn-success.shadow {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-success.shadow:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Ensure collapse starts properly hidden */
|
||||
#managedServiceIncludes {
|
||||
transition: all 0.35s ease;
|
||||
}
|
||||
|
||||
#managedServiceIncludes:not(.show) {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -1,949 +0,0 @@
|
|||
.calculator-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.input-group-custom {
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
/* Ensure proper stacking context */
|
||||
}
|
||||
|
||||
.input-group-custom label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Ensure input groups don't interfere with tooltips */
|
||||
.input-group {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 5px;
|
||||
background: #ddd;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #007bff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #007bff;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.scenario-card.active {
|
||||
border-color: #007bff;
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
|
||||
.scenario-card.disabled {
|
||||
opacity: 0.6;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Enhanced chart sizing for new layout */
|
||||
.chart-container canvas {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
/* Full-width chart containers */
|
||||
.card-body canvas {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Primary chart gets extra height */
|
||||
#instanceGrowthChart {
|
||||
height: 500px !important;
|
||||
}
|
||||
|
||||
/* Secondary charts get good height */
|
||||
#revenueChart, #cashFlowChart, #cspRevenueChart {
|
||||
height: 400px !important;
|
||||
}
|
||||
|
||||
/* Enhanced layout styles for new design */
|
||||
.sticky-top {
|
||||
z-index: 1020;
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Compact header controls */
|
||||
.form-range {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.input-group-sm .form-control,
|
||||
.form-select-sm,
|
||||
.btn-sm {
|
||||
font-size: 0.825rem;
|
||||
}
|
||||
|
||||
/* Clean chart headers */
|
||||
.card-header {
|
||||
background: white !important;
|
||||
border: none !important;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Responsive chart heights */
|
||||
@media (max-width: 768px) {
|
||||
#instanceGrowthChart {
|
||||
height: 350px !important;
|
||||
}
|
||||
|
||||
#revenueChart, #cashFlowChart, #cspRevenueChart {
|
||||
height: 300px !important;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
#instanceGrowthChart {
|
||||
height: 250px !important;
|
||||
}
|
||||
|
||||
#revenueChart, #cashFlowChart, #cspRevenueChart {
|
||||
height: 200px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Manual collapse functionality */
|
||||
.collapse {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapse.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.collapsing {
|
||||
position: relative;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
transition: height 0.35s ease;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.collapsible-section {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.collapsible-header {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px 8px 0 0;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsible-header:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapsible-content.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.phase-settings {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.currency-symbol {
|
||||
font-weight: bold;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.help-content h6 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.help-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Bootstrap tooltip styling improvements */
|
||||
.tooltip {
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
z-index: 9999 !important;
|
||||
/* Ensure tooltips appear above all other elements */
|
||||
pointer-events: none;
|
||||
/* Prevent tooltip from interfering with mouse events */
|
||||
}
|
||||
|
||||
.tooltip .tooltip-inner {
|
||||
max-width: 250px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tooltip .tooltip-arrow {
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.tooltip.bs-tooltip-top .tooltip-arrow::before {
|
||||
border-top-color: #212529;
|
||||
}
|
||||
|
||||
.tooltip.bs-tooltip-bottom .tooltip-arrow::before {
|
||||
border-bottom-color: #212529;
|
||||
}
|
||||
|
||||
.tooltip.bs-tooltip-start .tooltip-arrow::before {
|
||||
border-left-color: #212529;
|
||||
}
|
||||
|
||||
.tooltip.bs-tooltip-end .tooltip-arrow::before {
|
||||
border-right-color: #212529;
|
||||
}
|
||||
|
||||
/* Enhanced cursor for tooltip elements */
|
||||
[data-bs-toggle="tooltip"] {
|
||||
cursor: help;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-bs-toggle="tooltip"]:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.metric-value {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Clean Dual Model Layout */
|
||||
.model-comparison-indicator {
|
||||
font-size: 1.2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Scenario selection enhancements */
|
||||
.form-check {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
|
||||
/* Model result boxes */
|
||||
.model-result-box {
|
||||
transition: all 0.2s ease;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.model-result-box:hover {
|
||||
background: #f0f0f0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Enhanced main configuration styling */
|
||||
.main-config-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.main-config-fields {
|
||||
padding: 1.5rem;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.main-config-fields .form-label {
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.main-config-fields .input-group-text {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #dee2e6;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.main-config-fields .form-control {
|
||||
border-color: #dee2e6;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.main-config-fields .form-control:focus {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Investment amount input enhancements */
|
||||
#investment-amount {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
#investment-amount:focus {
|
||||
text-align: left;
|
||||
padding-left: 1rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
#investment-amount::placeholder {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.main-config-fields .form-select {
|
||||
border-color: #dee2e6;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.main-config-fields .form-select:focus {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Results Panel Styling */
|
||||
.results-panel {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.results-panel .card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.results-panel .card-header {
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
border-bottom: none;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.results-panel .card-body {
|
||||
padding: 2rem 1.5rem;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
transition: all 0.3s ease;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background: rgba(0, 123, 255, 0.02);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.result-item:hover .result-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.result-metrics {
|
||||
border: 1px solid #dee2e6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.result-item:hover .result-metrics {
|
||||
border-color: #007bff;
|
||||
background: rgba(248, 249, 250, 0.8) !important;
|
||||
}
|
||||
|
||||
/* Enhanced form styling */
|
||||
.form-check-lg .form-check-input {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.form-check-lg .form-check-label {
|
||||
font-size: 1.1rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Enhanced range sliders */
|
||||
.form-range {
|
||||
height: 8px;
|
||||
background: linear-gradient(to right, #e9ecef 0%, #dee2e6 100%);
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.form-range::-webkit-slider-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-range::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
|
||||
}
|
||||
|
||||
.form-range::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-range::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Button styling improvements */
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-outline-info:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
/* Advanced controls styling */
|
||||
.advanced-controls-section {
|
||||
background: #f8f9fa;
|
||||
border-top: 3px solid #007bff;
|
||||
}
|
||||
|
||||
.advanced-controls-section .card {
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.advanced-controls-section .card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.advanced-controls-section .card-header {
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* Slider styling improvements */
|
||||
.form-range {
|
||||
height: 6px;
|
||||
background: #e9ecef;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.form-range::-webkit-slider-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #007bff;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-range::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #007bff;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Real-time results cards */
|
||||
.results-card {
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.results-card:hover {
|
||||
border-color: #007bff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Growth scenario checkboxes */
|
||||
.form-check-input:checked {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-check-label:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
/* Responsive improvements */
|
||||
@media (max-width: 991px) {
|
||||
.results-panel {
|
||||
position: static;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.results-panel .card-body {
|
||||
min-height: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-config-fields {
|
||||
padding: 1rem;
|
||||
margin: 0 -15px;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.main-config-fields .form-label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.main-config-fields .form-control,
|
||||
.main-config-fields .form-select {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
font-size: 1rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
}
|
||||
|
||||
.form-check-lg .form-check-input {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.form-check-lg .form-check-label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.results-panel .card-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.results-panel .card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
width: 35px !important;
|
||||
height: 35px !important;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.advanced-controls-section .card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.advanced-controls-section .card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.d-flex.gap-3.flex-wrap {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem !important;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-check-lg {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.results-panel .card-body {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.result-metrics .row {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-metrics .col-6 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Model comparison box */
|
||||
.model-comparison-box {
|
||||
border: 1px solid #dee2e6;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.model-comparison-box:hover {
|
||||
border-color: #adb5bd;
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
/* Control section styling */
|
||||
.controls-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Enhanced form controls */
|
||||
.form-range {
|
||||
height: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-range::-webkit-slider-thumb {
|
||||
background: #0d6efd;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.form-range::-moz-range-thumb {
|
||||
background: #0d6efd;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Button group styling */
|
||||
.btn-group-enhanced .btn {
|
||||
border-radius: 4px;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-group-enhanced .btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.controls-section {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.model-result-box {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced table styling for financial analysis */
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table-success {
|
||||
--bs-table-bg: rgba(40, 167, 69, 0.1);
|
||||
}
|
||||
|
||||
.table-warning {
|
||||
--bs-table-bg: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
/* Tab styling improvements */
|
||||
.nav-tabs .nav-link {
|
||||
border: 1px solid transparent;
|
||||
border-top-left-radius: 0.375rem;
|
||||
border-top-right-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border-color: #dee2e6 #dee2e6 #fff;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover:not(.active) {
|
||||
border-color: #e9ecef #e9ecef #dee2e6;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Table cell improvements */
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table th {
|
||||
border-top: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Better responsive tables */
|
||||
@media (max-width: 768px) {
|
||||
.table-responsive {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Monthly Breakdown Filter Controls */
|
||||
.breakdown-filters {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.breakdown-filters .form-check {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.breakdown-filters .form-check-input:checked {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
|
||||
.breakdown-filters .form-check-label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Responsive breakdown filters */
|
||||
@media (max-width: 768px) {
|
||||
.breakdown-filters {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.breakdown-filters .d-flex {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced sticky header for monthly breakdown table */
|
||||
.table-responsive {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-responsive .sticky-top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: #212529 !important; /* Ensure dark background stays */
|
||||
}
|
||||
|
||||
.table-responsive .sticky-top th {
|
||||
background-color: #212529 !important;
|
||||
border-color: #454d55 !important;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Ensure sticky header works in scrollable containers */
|
||||
.table-responsive[style*="max-height"] .sticky-top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* Additional styling for better visibility */
|
||||
.table-dark th {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-dark th:first-child {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-dark th:nth-child(n+4) {
|
||||
text-align: right;
|
||||
}
|
||||
|
|
@ -351,8 +351,7 @@ dl {
|
|||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol,
|
||||
li p {
|
||||
ul ol {
|
||||
margin-bottom: 0
|
||||
}
|
||||
|
||||
|
|
@ -12535,8 +12534,4 @@ a.btn:focus {
|
|||
.article-content a {
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.article-content h2 {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 316 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 32 KiB |
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
398
hub/services/static/js/jspdf.umd.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -1,176 +0,0 @@
|
|||
/**
|
||||
* Addon Manager - Handles addon functionality
|
||||
*/
|
||||
class AddonManager {
|
||||
constructor(pricingDataManager) {
|
||||
this.pricingDataManager = pricingDataManager;
|
||||
}
|
||||
|
||||
// Update addons based on current configuration
|
||||
updateAddons(domManager) {
|
||||
const addonsContainer = domManager.get('addonsContainer');
|
||||
const addonsData = this.pricingDataManager.getAddonsData();
|
||||
|
||||
if (!addonsContainer || !addonsData) {
|
||||
// Hide addons section if no container or data
|
||||
const addonsSection = document.getElementById('addonsSection');
|
||||
if (addonsSection) addonsSection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceLevel = domManager.getSelectedServiceLevel();
|
||||
if (!serviceLevel || !addonsData[serviceLevel]) {
|
||||
// Hide addons section if no service level or no addons for this level
|
||||
const addonsSection = document.getElementById('addonsSection');
|
||||
if (addonsSection) addonsSection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const addons = addonsData[serviceLevel];
|
||||
|
||||
// Clear existing addons
|
||||
addonsContainer.innerHTML = '';
|
||||
|
||||
// Show or hide addons section based on availability
|
||||
const addonsSection = document.getElementById('addonsSection');
|
||||
if (addons && addons.length > 0) {
|
||||
if (addonsSection) addonsSection.style.display = 'block';
|
||||
} else {
|
||||
if (addonsSection) addonsSection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Add each addon
|
||||
addons.forEach(addon => {
|
||||
const addonElement = document.createElement('div');
|
||||
addonElement.className = `addon-item mb-2 p-2 border rounded ${addon.is_mandatory ? 'bg-light' : ''}`;
|
||||
|
||||
addonElement.innerHTML = `
|
||||
<div class="form-check">
|
||||
<input class="form-check-input addon-checkbox"
|
||||
type="checkbox"
|
||||
id="addon-${addon.id}"
|
||||
value="${addon.id}"
|
||||
data-addon='${JSON.stringify(addon)}'
|
||||
${addon.is_mandatory ? 'checked disabled' : ''}>
|
||||
<label class="form-check-label" for="addon-${addon.id}">
|
||||
<strong>${addon.name}</strong>
|
||||
<div class="text-muted small">${addon.commercial_description || ''}</div>
|
||||
<div class="text-primary addon-price-display">
|
||||
${addon.is_mandatory ? 'Required - ' : ''}CHF <span class="addon-price-value">0.00</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
addonsContainer.appendChild(addonElement);
|
||||
|
||||
// Add event listener for optional addons
|
||||
if (!addon.is_mandatory) {
|
||||
const checkbox = addonElement.querySelector('.addon-checkbox');
|
||||
checkbox.addEventListener('change', () => {
|
||||
// Update addon prices and recalculate total
|
||||
this.updateAddonPrices(domManager);
|
||||
// Trigger pricing update through custom event
|
||||
window.dispatchEvent(new CustomEvent('addon-changed'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update addon prices
|
||||
this.updateAddonPrices(domManager);
|
||||
}
|
||||
|
||||
// Update addon prices based on current configuration
|
||||
updateAddonPrices(domManager, planManager) {
|
||||
const addonsContainer = domManager.get('addonsContainer');
|
||||
if (!addonsContainer) return;
|
||||
|
||||
const config = domManager.getCurrentConfiguration();
|
||||
|
||||
// Find the current plan data to get variable_unit for addon calculations
|
||||
const matchedPlan = planManager ? planManager.getCurrentPlan(domManager) : null;
|
||||
const variableUnit = matchedPlan?.variable_unit || 'CPU';
|
||||
const units = variableUnit === 'CPU' ? config.cpus : config.memory;
|
||||
const totalUnits = units * config.instances;
|
||||
|
||||
const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox');
|
||||
addonCheckboxes.forEach(checkbox => {
|
||||
const addon = JSON.parse(checkbox.dataset.addon);
|
||||
const priceElement = checkbox.parentElement.querySelector('.addon-price-value');
|
||||
|
||||
let calculatedPrice = 0;
|
||||
|
||||
// Calculate addon price based on type
|
||||
if (addon.addon_type === 'BASE_FEE') {
|
||||
// Base fee: price per instance
|
||||
calculatedPrice = parseFloat(addon.price || 0) * config.instances;
|
||||
} else if (addon.addon_type === 'UNIT_RATE') {
|
||||
// Unit rate: price per unit (CPU or memory) across all instances
|
||||
calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits;
|
||||
}
|
||||
|
||||
// Update the display price
|
||||
if (priceElement) {
|
||||
priceElement.textContent = calculatedPrice.toFixed(2);
|
||||
}
|
||||
|
||||
// Store the calculated price for later use in total calculations
|
||||
checkbox.dataset.calculatedPrice = calculatedPrice.toString();
|
||||
});
|
||||
}
|
||||
|
||||
// Get selected addons with their calculated prices
|
||||
getSelectedAddons(domManager) {
|
||||
const addonsContainer = domManager.get('addonsContainer');
|
||||
if (!addonsContainer) return { mandatory: [], optional: [] };
|
||||
|
||||
const mandatoryAddons = [];
|
||||
const selectedOptionalAddons = [];
|
||||
|
||||
const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox');
|
||||
addonCheckboxes.forEach(checkbox => {
|
||||
const addon = JSON.parse(checkbox.dataset.addon);
|
||||
const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0);
|
||||
|
||||
if (addon.is_mandatory) {
|
||||
mandatoryAddons.push({
|
||||
name: addon.name,
|
||||
price: calculatedPrice.toFixed(2)
|
||||
});
|
||||
} else if (checkbox.checked) {
|
||||
selectedOptionalAddons.push({
|
||||
name: addon.name,
|
||||
price: calculatedPrice.toFixed(2)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
mandatory: mandatoryAddons,
|
||||
optional: selectedOptionalAddons
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate total optional addon price
|
||||
calculateOptionalAddonTotal(domManager) {
|
||||
const addonsContainer = domManager.get('addonsContainer');
|
||||
if (!addonsContainer) return 0;
|
||||
|
||||
let total = 0;
|
||||
const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox');
|
||||
|
||||
addonCheckboxes.forEach(checkbox => {
|
||||
const addon = JSON.parse(checkbox.dataset.addon);
|
||||
if (!addon.is_mandatory && checkbox.checked) {
|
||||
const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0);
|
||||
total += calculatedPrice;
|
||||
}
|
||||
});
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
window.AddonManager = AddonManager;
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
/**
|
||||
* DOM Manager - Handles DOM element references and basic manipulation
|
||||
*/
|
||||
class DOMManager {
|
||||
constructor() {
|
||||
this.elements = {};
|
||||
this.initElements();
|
||||
}
|
||||
|
||||
// Initialize DOM element references
|
||||
initElements() {
|
||||
// Calculator controls
|
||||
this.elements.cpuRange = document.getElementById('cpuRange');
|
||||
this.elements.memoryRange = document.getElementById('memoryRange');
|
||||
this.elements.storageRange = document.getElementById('storageRange');
|
||||
this.elements.instancesRange = document.getElementById('instancesRange');
|
||||
this.elements.cpuValue = document.getElementById('cpuValue');
|
||||
this.elements.memoryValue = document.getElementById('memoryValue');
|
||||
this.elements.storageValue = document.getElementById('storageValue');
|
||||
this.elements.instancesValue = document.getElementById('instancesValue');
|
||||
this.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
|
||||
this.elements.planSelect = document.getElementById('planSelect');
|
||||
|
||||
// Addon elements
|
||||
this.elements.addonsContainer = document.getElementById('addonsContainer');
|
||||
this.elements.addonPricingContainer = document.getElementById('addonPricingContainer');
|
||||
this.elements.managedServiceIncludesContainer = document.getElementById('managedServiceIncludesContainer');
|
||||
this.elements.managedServiceIncludes = document.getElementById('managedServiceIncludes');
|
||||
this.elements.managedServiceToggleButton = document.querySelector('button[data-bs-target="#managedServiceIncludes"]');
|
||||
|
||||
// Result display elements
|
||||
this.elements.planMatchStatus = document.getElementById('planMatchStatus');
|
||||
this.elements.selectedPlanDetails = document.getElementById('selectedPlanDetails');
|
||||
this.elements.noMatchFound = document.getElementById('noMatchFound');
|
||||
|
||||
// Plan detail elements
|
||||
this.elements.planGroup = document.getElementById('planGroup');
|
||||
this.elements.planName = document.getElementById('planName');
|
||||
this.elements.planDescription = document.getElementById('planDescription');
|
||||
this.elements.planCpus = document.getElementById('planCpus');
|
||||
this.elements.planMemory = document.getElementById('planMemory');
|
||||
this.elements.planInstances = document.getElementById('planInstances');
|
||||
this.elements.planServiceLevel = document.getElementById('planServiceLevel');
|
||||
this.elements.managedServicePrice = document.getElementById('managedServicePrice');
|
||||
this.elements.storagePriceEl = document.getElementById('storagePrice');
|
||||
this.elements.storageAmount = document.getElementById('storageAmount');
|
||||
this.elements.totalPrice = document.getElementById('totalPrice');
|
||||
|
||||
// Order button
|
||||
this.elements.orderButton = document.querySelector('a[href="#order-form"]');
|
||||
|
||||
// Service level group
|
||||
this.elements.serviceLevelGroup = document.getElementById('serviceLevelGroup');
|
||||
}
|
||||
|
||||
// Get element by key with error handling
|
||||
get(key) {
|
||||
const element = this.elements[key];
|
||||
if (!element && key !== 'addonsContainer') {
|
||||
console.warn(`DOM element '${key}' not found`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
// Check if element exists and is valid
|
||||
has(key) {
|
||||
return this.elements[key] && this.elements[key] !== null;
|
||||
}
|
||||
|
||||
// Update slider display values (min/max text below sliders)
|
||||
updateSliderDisplayValues() {
|
||||
// Update CPU slider display
|
||||
if (this.elements.cpuRange) {
|
||||
const cpuMinDisplay = document.getElementById('cpuMinDisplay');
|
||||
const cpuMaxDisplay = document.getElementById('cpuMaxDisplay');
|
||||
if (cpuMinDisplay) cpuMinDisplay.textContent = this.elements.cpuRange.min;
|
||||
if (cpuMaxDisplay) cpuMaxDisplay.textContent = this.elements.cpuRange.max;
|
||||
}
|
||||
|
||||
// Update Memory slider display
|
||||
if (this.elements.memoryRange) {
|
||||
const memoryMinDisplay = document.getElementById('memoryMinDisplay');
|
||||
const memoryMaxDisplay = document.getElementById('memoryMaxDisplay');
|
||||
if (memoryMinDisplay) memoryMinDisplay.textContent = this.elements.memoryRange.min;
|
||||
if (memoryMaxDisplay) memoryMaxDisplay.textContent = this.elements.memoryRange.max;
|
||||
}
|
||||
|
||||
// Update Storage slider display
|
||||
if (this.elements.storageRange) {
|
||||
const storageMinDisplay = document.getElementById('storageMinDisplay');
|
||||
const storageMaxDisplay = document.getElementById('storageMaxDisplay');
|
||||
if (storageMinDisplay) storageMinDisplay.textContent = this.elements.storageRange.min;
|
||||
if (storageMaxDisplay) storageMaxDisplay.textContent = this.elements.storageRange.max;
|
||||
}
|
||||
|
||||
// Update Instances slider display
|
||||
if (this.elements.instancesRange) {
|
||||
const instancesMinDisplay = document.getElementById('instancesMinDisplay');
|
||||
const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
|
||||
if (instancesMinDisplay) instancesMinDisplay.textContent = this.elements.instancesRange.min;
|
||||
if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.elements.instancesRange.max;
|
||||
}
|
||||
}
|
||||
|
||||
// Get slider container element by type
|
||||
getSliderContainer(type) {
|
||||
switch (type) {
|
||||
case 'cpu':
|
||||
return this.elements.cpuRange?.closest('.mb-4');
|
||||
case 'memory':
|
||||
return this.elements.memoryRange?.closest('.mb-4');
|
||||
case 'storage':
|
||||
return this.elements.storageRange?.closest('.mb-4');
|
||||
case 'instances':
|
||||
return this.elements.instancesRange?.closest('.mb-4');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset sliders to their default values
|
||||
resetSlidersToDefaults() {
|
||||
// Reset CPU slider to default value (0.5 vCPUs)
|
||||
if (this.elements.cpuRange) {
|
||||
this.elements.cpuRange.value = '0.5';
|
||||
if (this.elements.cpuValue) this.elements.cpuValue.textContent = '0.5';
|
||||
}
|
||||
|
||||
// Reset Memory slider to default value (1 GB)
|
||||
if (this.elements.memoryRange) {
|
||||
this.elements.memoryRange.value = '1';
|
||||
if (this.elements.memoryValue) this.elements.memoryValue.textContent = '1';
|
||||
}
|
||||
|
||||
// Reset Storage slider to default value (20 GB)
|
||||
if (this.elements.storageRange) {
|
||||
this.elements.storageRange.value = '20';
|
||||
if (this.elements.storageValue) this.elements.storageValue.textContent = '20';
|
||||
}
|
||||
|
||||
// Reset Instances slider to default value (1)
|
||||
if (this.elements.instancesRange) {
|
||||
this.elements.instancesRange.value = '1';
|
||||
if (this.elements.instancesValue) this.elements.instancesValue.textContent = '1';
|
||||
}
|
||||
}
|
||||
|
||||
// Set smart default values based on available plans
|
||||
setSmartDefaults(pricingDataManager) {
|
||||
const { cpuValues, memoryValues } = pricingDataManager.getAvailableSliderValues();
|
||||
|
||||
// Use the smallest available CPU value as default
|
||||
if (cpuValues.length > 0 && this.elements.cpuRange) {
|
||||
const defaultCpu = Math.min(...cpuValues);
|
||||
this.elements.cpuRange.value = defaultCpu;
|
||||
if (this.elements.cpuValue) this.elements.cpuValue.textContent = defaultCpu;
|
||||
}
|
||||
|
||||
// Use the smallest available memory value as default
|
||||
if (memoryValues.length > 0 && this.elements.memoryRange) {
|
||||
const defaultMemory = Math.min(...memoryValues);
|
||||
this.elements.memoryRange.value = defaultMemory;
|
||||
if (this.elements.memoryValue) this.elements.memoryValue.textContent = defaultMemory;
|
||||
}
|
||||
|
||||
// Keep existing defaults for storage and instances
|
||||
if (this.elements.storageRange) {
|
||||
this.elements.storageRange.value = '20';
|
||||
if (this.elements.storageValue) this.elements.storageValue.textContent = '20';
|
||||
}
|
||||
|
||||
if (this.elements.instancesRange) {
|
||||
this.elements.instancesRange.value = '1';
|
||||
if (this.elements.instancesValue) this.elements.instancesValue.textContent = '1';
|
||||
}
|
||||
}
|
||||
|
||||
// Get current selected service level
|
||||
getSelectedServiceLevel() {
|
||||
return document.querySelector('input[name="serviceLevel"]:checked')?.value;
|
||||
}
|
||||
|
||||
// Get current configuration values
|
||||
getCurrentConfiguration() {
|
||||
return {
|
||||
cpus: parseFloat(this.elements.cpuRange?.value || 0.5),
|
||||
memory: parseFloat(this.elements.memoryRange?.value || 1),
|
||||
storage: parseInt(this.elements.storageRange?.value || 20),
|
||||
instances: parseInt(this.elements.instancesRange?.value || 1),
|
||||
serviceLevel: this.getSelectedServiceLevel()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
window.DOMManager = DOMManager;
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
/**
|
||||
* Order Manager - Handles order form functionality
|
||||
*/
|
||||
class OrderManager {
|
||||
constructor() {
|
||||
this.selectedConfiguration = null;
|
||||
}
|
||||
|
||||
// Setup order button click handler
|
||||
setupOrderButton(domManager) {
|
||||
const orderButton = domManager.get('orderButton');
|
||||
if (orderButton) {
|
||||
orderButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleOrderClick();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle order button click
|
||||
handleOrderClick() {
|
||||
if (this.selectedConfiguration) {
|
||||
// Pre-fill the contact form with configuration details
|
||||
this.prefillContactForm();
|
||||
|
||||
// Scroll to the contact form
|
||||
const contactForm = document.getElementById('order-form');
|
||||
if (contactForm) {
|
||||
contactForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fill contact form with selected configuration
|
||||
prefillContactForm() {
|
||||
if (!this.selectedConfiguration) return;
|
||||
|
||||
const config = this.selectedConfiguration;
|
||||
|
||||
// Create configuration summary message
|
||||
const configMessage = this.generateConfigurationMessage(config);
|
||||
|
||||
// Find and fill the message textarea in the contact form
|
||||
const messageField = document.querySelector('#order-form textarea[name="message"]');
|
||||
if (messageField) {
|
||||
messageField.value = configMessage;
|
||||
}
|
||||
|
||||
// Find and fill alternative message field if the first one doesn't exist
|
||||
if (!messageField) {
|
||||
const altMessageField = document.querySelector('textarea[name="message"]');
|
||||
if (altMessageField) {
|
||||
altMessageField.value = configMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Store configuration details in hidden field if it exists
|
||||
const detailsField = document.querySelector('#order-form input[name="details"]');
|
||||
if (detailsField) {
|
||||
detailsField.value = JSON.stringify({
|
||||
plan: config.planName,
|
||||
vcpus: config.vcpus,
|
||||
memory: config.memory,
|
||||
storage: config.storage,
|
||||
instances: config.instances,
|
||||
serviceLevel: config.serviceLevel,
|
||||
totalPrice: config.totalPrice,
|
||||
addons: config.addons || []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate human-readable configuration message
|
||||
generateConfigurationMessage(config) {
|
||||
let message = `I would like to order the following configuration:
|
||||
|
||||
Plan: ${config.planName} (${config.planGroup})
|
||||
vCPUs: ${config.vcpus}
|
||||
Memory: ${config.memory} GB
|
||||
Storage: ${config.storage} GB
|
||||
Instances: ${config.instances}
|
||||
Service Level: ${config.serviceLevel}`;
|
||||
|
||||
// Add addons to the message if any are selected
|
||||
if (config.addons && config.addons.length > 0) {
|
||||
message += '\n\nSelected Add-ons:';
|
||||
config.addons.forEach(addon => {
|
||||
message += `\n- ${addon.name}: CHF ${addon.price}`;
|
||||
});
|
||||
}
|
||||
|
||||
message += `\n\nTotal Monthly Price: CHF ${config.totalPrice}
|
||||
|
||||
Please contact me with next steps for ordering this configuration.`;
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
// Store current configuration for order button
|
||||
storeConfiguration(plan, config, serviceLevel, totalPrice, addons) {
|
||||
this.selectedConfiguration = {
|
||||
planName: plan.compute_plan,
|
||||
planGroup: plan.groupName,
|
||||
vcpus: plan.vcpus,
|
||||
memory: plan.ram,
|
||||
storage: config.storage,
|
||||
instances: config.instances,
|
||||
serviceLevel: serviceLevel,
|
||||
totalPrice: totalPrice,
|
||||
addons: addons
|
||||
};
|
||||
}
|
||||
|
||||
// Get stored configuration
|
||||
getStoredConfiguration() {
|
||||
return this.selectedConfiguration;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
window.OrderManager = OrderManager;
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
/**
|
||||
* Plan Manager - Handles plan selection and matching logic
|
||||
*/
|
||||
class PlanManager {
|
||||
constructor(pricingDataManager) {
|
||||
this.pricingDataManager = pricingDataManager;
|
||||
}
|
||||
|
||||
// Find best matching plan based on requirements
|
||||
findBestMatchingPlan(cpus, memory, serviceLevel) {
|
||||
const pricingData = this.pricingDataManager.getPricingData();
|
||||
|
||||
if (!pricingData) return null;
|
||||
|
||||
let bestMatch = null;
|
||||
let bestScore = Infinity;
|
||||
|
||||
// Iterate through all groups and service levels
|
||||
Object.keys(pricingData).forEach(groupName => {
|
||||
const group = pricingData[groupName];
|
||||
|
||||
if (group[serviceLevel]) {
|
||||
group[serviceLevel].forEach(plan => {
|
||||
const planCpus = parseFloat(plan.vcpus);
|
||||
const planMemory = parseFloat(plan.ram);
|
||||
|
||||
// Check if plan meets minimum requirements
|
||||
if (planCpus >= cpus && planMemory >= memory) {
|
||||
// Calculate efficiency score (lower is better)
|
||||
const cpuOverhead = planCpus - cpus;
|
||||
const memoryOverhead = planMemory - memory;
|
||||
const score = cpuOverhead + memoryOverhead + plan.final_price * 0.1;
|
||||
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = {
|
||||
...plan,
|
||||
groupName: groupName
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
// Get current plan based on configuration
|
||||
getCurrentPlan(domManager) {
|
||||
const config = domManager.getCurrentConfiguration();
|
||||
const planSelect = domManager.get('planSelect');
|
||||
|
||||
if (planSelect?.value) {
|
||||
return JSON.parse(planSelect.value);
|
||||
}
|
||||
|
||||
return this.findBestMatchingPlan(config.cpus, config.memory, config.serviceLevel);
|
||||
}
|
||||
|
||||
// Populate plan dropdown based on selected service level
|
||||
populatePlanDropdown(domManager) {
|
||||
const planSelect = domManager.get('planSelect');
|
||||
if (!planSelect) return;
|
||||
|
||||
const serviceLevel = domManager.getSelectedServiceLevel();
|
||||
if (!serviceLevel) {
|
||||
// Clear dropdown if no service level is selected
|
||||
planSelect.innerHTML = '<option value="">Select a service level first</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing options
|
||||
planSelect.innerHTML = '<option value="">Auto-select best matching plan</option>';
|
||||
|
||||
// Get plans for the selected service level
|
||||
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
|
||||
|
||||
if (!availablePlans || availablePlans.length === 0) {
|
||||
planSelect.innerHTML = '<option value="">No plans available for this service level</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Add plans to dropdown
|
||||
availablePlans.forEach(plan => {
|
||||
const option = document.createElement('option');
|
||||
option.value = JSON.stringify(plan);
|
||||
option.textContent = `${plan.compute_plan} - ${plan.vcpus} vCPUs, ${plan.ram} GB RAM`;
|
||||
planSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Update sliders to match selected plan
|
||||
updateSlidersForPlan(plan, domManager) {
|
||||
const cpuRange = domManager.get('cpuRange');
|
||||
const memoryRange = domManager.get('memoryRange');
|
||||
const cpuValue = domManager.get('cpuValue');
|
||||
const memoryValue = domManager.get('memoryValue');
|
||||
|
||||
if (cpuRange && cpuValue) {
|
||||
cpuRange.value = plan.vcpus;
|
||||
cpuValue.textContent = plan.vcpus;
|
||||
}
|
||||
|
||||
if (memoryRange && memoryValue) {
|
||||
memoryRange.value = plan.ram;
|
||||
memoryValue.textContent = plan.ram;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
window.PlanManager = PlanManager;
|
||||
|
|
@ -1,502 +0,0 @@
|
|||
/**
|
||||
* Price Calculator - Main orchestrator class
|
||||
* Coordinates all the different managers to provide pricing calculation functionality
|
||||
*/
|
||||
class PriceCalculator {
|
||||
constructor() {
|
||||
try {
|
||||
// Initialize managers
|
||||
this.domManager = new DOMManager();
|
||||
this.currentOffering = this.extractOfferingFromURL();
|
||||
|
||||
if (!this.currentOffering) {
|
||||
throw new Error('Unable to extract offering information from URL');
|
||||
}
|
||||
|
||||
this.pricingDataManager = new PricingDataManager(this.currentOffering);
|
||||
this.planManager = new PlanManager(this.pricingDataManager);
|
||||
this.addonManager = new AddonManager(this.pricingDataManager);
|
||||
this.uiManager = new UIManager();
|
||||
this.orderManager = new OrderManager();
|
||||
|
||||
// Initialize the calculator
|
||||
this.init();
|
||||
} catch (error) {
|
||||
console.error('Error initializing PriceCalculator:', error);
|
||||
this.showInitializationError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract offering info from URL
|
||||
extractOfferingFromURL() {
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
if (pathParts.length >= 4 && pathParts[1] === 'offering') {
|
||||
return {
|
||||
provider_slug: pathParts[2],
|
||||
service_slug: pathParts[3]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize calculator
|
||||
async init() {
|
||||
try {
|
||||
// Load pricing data and setup calculator
|
||||
if (this.currentOffering) {
|
||||
await this.pricingDataManager.loadPricingData();
|
||||
|
||||
this.setupEventListeners();
|
||||
this.setupUI();
|
||||
this.orderManager.setupOrderButton(this.domManager);
|
||||
this.updateCalculator();
|
||||
} else {
|
||||
throw new Error('No current offering found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing price calculator:', error);
|
||||
this.showInitializationError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Show initialization error to user
|
||||
showInitializationError(message) {
|
||||
const planMatchStatus = this.domManager?.get('planMatchStatus');
|
||||
if (planMatchStatus) {
|
||||
planMatchStatus.innerHTML = `
|
||||
<i class="bi bi-exclamation-triangle me-2 text-danger"></i>
|
||||
<span class="text-danger">Failed to load pricing calculator: ${message}</span>
|
||||
`;
|
||||
planMatchStatus.className = 'alert alert-danger mb-3';
|
||||
planMatchStatus.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Setup initial UI components
|
||||
setupUI() {
|
||||
// Setup service levels based on available data
|
||||
this.uiManager.setupServiceLevels(this.domManager, this.pricingDataManager);
|
||||
|
||||
// Calculate and set slider maximums and ranges
|
||||
this.uiManager.updateSliderMaximums(this.domManager, this.pricingDataManager);
|
||||
|
||||
// Set smart default values based on available plans
|
||||
this.domManager.setSmartDefaults(this.pricingDataManager);
|
||||
|
||||
// Populate plan dropdown
|
||||
this.planManager.populatePlanDropdown(this.domManager);
|
||||
|
||||
// Initialize instances slider
|
||||
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
|
||||
|
||||
// Setup service level event listeners after UI is created
|
||||
this.setupServiceLevelEventListeners();
|
||||
}
|
||||
|
||||
// Setup event listeners for calculator controls
|
||||
setupEventListeners() {
|
||||
const cpuRange = this.domManager.get('cpuRange');
|
||||
const memoryRange = this.domManager.get('memoryRange');
|
||||
const storageRange = this.domManager.get('storageRange');
|
||||
const instancesRange = this.domManager.get('instancesRange');
|
||||
|
||||
if (!cpuRange || !memoryRange || !storageRange || !instancesRange) return;
|
||||
|
||||
// Slider event listeners
|
||||
cpuRange.addEventListener('input', () => {
|
||||
this.domManager.get('cpuValue').textContent = cpuRange.value;
|
||||
// Only synchronize if in auto-select mode (no manual plan selection)
|
||||
const planSelect = this.domManager.get('planSelect');
|
||||
if (!planSelect?.value) {
|
||||
this.synchronizeMemoryToMatchingPlan(parseFloat(cpuRange.value));
|
||||
}
|
||||
this.updatePricing();
|
||||
});
|
||||
|
||||
memoryRange.addEventListener('input', () => {
|
||||
this.domManager.get('memoryValue').textContent = memoryRange.value;
|
||||
// Only synchronize if in auto-select mode (no manual plan selection)
|
||||
const planSelect = this.domManager.get('planSelect');
|
||||
if (!planSelect?.value) {
|
||||
this.synchronizeCpuToMatchingPlan(parseFloat(memoryRange.value));
|
||||
}
|
||||
this.updatePricing();
|
||||
});
|
||||
|
||||
storageRange.addEventListener('input', () => {
|
||||
this.domManager.get('storageValue').textContent = storageRange.value;
|
||||
this.updatePricing();
|
||||
});
|
||||
|
||||
instancesRange.addEventListener('input', () => {
|
||||
this.domManager.get('instancesValue').textContent = instancesRange.value;
|
||||
this.updatePricing();
|
||||
});
|
||||
|
||||
// Plan selection listener
|
||||
const planSelect = this.domManager.get('planSelect');
|
||||
if (planSelect) {
|
||||
planSelect.addEventListener('change', () => {
|
||||
if (planSelect.value) {
|
||||
const selectedPlan = JSON.parse(planSelect.value);
|
||||
|
||||
// Update sliders to match selected plan
|
||||
this.planManager.updateSlidersForPlan(selectedPlan, this.domManager);
|
||||
|
||||
// Fade out CPU and Memory sliders since plan is manually selected
|
||||
this.uiManager.fadeOutSliders(this.domManager, ['cpu', 'memory']);
|
||||
|
||||
// Update addons for the new configuration
|
||||
this.addonManager.updateAddons(this.domManager);
|
||||
|
||||
// Update pricing with the selected plan
|
||||
this.updatePricingWithPlan(selectedPlan);
|
||||
} else {
|
||||
// Auto-select mode - reset sliders to smart default values
|
||||
this.domManager.setSmartDefaults(this.pricingDataManager);
|
||||
|
||||
// Auto-select mode - fade sliders back in
|
||||
this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']);
|
||||
|
||||
// Auto-select mode - update addons and recalculate
|
||||
this.addonManager.updateAddons(this.domManager);
|
||||
this.updatePricing();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for addon changes
|
||||
window.addEventListener('addon-changed', () => {
|
||||
this.updatePricing();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup service level event listeners (called after UI setup)
|
||||
setupServiceLevelEventListeners() {
|
||||
// Service level change listener
|
||||
const serviceLevelInputs = this.domManager.get('serviceLevelInputs');
|
||||
|
||||
if (serviceLevelInputs) {
|
||||
serviceLevelInputs.forEach((input, index) => {
|
||||
input.addEventListener('change', () => {
|
||||
try {
|
||||
// Check if a plan is currently selected before updating
|
||||
const planSelect = this.domManager.get('planSelect');
|
||||
const currentlySelectedPlan = planSelect?.value ? JSON.parse(planSelect.value) : null;
|
||||
|
||||
// Update instances slider for the new service level (functionality from UIManager)
|
||||
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
|
||||
|
||||
// Update plan dropdown for new service level
|
||||
this.planManager.populatePlanDropdown(this.domManager);
|
||||
|
||||
// Update addons for new service level first
|
||||
this.addonManager.updateAddons(this.domManager);
|
||||
|
||||
// If a plan was previously selected, try to maintain selection
|
||||
if (currentlySelectedPlan && planSelect) {
|
||||
// Find the same plan in the new dropdown options
|
||||
const options = planSelect.querySelectorAll('option');
|
||||
let planFound = false;
|
||||
let matchingPlan = null;
|
||||
|
||||
for (const option of options) {
|
||||
if (option.value) {
|
||||
try {
|
||||
const optionPlan = JSON.parse(option.value);
|
||||
|
||||
// First, try to match by exact plan name
|
||||
if (optionPlan.compute_plan === currentlySelectedPlan.compute_plan) {
|
||||
matchingPlan = optionPlan;
|
||||
planFound = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error parsing plan option:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (planFound && matchingPlan) {
|
||||
// Set the plan selection
|
||||
planSelect.value = JSON.stringify(matchingPlan);
|
||||
|
||||
// Maintain the UI state for manually selected plan
|
||||
this.planManager.updateSlidersForPlan(matchingPlan, this.domManager);
|
||||
this.uiManager.fadeOutSliders(this.domManager, ['cpu', 'memory']);
|
||||
|
||||
// Update pricing with the selected plan
|
||||
this.updatePricingWithPlan(matchingPlan);
|
||||
} else {
|
||||
planSelect.value = '';
|
||||
// Reset sliders to smart defaults and fade them back in
|
||||
this.domManager.setSmartDefaults(this.pricingDataManager);
|
||||
this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']);
|
||||
// Update pricing in auto-select mode
|
||||
this.updatePricing();
|
||||
}
|
||||
} else {
|
||||
// No plan was previously selected, just update pricing
|
||||
this.updatePricing();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in service level change handler:', error);
|
||||
// Fallback to basic functionality if there's an error
|
||||
this.updatePricing();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update calculator (initial setup)
|
||||
updateCalculator() {
|
||||
this.addonManager.updateAddons(this.domManager);
|
||||
this.updatePricing();
|
||||
}
|
||||
|
||||
// Update pricing with specific plan
|
||||
updatePricingWithPlan(selectedPlan) {
|
||||
const config = this.domManager.getCurrentConfiguration();
|
||||
|
||||
// Update addon prices first to ensure calculated prices are current
|
||||
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
|
||||
|
||||
this.showPlanDetails(selectedPlan, config.storage, config.instances);
|
||||
this.uiManager.updateStatusMessage(this.domManager, 'Plan selected directly!', 'success');
|
||||
}
|
||||
|
||||
// Main pricing update function
|
||||
updatePricing() {
|
||||
// Update addon prices first to ensure they're current
|
||||
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
|
||||
|
||||
const planSelect = this.domManager.get('planSelect');
|
||||
|
||||
// Reset plan selection if in auto-select mode
|
||||
if (!planSelect?.value) {
|
||||
const config = this.domManager.getCurrentConfiguration();
|
||||
|
||||
if (!config.serviceLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find best matching plan
|
||||
const matchedPlan = this.planManager.findBestMatchingPlan(config.cpus, config.memory, config.serviceLevel);
|
||||
|
||||
if (matchedPlan) {
|
||||
this.showPlanDetails(matchedPlan, config.storage, config.instances);
|
||||
this.uiManager.updateStatusMessage(this.domManager, 'Perfect match found!', 'success');
|
||||
} else {
|
||||
this.uiManager.showNoMatch(this.domManager);
|
||||
}
|
||||
} else {
|
||||
// Plan is directly selected, update storage pricing
|
||||
const selectedPlan = JSON.parse(planSelect.value);
|
||||
const config = this.domManager.getCurrentConfiguration();
|
||||
|
||||
// Update addon prices for current configuration
|
||||
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
|
||||
this.showPlanDetails(selectedPlan, config.storage, config.instances);
|
||||
this.uiManager.updateStatusMessage(this.domManager, 'Plan selected directly!', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// Show plan details in the UI
|
||||
showPlanDetails(plan, storage, instances) {
|
||||
// Get current service level
|
||||
const serviceLevel = this.domManager.getSelectedServiceLevel() || 'Best Effort';
|
||||
|
||||
// Ensure addon prices are calculated with current configuration
|
||||
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
|
||||
|
||||
// Calculate pricing using final price from plan data (which already includes mandatory addons)
|
||||
const managedServicePricePerInstance = parseFloat(plan.final_price);
|
||||
|
||||
// Collect addon information for display and calculation
|
||||
const addons = this.addonManager.getSelectedAddons(this.domManager);
|
||||
const optionalAddonTotal = this.addonManager.calculateOptionalAddonTotal(this.domManager);
|
||||
|
||||
const managedServicePrice = managedServicePricePerInstance * instances;
|
||||
|
||||
// Use storage price from plan data or fallback to instance variable
|
||||
const storageUnitPrice = plan.storage_price !== undefined ?
|
||||
parseFloat(plan.storage_price) :
|
||||
this.pricingDataManager.getStoragePrice();
|
||||
const storagePriceValue = storage * storageUnitPrice * instances;
|
||||
|
||||
// Total price = managed service price (includes mandatory addons) + storage + optional addons
|
||||
const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal;
|
||||
|
||||
// Show plan details in UI
|
||||
this.uiManager.showPlanDetails(
|
||||
this.domManager,
|
||||
plan,
|
||||
storage,
|
||||
instances,
|
||||
serviceLevel,
|
||||
managedServicePrice,
|
||||
storagePriceValue,
|
||||
totalPriceValue
|
||||
);
|
||||
|
||||
// Update addon pricing display
|
||||
this.uiManager.updateAddonPricingDisplay(this.domManager, addons.mandatory, addons.optional);
|
||||
|
||||
// Store current configuration for order button
|
||||
this.orderManager.storeConfiguration(
|
||||
plan,
|
||||
{ storage, instances },
|
||||
serviceLevel,
|
||||
totalPriceValue.toFixed(2),
|
||||
[...addons.mandatory, ...addons.optional]
|
||||
);
|
||||
}
|
||||
|
||||
// Synchronize memory slider to match CPU value with best matching plan
|
||||
synchronizeMemoryToMatchingPlan(targetCpu) {
|
||||
const serviceLevel = this.domManager.getSelectedServiceLevel();
|
||||
if (!serviceLevel) return;
|
||||
|
||||
// Get all available plans for the current service level
|
||||
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
|
||||
if (!availablePlans || availablePlans.length === 0) return;
|
||||
|
||||
// Snap CPU to nearest available value first
|
||||
const { cpuValues } = this.pricingDataManager.getAvailableSliderValues();
|
||||
const snappedCpu = this.findNearestValue(targetCpu, cpuValues);
|
||||
|
||||
// Update CPU slider to snapped value if different
|
||||
if (snappedCpu !== targetCpu) {
|
||||
const cpuRange = this.domManager.get('cpuRange');
|
||||
const cpuValue = this.domManager.get('cpuValue');
|
||||
if (cpuRange && cpuValue) {
|
||||
cpuRange.value = snappedCpu;
|
||||
cpuValue.textContent = snappedCpu;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the plan that best matches the snapped CPU requirement
|
||||
let bestPlan = null;
|
||||
let minDifference = Infinity;
|
||||
|
||||
availablePlans.forEach(plan => {
|
||||
const planCpu = parseFloat(plan.vcpus);
|
||||
// Look for plans that meet or exceed the CPU requirement
|
||||
if (planCpu >= snappedCpu) {
|
||||
const difference = planCpu - snappedCpu;
|
||||
if (difference < minDifference) {
|
||||
minDifference = difference;
|
||||
bestPlan = plan;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If no plan meets the CPU requirement, find the closest one below it
|
||||
if (!bestPlan) {
|
||||
availablePlans.forEach(plan => {
|
||||
const planCpu = parseFloat(plan.vcpus);
|
||||
const difference = Math.abs(planCpu - snappedCpu);
|
||||
if (difference < minDifference) {
|
||||
minDifference = difference;
|
||||
bestPlan = plan;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update memory slider to match the found plan
|
||||
if (bestPlan) {
|
||||
const memoryRange = this.domManager.get('memoryRange');
|
||||
const memoryValue = this.domManager.get('memoryValue');
|
||||
|
||||
if (memoryRange && memoryValue) {
|
||||
memoryRange.value = bestPlan.ram;
|
||||
memoryValue.textContent = bestPlan.ram;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize CPU slider to match memory value with best matching plan
|
||||
synchronizeCpuToMatchingPlan(targetMemory) {
|
||||
const serviceLevel = this.domManager.getSelectedServiceLevel();
|
||||
if (!serviceLevel) return;
|
||||
|
||||
// Get all available plans for the current service level
|
||||
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
|
||||
if (!availablePlans || availablePlans.length === 0) return;
|
||||
|
||||
// Snap memory to nearest available value first
|
||||
const { memoryValues } = this.pricingDataManager.getAvailableSliderValues();
|
||||
const snappedMemory = this.findNearestValue(targetMemory, memoryValues);
|
||||
|
||||
// Update memory slider to snapped value if different
|
||||
if (snappedMemory !== targetMemory) {
|
||||
const memoryRange = this.domManager.get('memoryRange');
|
||||
const memoryValue = this.domManager.get('memoryValue');
|
||||
if (memoryRange && memoryValue) {
|
||||
memoryRange.value = snappedMemory;
|
||||
memoryValue.textContent = snappedMemory;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the plan that best matches the snapped memory requirement
|
||||
let bestPlan = null;
|
||||
let minDifference = Infinity;
|
||||
|
||||
availablePlans.forEach(plan => {
|
||||
const planMemory = parseFloat(plan.ram);
|
||||
// Look for plans that meet or exceed the memory requirement
|
||||
if (planMemory >= snappedMemory) {
|
||||
const difference = planMemory - snappedMemory;
|
||||
if (difference < minDifference) {
|
||||
minDifference = difference;
|
||||
bestPlan = plan;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If no plan meets the memory requirement, find the closest one below it
|
||||
if (!bestPlan) {
|
||||
availablePlans.forEach(plan => {
|
||||
const planMemory = parseFloat(plan.ram);
|
||||
const difference = Math.abs(planMemory - snappedMemory);
|
||||
if (difference < minDifference) {
|
||||
minDifference = difference;
|
||||
bestPlan = plan;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update CPU slider to match the found plan
|
||||
if (bestPlan) {
|
||||
const cpuRange = this.domManager.get('cpuRange');
|
||||
const cpuValue = this.domManager.get('cpuValue');
|
||||
|
||||
if (cpuRange && cpuValue) {
|
||||
cpuRange.value = bestPlan.vcpus;
|
||||
cpuValue.textContent = bestPlan.vcpus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the nearest value in an array to a target value
|
||||
findNearestValue(target, availableValues) {
|
||||
if (!availableValues || availableValues.length === 0) return target;
|
||||
|
||||
let nearest = availableValues[0];
|
||||
let minDifference = Math.abs(target - nearest);
|
||||
|
||||
for (let i = 1; i < availableValues.length; i++) {
|
||||
const difference = Math.abs(target - availableValues[i]);
|
||||
if (difference < minDifference) {
|
||||
minDifference = difference;
|
||||
nearest = availableValues[i];
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
window.PriceCalculator = PriceCalculator;
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
/**
|
||||
* Pricing Data Manager - Handles API calls and data extraction
|
||||
*/
|
||||
class PricingDataManager {
|
||||
constructor(currentOffering) {
|
||||
this.currentOffering = currentOffering;
|
||||
this.pricingData = null;
|
||||
this.storagePrice = null;
|
||||
this.replicaInfo = null;
|
||||
this.addonsData = null;
|
||||
}
|
||||
|
||||
// Load pricing data from API endpoint
|
||||
async loadPricingData() {
|
||||
try {
|
||||
const url = `/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load pricing data: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid pricing data received from server');
|
||||
}
|
||||
|
||||
this.pricingData = data.pricing || data;
|
||||
|
||||
// Validate that we have usable pricing data
|
||||
if (!this.pricingData || Object.keys(this.pricingData).length === 0) {
|
||||
throw new Error('No pricing data available for this offering');
|
||||
}
|
||||
|
||||
// Extract addons data from the plans - addons are embedded in each plan
|
||||
this.extractAddonsData();
|
||||
|
||||
// Extract storage price from the first available plan
|
||||
this.extractStoragePrice();
|
||||
|
||||
return this.pricingData;
|
||||
} catch (error) {
|
||||
console.error('Error loading pricing data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract replica information and storage price from pricing data
|
||||
extractStoragePrice() {
|
||||
if (!this.pricingData) return;
|
||||
|
||||
// Find the first plan with storage pricing data and replica info
|
||||
for (const groupName of Object.keys(this.pricingData)) {
|
||||
const group = this.pricingData[groupName];
|
||||
for (const serviceLevel of Object.keys(group)) {
|
||||
const plans = group[serviceLevel];
|
||||
if (plans.length > 0 && plans[0].storage_price !== undefined) {
|
||||
this.storagePrice = parseFloat(plans[0].storage_price);
|
||||
this.replicaInfo = {
|
||||
ha_replica_min: plans[0].ha_replica_min || 1,
|
||||
ha_replica_max: plans[0].ha_replica_max || 1
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract addons data from pricing plans
|
||||
extractAddonsData() {
|
||||
if (!this.pricingData) return;
|
||||
|
||||
this.addonsData = {};
|
||||
|
||||
// Extract addons from the first available plan for each service level
|
||||
Object.keys(this.pricingData).forEach(groupName => {
|
||||
const group = this.pricingData[groupName];
|
||||
Object.keys(group).forEach(serviceLevel => {
|
||||
const plans = group[serviceLevel];
|
||||
if (plans.length > 0) {
|
||||
// Use the first plan's addon data for this service level
|
||||
const plan = plans[0];
|
||||
const allAddons = [];
|
||||
|
||||
// Add mandatory addons
|
||||
if (plan.mandatory_addons) {
|
||||
plan.mandatory_addons.forEach(addon => {
|
||||
allAddons.push({
|
||||
...addon,
|
||||
is_mandatory: true,
|
||||
addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add optional addons
|
||||
if (plan.optional_addons) {
|
||||
plan.optional_addons.forEach(addon => {
|
||||
allAddons.push({
|
||||
...addon,
|
||||
is_mandatory: false,
|
||||
addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.addonsData[serviceLevel] = allAddons;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get available service levels from pricing data
|
||||
getAvailableServiceLevels() {
|
||||
if (!this.pricingData) return new Set();
|
||||
|
||||
const availableServiceLevels = new Set();
|
||||
Object.keys(this.pricingData).forEach(groupName => {
|
||||
const group = this.pricingData[groupName];
|
||||
Object.keys(group).forEach(serviceLevel => {
|
||||
availableServiceLevels.add(serviceLevel);
|
||||
});
|
||||
});
|
||||
|
||||
return availableServiceLevels;
|
||||
}
|
||||
|
||||
// Get maximum CPU and memory values from all plans
|
||||
getSliderMaximums() {
|
||||
if (!this.pricingData) return { maxCpus: 0, maxMemory: 0 };
|
||||
|
||||
let maxCpus = 0;
|
||||
let maxMemory = 0;
|
||||
|
||||
// Find maximum CPU and memory across all plans
|
||||
Object.keys(this.pricingData).forEach(groupName => {
|
||||
const group = this.pricingData[groupName];
|
||||
Object.keys(group).forEach(serviceLevel => {
|
||||
group[serviceLevel].forEach(plan => {
|
||||
const planCpus = parseFloat(plan.vcpus);
|
||||
const planMemory = parseFloat(plan.ram);
|
||||
|
||||
if (planCpus > maxCpus) maxCpus = planCpus;
|
||||
if (planMemory > maxMemory) maxMemory = planMemory;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { maxCpus, maxMemory };
|
||||
}
|
||||
|
||||
// Get all unique CPU and memory values from plans
|
||||
getAvailableSliderValues() {
|
||||
if (!this.pricingData) return { cpuValues: [], memoryValues: [] };
|
||||
|
||||
const cpuSet = new Set();
|
||||
const memorySet = new Set();
|
||||
|
||||
// Collect all unique CPU and memory values across all plans
|
||||
Object.keys(this.pricingData).forEach(groupName => {
|
||||
const group = this.pricingData[groupName];
|
||||
Object.keys(group).forEach(serviceLevel => {
|
||||
group[serviceLevel].forEach(plan => {
|
||||
cpuSet.add(parseFloat(plan.vcpus));
|
||||
memorySet.add(parseFloat(plan.ram));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to sorted arrays
|
||||
const cpuValues = Array.from(cpuSet).sort((a, b) => a - b);
|
||||
const memoryValues = Array.from(memorySet).sort((a, b) => a - b);
|
||||
|
||||
return { cpuValues, memoryValues };
|
||||
}
|
||||
|
||||
// Get all plans for a specific service level
|
||||
getPlansForServiceLevel(serviceLevel) {
|
||||
if (!this.pricingData || !serviceLevel) return [];
|
||||
|
||||
const availablePlans = [];
|
||||
Object.keys(this.pricingData).forEach(groupName => {
|
||||
const group = this.pricingData[groupName];
|
||||
if (group[serviceLevel]) {
|
||||
group[serviceLevel].forEach(plan => {
|
||||
availablePlans.push({
|
||||
...plan,
|
||||
groupName: groupName
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort plans by vCPU, then by RAM
|
||||
availablePlans.sort((a, b) => {
|
||||
if (parseFloat(a.vcpus) !== parseFloat(b.vcpus)) {
|
||||
return parseFloat(a.vcpus) - parseFloat(b.vcpus);
|
||||
}
|
||||
return parseFloat(a.ram) - parseFloat(b.ram);
|
||||
});
|
||||
|
||||
return availablePlans;
|
||||
}
|
||||
|
||||
// Getters
|
||||
getPricingData() {
|
||||
return this.pricingData;
|
||||
}
|
||||
|
||||
getStoragePrice() {
|
||||
return this.storagePrice;
|
||||
}
|
||||
|
||||
getReplicaInfo() {
|
||||
return this.replicaInfo;
|
||||
}
|
||||
|
||||
getAddonsData() {
|
||||
return this.addonsData;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
window.PricingDataManager = PricingDataManager;
|
||||
|
|
@ -1,330 +0,0 @@
|
|||
/**
|
||||
* UI Manager - Handles UI updates and visual feedback
|
||||
*/
|
||||
class UIManager {
|
||||
constructor() {
|
||||
// Visual feedback states
|
||||
this.isSlidersFaded = false;
|
||||
}
|
||||
|
||||
// Update status message
|
||||
updateStatusMessage(domManager, message, type) {
|
||||
const planMatchStatus = domManager.get('planMatchStatus');
|
||||
if (!planMatchStatus) return;
|
||||
|
||||
const iconClass = type === 'success' ? 'bi-check-circle' : 'bi-info-circle';
|
||||
const textClass = type === 'success' ? 'text-success' : '';
|
||||
const alertClass = type === 'success' ? 'alert-success' : 'alert-info';
|
||||
|
||||
planMatchStatus.innerHTML = `<i class="bi ${iconClass} me-2 ${textClass}"></i><span class="${textClass}">${message}</span>`;
|
||||
planMatchStatus.className = `alert ${alertClass} mb-3`;
|
||||
planMatchStatus.style.display = 'block';
|
||||
}
|
||||
|
||||
// Show error message
|
||||
showError(domManager, message) {
|
||||
const planMatchStatus = domManager.get('planMatchStatus');
|
||||
if (planMatchStatus) {
|
||||
planMatchStatus.innerHTML = `<i class="bi bi-exclamation-triangle me-2 text-danger"></i><span class="text-danger">${message}</span>`;
|
||||
planMatchStatus.className = 'alert alert-danger mb-3';
|
||||
planMatchStatus.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Show no matching plan found
|
||||
showNoMatch(domManager) {
|
||||
const planMatchStatus = domManager.get('planMatchStatus');
|
||||
const selectedPlanDetails = domManager.get('selectedPlanDetails');
|
||||
const noMatchFound = domManager.get('noMatchFound');
|
||||
|
||||
if (planMatchStatus) planMatchStatus.style.display = 'none';
|
||||
if (selectedPlanDetails) selectedPlanDetails.style.display = 'none';
|
||||
if (noMatchFound) noMatchFound.style.display = 'block';
|
||||
}
|
||||
|
||||
// Show plan details in the UI
|
||||
showPlanDetails(domManager, plan, storage, instances, serviceLevel, managedServicePrice, storagePriceValue, totalPriceValue) {
|
||||
const selectedPlanDetails = domManager.get('selectedPlanDetails');
|
||||
if (!selectedPlanDetails) return;
|
||||
|
||||
// Show plan details section
|
||||
const planMatchStatus = domManager.get('planMatchStatus');
|
||||
const noMatchFound = domManager.get('noMatchFound');
|
||||
|
||||
if (planMatchStatus) planMatchStatus.style.display = 'block';
|
||||
selectedPlanDetails.style.display = 'block';
|
||||
if (noMatchFound) noMatchFound.style.display = 'none';
|
||||
|
||||
// Update plan information
|
||||
const planGroup = domManager.get('planGroup');
|
||||
const planName = domManager.get('planName');
|
||||
const planDescription = domManager.get('planDescription');
|
||||
const planCpus = domManager.get('planCpus');
|
||||
const planMemory = domManager.get('planMemory');
|
||||
const planInstances = domManager.get('planInstances');
|
||||
const planServiceLevel = domManager.get('planServiceLevel');
|
||||
const managedServicePriceEl = domManager.get('managedServicePrice');
|
||||
const storagePriceEl = domManager.get('storagePriceEl');
|
||||
const storageAmount = domManager.get('storageAmount');
|
||||
const totalPrice = domManager.get('totalPrice');
|
||||
|
||||
if (planGroup) planGroup.textContent = plan.groupName;
|
||||
if (planName) planName.textContent = plan.compute_plan;
|
||||
if (planDescription) planDescription.textContent = plan.compute_plan_group_description || '';
|
||||
if (planCpus) planCpus.textContent = plan.vcpus;
|
||||
if (planMemory) planMemory.textContent = plan.ram + ' GB';
|
||||
if (planInstances) planInstances.textContent = instances;
|
||||
if (planServiceLevel) planServiceLevel.textContent = serviceLevel;
|
||||
|
||||
// Update pricing display
|
||||
if (managedServicePriceEl) managedServicePriceEl.textContent = managedServicePrice.toFixed(2);
|
||||
if (storagePriceEl) storagePriceEl.textContent = storagePriceValue.toFixed(2);
|
||||
if (storageAmount) storageAmount.textContent = storage;
|
||||
if (totalPrice) totalPrice.textContent = totalPriceValue.toFixed(2);
|
||||
}
|
||||
|
||||
// Update addon pricing display in the results panel
|
||||
updateAddonPricingDisplay(domManager, mandatoryAddons, selectedOptionalAddons) {
|
||||
// Get references to the managed service includes elements
|
||||
const managedServiceIncludesContainer = domManager.get('managedServiceIncludesContainer');
|
||||
|
||||
if (managedServiceIncludesContainer) {
|
||||
// Clear existing content
|
||||
managedServiceIncludesContainer.innerHTML = '';
|
||||
|
||||
// Always add "Compute" as the first item
|
||||
const computeRow = document.createElement('div');
|
||||
computeRow.className = 'd-flex justify-content-between small text-muted mb-1';
|
||||
computeRow.innerHTML = `
|
||||
<span>Compute (vCPUs & Memory)</span>
|
||||
<span>Included</span>
|
||||
`;
|
||||
managedServiceIncludesContainer.appendChild(computeRow);
|
||||
|
||||
// Add mandatory addons to the managed service includes section
|
||||
if (mandatoryAddons && mandatoryAddons.length > 0) {
|
||||
mandatoryAddons.forEach(addon => {
|
||||
const addonRow = document.createElement('div');
|
||||
addonRow.className = 'd-flex justify-content-between small text-muted mb-1';
|
||||
addonRow.innerHTML = `
|
||||
<span>Add-on: ${addon.name}</span>
|
||||
<span>CHF ${addon.price}</span>
|
||||
`;
|
||||
managedServiceIncludesContainer.appendChild(addonRow);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update optional addons in the addon pricing container
|
||||
const addonPricingContainer = domManager.get('addonPricingContainer');
|
||||
if (!addonPricingContainer) return;
|
||||
|
||||
// Clear existing addon pricing display
|
||||
addonPricingContainer.innerHTML = '';
|
||||
|
||||
// Add optional addons to pricing breakdown (these are added to total)
|
||||
if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
|
||||
selectedOptionalAddons.forEach(addon => {
|
||||
const addonRow = document.createElement('div');
|
||||
addonRow.className = 'd-flex justify-content-between align-items-center mb-2';
|
||||
addonRow.innerHTML = `
|
||||
<span class="text-nowrap flex-shrink-1" style="min-width: 0;">Add-on: ${addon.name}</span>
|
||||
<span class="fw-bold text-nowrap flex-shrink-0" style="min-width: 110px; text-align: right;">CHF ${addon.price}</span>
|
||||
`;
|
||||
addonPricingContainer.appendChild(addonRow);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fade out specified sliders when plan is manually selected
|
||||
fadeOutSliders(domManager, sliderTypes) {
|
||||
sliderTypes.forEach(type => {
|
||||
const sliderContainer = domManager.getSliderContainer(type);
|
||||
if (sliderContainer) {
|
||||
sliderContainer.style.transition = 'opacity 0.3s ease-in-out';
|
||||
sliderContainer.style.opacity = '0.3';
|
||||
sliderContainer.style.pointerEvents = 'none';
|
||||
|
||||
// Add visual indicator that sliders are disabled
|
||||
const slider = sliderContainer.querySelector('.form-range');
|
||||
if (slider) {
|
||||
slider.style.cursor = 'not-allowed';
|
||||
}
|
||||
}
|
||||
});
|
||||
this.isSlidersFaded = true;
|
||||
}
|
||||
|
||||
// Fade in specified sliders when auto-select mode is chosen
|
||||
fadeInSliders(domManager, sliderTypes) {
|
||||
sliderTypes.forEach(type => {
|
||||
const sliderContainer = domManager.getSliderContainer(type);
|
||||
if (sliderContainer) {
|
||||
sliderContainer.style.transition = 'opacity 0.3s ease-in-out';
|
||||
sliderContainer.style.opacity = '1';
|
||||
sliderContainer.style.pointerEvents = 'auto';
|
||||
|
||||
// Remove visual indicator
|
||||
const slider = sliderContainer.querySelector('.form-range');
|
||||
if (slider) {
|
||||
slider.style.cursor = 'pointer';
|
||||
}
|
||||
}
|
||||
});
|
||||
this.isSlidersFaded = false;
|
||||
}
|
||||
|
||||
// Setup service levels dynamically from pricing data
|
||||
setupServiceLevels(domManager, pricingDataManager) {
|
||||
const serviceLevelGroup = domManager.get('serviceLevelGroup');
|
||||
if (!serviceLevelGroup) return;
|
||||
|
||||
// Get all available service levels from the pricing data
|
||||
const availableServiceLevels = pricingDataManager.getAvailableServiceLevels();
|
||||
|
||||
// Clear existing service level buttons
|
||||
serviceLevelGroup.innerHTML = '';
|
||||
|
||||
// Create buttons for each available service level
|
||||
let isFirst = true;
|
||||
availableServiceLevels.forEach(serviceLevel => {
|
||||
const inputId = `serviceLevel${serviceLevel.replace(/\s+/g, '')}`;
|
||||
|
||||
// Create radio input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'radio';
|
||||
input.className = 'btn-check';
|
||||
input.name = 'serviceLevel';
|
||||
input.id = inputId;
|
||||
input.value = serviceLevel;
|
||||
if (isFirst) {
|
||||
input.checked = true;
|
||||
isFirst = false;
|
||||
}
|
||||
|
||||
// Create label
|
||||
const label = document.createElement('label');
|
||||
label.className = 'btn btn-outline-primary';
|
||||
label.setAttribute('for', inputId);
|
||||
label.textContent = serviceLevel;
|
||||
|
||||
serviceLevelGroup.appendChild(input);
|
||||
serviceLevelGroup.appendChild(label);
|
||||
});
|
||||
|
||||
// Update the serviceLevelInputs reference
|
||||
domManager.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
|
||||
|
||||
// Note: Event listeners are now handled in price-calculator.js setupEventListeners method
|
||||
// to properly preserve plan selection when service level changes
|
||||
// this.setupServiceLevelEventListeners(domManager, pricingDataManager);
|
||||
}
|
||||
|
||||
// Setup event listeners for service level inputs
|
||||
setupServiceLevelEventListeners(domManager, pricingDataManager) {
|
||||
const serviceLevelInputs = domManager.get('serviceLevelInputs');
|
||||
if (!serviceLevelInputs) return;
|
||||
|
||||
// Get the main price calculator instance from window
|
||||
const priceCalculator = window.priceCalculator;
|
||||
if (!priceCalculator) return;
|
||||
|
||||
serviceLevelInputs.forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
this.updateInstancesSlider(domManager, pricingDataManager);
|
||||
priceCalculator.planManager.populatePlanDropdown(domManager);
|
||||
priceCalculator.addonManager.updateAddons(domManager);
|
||||
priceCalculator.updatePricing();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update slider maximums based on pricing data
|
||||
updateSliderMaximums(domManager, pricingDataManager) {
|
||||
const cpuRange = domManager.get('cpuRange');
|
||||
const memoryRange = domManager.get('memoryRange');
|
||||
|
||||
if (!cpuRange || !memoryRange) return;
|
||||
|
||||
const { cpuValues, memoryValues } = pricingDataManager.getAvailableSliderValues();
|
||||
|
||||
// Set CPU slider range based on available plan values
|
||||
if (cpuValues.length > 0) {
|
||||
cpuRange.min = Math.min(...cpuValues);
|
||||
cpuRange.max = Math.max(...cpuValues);
|
||||
// Calculate step size - use the smallest difference between consecutive values
|
||||
const cpuStep = this.calculateOptimalStep(cpuValues);
|
||||
cpuRange.step = cpuStep;
|
||||
}
|
||||
|
||||
// Set Memory slider range based on available plan values
|
||||
if (memoryValues.length > 0) {
|
||||
memoryRange.min = Math.min(...memoryValues);
|
||||
memoryRange.max = Math.max(...memoryValues);
|
||||
// Calculate step size - use the smallest difference between consecutive values
|
||||
const memoryStep = this.calculateOptimalStep(memoryValues);
|
||||
memoryRange.step = memoryStep;
|
||||
}
|
||||
|
||||
// Update display values after changing min/max
|
||||
domManager.updateSliderDisplayValues();
|
||||
}
|
||||
|
||||
// Calculate optimal step size for slider based on available values
|
||||
calculateOptimalStep(values) {
|
||||
if (values.length <= 1) return 0.25; // Default step
|
||||
|
||||
// Find the smallest difference between consecutive values
|
||||
let minDiff = Infinity;
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
const diff = values[i] - values[i - 1];
|
||||
if (diff > 0 && diff < minDiff) {
|
||||
minDiff = diff;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the minimum difference as step, but ensure it's reasonable
|
||||
// Round to common step values (0.25, 0.5, 1, etc.)
|
||||
if (minDiff <= 0.25) return 0.25;
|
||||
if (minDiff <= 0.5) return 0.5;
|
||||
if (minDiff <= 1) return 1;
|
||||
return Math.ceil(minDiff);
|
||||
}
|
||||
|
||||
// Update instances slider based on service level and replica info
|
||||
updateInstancesSlider(domManager, pricingDataManager) {
|
||||
const instancesRange = domManager.get('instancesRange');
|
||||
const instancesValue = domManager.get('instancesValue');
|
||||
const replicaInfo = pricingDataManager.getReplicaInfo();
|
||||
|
||||
if (!instancesRange || !replicaInfo) return;
|
||||
|
||||
const serviceLevel = domManager.getSelectedServiceLevel();
|
||||
|
||||
if (serviceLevel === 'Guaranteed Availability') {
|
||||
// For GA, min is ha_replica_min
|
||||
instancesRange.min = replicaInfo.ha_replica_min;
|
||||
instancesRange.value = Math.max(instancesRange.value, replicaInfo.ha_replica_min);
|
||||
} else {
|
||||
// For BE, min is 1
|
||||
instancesRange.min = 1;
|
||||
instancesRange.value = Math.max(instancesRange.value, 1);
|
||||
}
|
||||
|
||||
// Set max to ha_replica_max
|
||||
instancesRange.max = replicaInfo.ha_replica_max;
|
||||
|
||||
// Update display value
|
||||
if (instancesValue) instancesValue.textContent = instancesRange.value;
|
||||
|
||||
// Update the min/max display under the slider
|
||||
const instancesMinDisplay = document.getElementById('instancesMinDisplay');
|
||||
const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
|
||||
|
||||
if (instancesMinDisplay) instancesMinDisplay.textContent = instancesRange.min;
|
||||
if (instancesMaxDisplay) instancesMaxDisplay.textContent = instancesRange.max;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
window.UIManager = UIManager;
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
/**
|
||||
* ROI Calculator - Modular Version
|
||||
* This file loads all modules and provides global function wrappers for backward compatibility
|
||||
*/
|
||||
|
||||
// Global function wrappers for backward compatibility with existing HTML
|
||||
function updateCalculations() {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.updateCalculations();
|
||||
}
|
||||
}
|
||||
|
||||
function exportToPDF() {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.exportToPDF();
|
||||
}
|
||||
}
|
||||
|
||||
function exportToCSV() {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.exportToCSV();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInvestmentAmountInput(input) {
|
||||
InputUtils.handleInvestmentAmountInput(input);
|
||||
}
|
||||
|
||||
function updateInvestmentAmount(value) {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.updateInvestmentAmount(value);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRevenuePerInstance(value) {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.updateRevenuePerInstance(value);
|
||||
}
|
||||
}
|
||||
|
||||
function updateServalaShare(value) {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.updateServalaShare(value);
|
||||
}
|
||||
}
|
||||
|
||||
function updateGracePeriod(value) {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.updateGracePeriod(value);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLoanRate(value) {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.updateLoanRate(value);
|
||||
}
|
||||
}
|
||||
|
||||
function updateScenarioChurn(scenarioKey, churnRate) {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.updateScenarioChurn(scenarioKey, churnRate);
|
||||
}
|
||||
}
|
||||
|
||||
function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth);
|
||||
}
|
||||
}
|
||||
|
||||
function resetAdvancedParameters() {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.resetAdvancedParameters();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScenario(scenarioKey) {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.toggleScenario(scenarioKey);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollapsible(elementId) {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.toggleCollapsible(elementId);
|
||||
}
|
||||
}
|
||||
|
||||
function resetCalculator() {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.resetCalculator();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInvestmentModel() {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.toggleInvestmentModel();
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
if (window.ROICalculatorApp) {
|
||||
window.ROICalculatorApp.logout();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# ROI Calculator Modules
|
||||
|
||||
This directory contains the modular ROI Calculator implementation, split into focused, maintainable modules.
|
||||
|
||||
## Module Structure
|
||||
|
||||
### Core Modules
|
||||
|
||||
- **`calculator-core.js`** - Main ROICalculator class with calculation logic
|
||||
- **`chart-manager.js`** - Chart.js integration and chart rendering
|
||||
- **`ui-manager.js`** - DOM updates, table rendering, and metric display
|
||||
- **`export-manager.js`** - PDF and CSV export functionality
|
||||
- **`input-utils.js`** - Input validation, parsing, and formatting utilities
|
||||
- **`roi-calculator-app.js`** - Main application coordinator class
|
||||
|
||||
### Integration
|
||||
|
||||
- **`../roi-calculator-modular.js`** - Global function wrappers for backward compatibility
|
||||
|
||||
## Key Improvements
|
||||
|
||||
1. **Modular Architecture**: Each module has a single responsibility
|
||||
2. **Error Handling**: Comprehensive try-catch blocks with graceful fallbacks
|
||||
3. **No Global Variables**: App instance contained in window.ROICalculatorApp
|
||||
4. **Type Safety**: Input validation and null checks throughout
|
||||
5. **Separation of Concerns**: Calculation, UI, charts, and exports are separated
|
||||
|
||||
## Usage
|
||||
|
||||
All modules are automatically loaded via the HTML template. The ROICalculatorApp class coordinates all modules and provides the same public API as the original monolithic version.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
All existing HTML onclick handlers and function calls continue to work through the global wrapper functions in `roi-calculator-modular.js`.
|
||||
|
|
@ -1,452 +0,0 @@
|
|||
/**
|
||||
* Core ROI Calculator Class
|
||||
* Handles calculation logic and data management
|
||||
*/
|
||||
class ROICalculator {
|
||||
constructor() {
|
||||
this.scenarios = {
|
||||
conservative: {
|
||||
name: 'Conservative',
|
||||
enabled: true,
|
||||
churnRate: 0.025, // Lower churn with sticky managed services
|
||||
phases: [
|
||||
{ months: 6, newInstancesPerMonth: 15 }, // 5 new clients × 3 instances each
|
||||
{ months: 6, newInstancesPerMonth: 24 }, // 6 new clients × 4 instances (growth + expansion)
|
||||
{ months: 12, newInstancesPerMonth: 35 }, // 7 new clients × 5 instances (mature usage)
|
||||
{ months: 12, newInstancesPerMonth: 40 } // 8 new clients × 5 instances
|
||||
]
|
||||
},
|
||||
moderate: {
|
||||
name: 'Moderate',
|
||||
enabled: true,
|
||||
churnRate: 0.03, // Balanced churn with growth
|
||||
phases: [
|
||||
{ months: 6, newInstancesPerMonth: 25 }, // 8 new clients × 3 instances each
|
||||
{ months: 6, newInstancesPerMonth: 45 }, // 10 new clients × 4.5 instances (expansion)
|
||||
{ months: 12, newInstancesPerMonth: 70 }, // 12 new clients × 6 instances (diverse services)
|
||||
{ months: 12, newInstancesPerMonth: 90 } // 15 new clients × 6 instances
|
||||
]
|
||||
},
|
||||
aggressive: {
|
||||
name: 'Aggressive',
|
||||
enabled: true,
|
||||
churnRate: 0.035, // Slightly higher churn with rapid growth
|
||||
phases: [
|
||||
{ months: 6, newInstancesPerMonth: 40 }, // 12 new clients × 3.5 instances
|
||||
{ months: 6, newInstancesPerMonth: 80 }, // 16 new clients × 5 instances (viral growth)
|
||||
{ months: 12, newInstancesPerMonth: 120 }, // 20 new clients × 6 instances (full adoption)
|
||||
{ months: 12, newInstancesPerMonth: 150 } // 25 new clients × 6 instances
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
this.charts = {};
|
||||
this.monthlyData = {};
|
||||
this.results = {};
|
||||
|
||||
// Note: Charts and initial calculation will be handled by the app coordinator
|
||||
}
|
||||
|
||||
getInputValues() {
|
||||
try {
|
||||
// Get investment model with fallback
|
||||
const investmentModelElement = document.querySelector('input[name="investment-model"]:checked');
|
||||
const investmentModel = investmentModelElement ? investmentModelElement.value : 'direct';
|
||||
|
||||
// Get investment amount with validation
|
||||
const investmentAmountElement = document.getElementById('investment-amount');
|
||||
const investmentAmountValue = investmentAmountElement ? investmentAmountElement.getAttribute('data-value') : '500000';
|
||||
const investmentAmount = parseFloat(investmentAmountValue) || 500000;
|
||||
|
||||
// Get timeframe with validation
|
||||
const timeframeElement = document.getElementById('timeframe');
|
||||
const timeframe = timeframeElement ? parseInt(timeframeElement.value) || 3 : 3;
|
||||
|
||||
// Get loan interest rate with validation
|
||||
const loanRateElement = document.getElementById('loan-interest-rate');
|
||||
const loanInterestRate = loanRateElement ? (parseFloat(loanRateElement.value) || 5.0) / 100 : 0.05;
|
||||
|
||||
// Get revenue per instance with validation
|
||||
const revenueElement = document.getElementById('revenue-per-instance');
|
||||
const revenuePerInstance = revenueElement ? parseFloat(revenueElement.value) || 50 : 50;
|
||||
|
||||
// Get servala share with validation
|
||||
const shareElement = document.getElementById('servala-share');
|
||||
const servalaShare = shareElement ? (parseFloat(shareElement.value) || 25) / 100 : 0.25;
|
||||
|
||||
// Get grace period with validation
|
||||
const graceElement = document.getElementById('grace-period');
|
||||
const gracePeriod = graceElement ? parseInt(graceElement.value) || 6 : 6;
|
||||
|
||||
// Get core service revenue with validation
|
||||
const coreRevenueElement = document.getElementById('core-service-revenue');
|
||||
const coreServiceRevenue = coreRevenueElement ? parseFloat(coreRevenueElement.value) || 0 : 0;
|
||||
|
||||
// Get currency with validation
|
||||
const currencyElement = document.getElementById('currency');
|
||||
const currency = currencyElement ? currencyElement.value || 'CHF' : 'CHF';
|
||||
|
||||
return {
|
||||
investmentAmount,
|
||||
timeframe,
|
||||
investmentModel,
|
||||
loanInterestRate,
|
||||
revenuePerInstance,
|
||||
coreServiceRevenue,
|
||||
servalaShare,
|
||||
gracePeriod,
|
||||
currency
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting input values:', error);
|
||||
// Return safe default values
|
||||
return {
|
||||
investmentAmount: 500000,
|
||||
timeframe: 3,
|
||||
investmentModel: 'direct',
|
||||
loanInterestRate: 0.05,
|
||||
revenuePerInstance: 50,
|
||||
coreServiceRevenue: 0,
|
||||
servalaShare: 0.25,
|
||||
currency: 'CHF',
|
||||
gracePeriod: 6
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
calculateScenario(scenarioKey, inputs) {
|
||||
try {
|
||||
const scenario = this.scenarios[scenarioKey];
|
||||
if (!scenario.enabled) return null;
|
||||
|
||||
// Calculate loan payment if using loan model
|
||||
let monthlyLoanPayment = 0;
|
||||
if (inputs.investmentModel === 'loan') {
|
||||
const monthlyRate = inputs.loanInterestRate / 12;
|
||||
const numPayments = inputs.timeframe * 12;
|
||||
// Calculate fixed monthly payment using amortization formula
|
||||
if (monthlyRate > 0) {
|
||||
monthlyLoanPayment = inputs.investmentAmount *
|
||||
(monthlyRate * Math.pow(1 + monthlyRate, numPayments)) /
|
||||
(Math.pow(1 + monthlyRate, numPayments) - 1);
|
||||
} else {
|
||||
monthlyLoanPayment = inputs.investmentAmount / numPayments;
|
||||
}
|
||||
}
|
||||
|
||||
// Market-realistic investment scaling factor (only for direct investment)
|
||||
// Conservative scaling based on industry standards and economies of scale
|
||||
const baseInvestment = 500000;
|
||||
let investmentScaleFactor;
|
||||
let churnReductionFactor;
|
||||
let revenueMultiplier;
|
||||
let acceleratedBreakEvenFactor;
|
||||
|
||||
if (inputs.investmentModel === 'loan') {
|
||||
investmentScaleFactor = 1.0;
|
||||
churnReductionFactor = 1.0;
|
||||
revenueMultiplier = 1.0;
|
||||
acceleratedBreakEvenFactor = 1.0;
|
||||
} else {
|
||||
// Conservative linear scaling with market-realistic caps
|
||||
if (inputs.investmentAmount <= baseInvestment) {
|
||||
investmentScaleFactor = inputs.investmentAmount / baseInvestment;
|
||||
} else if (inputs.investmentAmount <= 1000000) {
|
||||
// 500k to 1M: Moderate linear scaling (1.0x to 1.5x)
|
||||
const ratio = inputs.investmentAmount / baseInvestment;
|
||||
investmentScaleFactor = 1.0 + ((ratio - 1.0) * 0.5);
|
||||
} else if (inputs.investmentAmount <= 1500000) {
|
||||
// 1M to 1.5M: Conservative scaling (1.5x to 1.8x)
|
||||
const progress = (inputs.investmentAmount - 1000000) / 500000;
|
||||
investmentScaleFactor = 1.5 + (progress * 0.3);
|
||||
} else {
|
||||
// 1.5M to 2M: Maximum realistic scaling (1.8x to 2.0x)
|
||||
const progress = (inputs.investmentAmount - 1500000) / 500000;
|
||||
investmentScaleFactor = 1.8 + (progress * 0.2);
|
||||
}
|
||||
|
||||
// Market-sustainable revenue premiums (10-20% max)
|
||||
if (inputs.investmentAmount >= 2000000) {
|
||||
revenueMultiplier = 1.2; // 20% premium - maximum sustainable
|
||||
} else if (inputs.investmentAmount >= 1500000) {
|
||||
revenueMultiplier = 1.15; // 15% premium
|
||||
} else if (inputs.investmentAmount >= 1000000) {
|
||||
revenueMultiplier = 1.1; // 10% premium
|
||||
} else {
|
||||
revenueMultiplier = 1.0; // No premium
|
||||
}
|
||||
|
||||
// Modest break-even improvements (not dramatic acceleration)
|
||||
if (inputs.investmentAmount >= 1500000) {
|
||||
acceleratedBreakEvenFactor = 1.15; // 15% faster break-even
|
||||
} else if (inputs.investmentAmount >= 1000000) {
|
||||
acceleratedBreakEvenFactor = 1.1; // 10% faster break-even
|
||||
} else {
|
||||
acceleratedBreakEvenFactor = 1.0; // Standard break-even
|
||||
}
|
||||
|
||||
// Realistic churn reduction based on customer success investments
|
||||
const churnReductionRatio = Math.min((inputs.investmentAmount - baseInvestment) / 1500000, 1.0);
|
||||
churnReductionFactor = Math.max(0.75, 1 - (churnReductionRatio * 0.25)); // Up to 25% churn reduction
|
||||
}
|
||||
|
||||
// Calculate adjusted churn rate with investment-based reduction
|
||||
const adjustedChurnRate = scenario.churnRate * churnReductionFactor;
|
||||
|
||||
const totalMonths = inputs.timeframe * 12;
|
||||
const monthlyData = [];
|
||||
let currentInstances = 0;
|
||||
let cumulativeCSPRevenue = 0;
|
||||
let cumulativeServalaRevenue = 0;
|
||||
let breakEvenMonth = null;
|
||||
|
||||
// Calculate commercially viable grace period based on investment size
|
||||
const baseGracePeriod = inputs.gracePeriod;
|
||||
let gracePeriodBonus;
|
||||
if (inputs.investmentAmount >= 1500000) {
|
||||
gracePeriodBonus = 3; // 3 months extra for large investments
|
||||
} else if (inputs.investmentAmount >= 1000000) {
|
||||
gracePeriodBonus = 2; // 2 months extra for medium investments
|
||||
} else {
|
||||
gracePeriodBonus = Math.floor((inputs.investmentAmount - baseInvestment) / 500000); // More conservative bonus calculation
|
||||
}
|
||||
const effectiveGracePeriod = inputs.investmentModel === 'loan' ? 0 :
|
||||
Math.min(baseGracePeriod + gracePeriodBonus, 6); // Maximum 6 months grace period
|
||||
|
||||
// Track baseline performance for performance bonuses (direct investment only)
|
||||
let baselineInstances = 0; // Will track expected instances without performance scaling
|
||||
|
||||
// Track phase progression
|
||||
let currentPhase = 0;
|
||||
let monthsInCurrentPhase = 0;
|
||||
|
||||
for (let month = 1; month <= totalMonths; month++) {
|
||||
// Determine current phase
|
||||
if (monthsInCurrentPhase >= scenario.phases[currentPhase].months && currentPhase < scenario.phases.length - 1) {
|
||||
currentPhase++;
|
||||
monthsInCurrentPhase = 0;
|
||||
}
|
||||
|
||||
// Calculate new instances for this month with investment scaling
|
||||
const baseNewInstances = scenario.phases[currentPhase].newInstancesPerMonth;
|
||||
const newInstances = Math.floor(baseNewInstances * investmentScaleFactor);
|
||||
|
||||
// Track baseline instances (without investment scaling) for performance comparison
|
||||
const baselineNewInstances = baseNewInstances;
|
||||
const baselineChurnedInstances = Math.floor(baselineInstances * scenario.churnRate);
|
||||
baselineInstances = baselineInstances + baselineNewInstances - baselineChurnedInstances;
|
||||
|
||||
// Calculate churn using the pre-calculated adjusted churn rate
|
||||
const churnedInstances = Math.floor(currentInstances * adjustedChurnRate);
|
||||
|
||||
// Update total instances
|
||||
currentInstances = currentInstances + newInstances - churnedInstances;
|
||||
|
||||
// Calculate revenue based on investment model
|
||||
let cspRevenue, servalaRevenue, monthlyRevenue, performanceBonus = 0, adjustedServalaShare = inputs.servalaShare;
|
||||
|
||||
if (inputs.investmentModel === 'loan') {
|
||||
// Loan model: CSP receives fixed monthly loan payment (predictable returns)
|
||||
cspRevenue = monthlyLoanPayment;
|
||||
servalaRevenue = 0;
|
||||
monthlyRevenue = monthlyLoanPayment;
|
||||
} else {
|
||||
// Direct investment model: Revenue based on instances with performance incentives
|
||||
// Service revenue (shared with Servala) + Core service revenue (100% to CSP)
|
||||
const baseServiceRevenue = currentInstances * inputs.revenuePerInstance;
|
||||
const baseCoreRevenue = currentInstances * inputs.coreServiceRevenue;
|
||||
|
||||
// Apply revenue multiplier for large investments (economies of scale)
|
||||
const serviceRevenue = baseServiceRevenue * revenueMultiplier;
|
||||
const coreRevenue = baseCoreRevenue * revenueMultiplier;
|
||||
monthlyRevenue = serviceRevenue;
|
||||
|
||||
// Calculate realistic performance bonus based on investment size
|
||||
let maxPerformanceBonus;
|
||||
if (inputs.investmentAmount >= 2000000) {
|
||||
maxPerformanceBonus = 0.15; // 15% max bonus for largest investments
|
||||
} else if (inputs.investmentAmount >= 1500000) {
|
||||
maxPerformanceBonus = 0.12; // 12% max bonus for large investments
|
||||
} else if (inputs.investmentAmount >= 1000000) {
|
||||
maxPerformanceBonus = 0.10; // 10% max bonus for medium investments
|
||||
} else {
|
||||
maxPerformanceBonus = 0.08; // 8% max bonus for base investments
|
||||
}
|
||||
|
||||
// Calculate performance bonus if CSP exceeds baseline expectations
|
||||
if (baselineInstances > 0 && month > 6) { // Start performance tracking after 6 months
|
||||
const performanceRatio = currentInstances / Math.max(baselineInstances, 1);
|
||||
if (performanceRatio > 1.1) { // 10% threshold for performance bonus
|
||||
performanceBonus = Math.max(0, Math.min(maxPerformanceBonus, (performanceRatio - 1.1) * 0.5));
|
||||
adjustedServalaShare = Math.max(0.10, inputs.servalaShare - performanceBonus);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine revenue split based on dynamic grace period
|
||||
if (month <= effectiveGracePeriod) {
|
||||
// During grace period: CSP keeps all service revenue + core revenue
|
||||
cspRevenue = serviceRevenue + coreRevenue;
|
||||
servalaRevenue = 0;
|
||||
} else {
|
||||
// After grace period: CSP keeps share of service revenue + all core revenue
|
||||
cspRevenue = (serviceRevenue * (1 - adjustedServalaShare)) + coreRevenue;
|
||||
servalaRevenue = serviceRevenue * adjustedServalaShare;
|
||||
}
|
||||
}
|
||||
|
||||
// Update cumulative revenue
|
||||
cumulativeCSPRevenue += cspRevenue;
|
||||
cumulativeServalaRevenue += servalaRevenue;
|
||||
|
||||
// Enhanced break-even calculation with acceleration for large investments
|
||||
let netPosition; // CSP's net financial position
|
||||
if (inputs.investmentModel === 'loan') {
|
||||
// For loan model: net position is cumulative payments received minus loan principal outstanding
|
||||
const principalPaid = inputs.investmentAmount * (month / totalMonths); // Simplified principal tracking
|
||||
netPosition = cumulativeCSPRevenue - (inputs.investmentAmount - principalPaid);
|
||||
} else {
|
||||
// For direct investment: accelerated break-even for large investments
|
||||
// Large investments benefit from faster effective cost recovery
|
||||
const adjustedInvestment = inputs.investmentAmount / acceleratedBreakEvenFactor;
|
||||
netPosition = cumulativeCSPRevenue - adjustedInvestment;
|
||||
}
|
||||
|
||||
if (breakEvenMonth === null && netPosition >= 0) {
|
||||
breakEvenMonth = month;
|
||||
}
|
||||
|
||||
// Calculate revenue components for data tracking
|
||||
let serviceRevenueForData, coreRevenueForData, totalRevenueForData;
|
||||
if (inputs.investmentModel === 'loan') {
|
||||
serviceRevenueForData = monthlyLoanPayment;
|
||||
coreRevenueForData = 0;
|
||||
totalRevenueForData = monthlyLoanPayment;
|
||||
} else {
|
||||
serviceRevenueForData = currentInstances * inputs.revenuePerInstance;
|
||||
coreRevenueForData = currentInstances * inputs.coreServiceRevenue;
|
||||
totalRevenueForData = serviceRevenueForData + coreRevenueForData;
|
||||
}
|
||||
|
||||
monthlyData.push({
|
||||
month,
|
||||
scenario: scenario.name,
|
||||
newInstances,
|
||||
churnedInstances,
|
||||
totalInstances: currentInstances,
|
||||
baselineInstances,
|
||||
serviceRevenue: serviceRevenueForData,
|
||||
coreRevenue: coreRevenueForData,
|
||||
totalRevenue: totalRevenueForData,
|
||||
monthlyRevenue: serviceRevenueForData, // Keep for backward compatibility
|
||||
cspRevenue,
|
||||
servalaRevenue,
|
||||
cumulativeCSPRevenue,
|
||||
cumulativeServalaRevenue,
|
||||
netPosition,
|
||||
performanceBonus,
|
||||
adjustedServalaShare,
|
||||
effectiveGracePeriod,
|
||||
investmentScaleFactor: investmentScaleFactor,
|
||||
adjustedChurnRate: adjustedChurnRate,
|
||||
roiPercent: inputs.investmentModel === 'loan' ?
|
||||
((cumulativeCSPRevenue - inputs.investmentAmount) / inputs.investmentAmount * 100) :
|
||||
(netPosition / inputs.investmentAmount * 100)
|
||||
});
|
||||
|
||||
monthsInCurrentPhase++;
|
||||
}
|
||||
|
||||
// Calculate final metrics with enhanced business intelligence
|
||||
const totalRevenue = cumulativeCSPRevenue + cumulativeServalaRevenue;
|
||||
let finalNetPosition, roi;
|
||||
if (inputs.investmentModel === 'loan') {
|
||||
finalNetPosition = cumulativeCSPRevenue - inputs.investmentAmount;
|
||||
roi = (finalNetPosition / inputs.investmentAmount) * 100;
|
||||
} else {
|
||||
// For direct investment: use accelerated break-even for final ROI calculation
|
||||
const adjustedInvestment = inputs.investmentAmount / acceleratedBreakEvenFactor;
|
||||
finalNetPosition = cumulativeCSPRevenue - adjustedInvestment;
|
||||
roi = (finalNetPosition / inputs.investmentAmount) * 100;
|
||||
}
|
||||
|
||||
// Calculate average performance bonus over the investment period
|
||||
const performanceBonusMonths = monthlyData.filter(m => m.performanceBonus > 0);
|
||||
const avgPerformanceBonus = performanceBonusMonths.length > 0 ?
|
||||
performanceBonusMonths.reduce((sum, m) => sum + m.performanceBonus, 0) / performanceBonusMonths.length : 0;
|
||||
|
||||
return {
|
||||
scenario: scenario.name,
|
||||
investmentModel: inputs.investmentModel,
|
||||
finalInstances: currentInstances,
|
||||
baselineFinalInstances: baselineInstances,
|
||||
totalRevenue,
|
||||
cspRevenue: cumulativeCSPRevenue,
|
||||
servalaRevenue: cumulativeServalaRevenue,
|
||||
netPosition: finalNetPosition,
|
||||
roi,
|
||||
breakEvenMonth,
|
||||
effectiveGracePeriod,
|
||||
avgPerformanceBonus,
|
||||
monthlyData,
|
||||
investmentScaleFactor: investmentScaleFactor,
|
||||
revenueMultiplier: revenueMultiplier,
|
||||
acceleratedBreakEvenFactor: acceleratedBreakEvenFactor,
|
||||
maxPerformanceBonus: inputs.investmentModel === 'direct' ?
|
||||
(inputs.investmentAmount >= 2000000 ? 15 :
|
||||
inputs.investmentAmount >= 1500000 ? 12 :
|
||||
inputs.investmentAmount >= 1000000 ? 10 : 8) : 0,
|
||||
adjustedChurnRate: adjustedChurnRate * 100,
|
||||
performanceMultiplier: baselineInstances > 0 ? (currentInstances / baselineInstances) : 1.0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error calculating scenario ${scenarioKey}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
updateCalculations() {
|
||||
try {
|
||||
const inputs = this.getInputValues();
|
||||
this.results = {};
|
||||
this.monthlyData = {};
|
||||
|
||||
// Show loading spinner
|
||||
const loadingSpinner = document.getElementById('loading-spinner');
|
||||
|
||||
if (loadingSpinner) loadingSpinner.style.display = 'block';
|
||||
|
||||
// Calculate results for each enabled scenario with both investment models
|
||||
Object.keys(this.scenarios).forEach(scenarioKey => {
|
||||
// Calculate for Direct investment model
|
||||
const directInputs = { ...inputs, investmentModel: 'direct' };
|
||||
const directResult = this.calculateScenario(scenarioKey, directInputs);
|
||||
if (directResult) {
|
||||
this.results[scenarioKey + '_direct'] = directResult;
|
||||
this.monthlyData[scenarioKey + '_direct'] = directResult.monthlyData;
|
||||
}
|
||||
|
||||
// Calculate for Loan investment model
|
||||
const loanInputs = { ...inputs, investmentModel: 'loan' };
|
||||
const loanResult = this.calculateScenario(scenarioKey, loanInputs);
|
||||
if (loanResult) {
|
||||
this.results[scenarioKey + '_loan'] = loanResult;
|
||||
this.monthlyData[scenarioKey + '_loan'] = loanResult.monthlyData;
|
||||
}
|
||||
});
|
||||
|
||||
// Update UI immediately
|
||||
this.updateSummaryMetrics();
|
||||
this.updateCharts();
|
||||
this.updateComparisonTable();
|
||||
this.updateMonthlyBreakdown();
|
||||
|
||||
// Hide loading spinner
|
||||
if (loadingSpinner) loadingSpinner.style.display = 'none';
|
||||
} catch (error) {
|
||||
console.error('Error updating calculations:', error);
|
||||
// Hide loading spinner on error
|
||||
const loadingSpinner = document.getElementById('loading-spinner');
|
||||
if (loadingSpinner) loadingSpinner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,452 +0,0 @@
|
|||
/**
|
||||
* Chart Management Module
|
||||
* Handles Chart.js initialization and updates
|
||||
*/
|
||||
class ChartManager {
|
||||
constructor(calculator) {
|
||||
this.calculator = calculator;
|
||||
this.charts = {};
|
||||
}
|
||||
|
||||
initializeCharts() {
|
||||
// Check if Chart.js is available
|
||||
if (typeof Chart === 'undefined') {
|
||||
console.error('Chart.js library not loaded. Charts will not be available.');
|
||||
this.showChartError('Chart.js library failed to load. Please refresh the page.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// ROI Progression Chart (replaces Instance Growth Chart)
|
||||
const roiCanvas = document.getElementById('instanceGrowthChart');
|
||||
if (!roiCanvas) {
|
||||
console.error('ROI progression chart canvas not found');
|
||||
return;
|
||||
}
|
||||
const roiCtx = roiCanvas.getContext('2d');
|
||||
this.charts.roiProgression = new Chart(roiCtx, {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [] },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: true, text: 'ROI Progression Over Time - Direct Investment Only' }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
title: { display: true, text: 'ROI (%)' },
|
||||
grid: {
|
||||
color: function(context) {
|
||||
return context.tick.value === 0 ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.1)';
|
||||
}
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: 'Month' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Net Position Chart (replaces simple Revenue Chart)
|
||||
const netPositionCanvas = document.getElementById('revenueChart');
|
||||
if (!netPositionCanvas) {
|
||||
console.error('Net position chart canvas not found');
|
||||
return;
|
||||
}
|
||||
const netPositionCtx = netPositionCanvas.getContext('2d');
|
||||
this.charts.netPosition = new Chart(netPositionCtx, {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [] },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: true, text: 'Net Financial Position - Direct Investment Only' }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
title: { display: true, text: 'Net Position (CHF)' },
|
||||
grid: {
|
||||
color: function(context) {
|
||||
return context.tick.value === 0 ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.1)';
|
||||
}
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: 'Month' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// CSP Revenue Breakdown Chart
|
||||
const cspRevenueCanvas = document.getElementById('cspRevenueChart');
|
||||
if (!cspRevenueCanvas) {
|
||||
console.error('CSP revenue breakdown chart canvas not found');
|
||||
return;
|
||||
}
|
||||
const cspRevenueCtx = cspRevenueCanvas.getContext('2d');
|
||||
this.charts.cspRevenue = new Chart(cspRevenueCtx, {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [] },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 10,
|
||||
right: 200, // Add space for side legend
|
||||
top: 10,
|
||||
bottom: 10
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
align: 'start',
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
usePointStyle: true,
|
||||
generateLabels: function(chart) {
|
||||
const original = Chart.defaults.plugins.legend.labels.generateLabels;
|
||||
const labels = original.call(this, chart);
|
||||
|
||||
// Group labels by scenario for better organization
|
||||
return labels.map(label => {
|
||||
// Shorten label text for better fit
|
||||
if (label.text.includes('Service Revenue')) {
|
||||
label.text = label.text.replace(' - Service Revenue', ' - Service');
|
||||
}
|
||||
if (label.text.includes('Core Service Revenue')) {
|
||||
label.text = label.text.replace(' - Core Service Revenue', ' - Core');
|
||||
}
|
||||
if (label.text.includes('CSP Total')) {
|
||||
label.text = label.text.replace(' - CSP Total', ' - Total');
|
||||
}
|
||||
if (label.text.includes('Servala Revenue')) {
|
||||
label.text = label.text.replace(' - Servala Revenue', ' - Servala');
|
||||
}
|
||||
return label;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'CSP Revenue Growth - Direct Investment Only',
|
||||
font: {
|
||||
size: 14,
|
||||
weight: 'bold'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: { display: true, text: 'Revenue Amount' },
|
||||
stacked: false,
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.1)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: 'Month' },
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.05)'
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 3,
|
||||
hoverRadius: 6
|
||||
},
|
||||
line: {
|
||||
tension: 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Performance Comparison Chart (for cashFlowChart canvas)
|
||||
const performanceCanvas = document.getElementById('cashFlowChart');
|
||||
if (!performanceCanvas) {
|
||||
console.error('Performance comparison chart canvas not found');
|
||||
return;
|
||||
}
|
||||
const performanceCtx = performanceCanvas.getContext('2d');
|
||||
this.charts.performance = new Chart(performanceCtx, {
|
||||
type: 'bar',
|
||||
data: { labels: [], datasets: [] },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: true, text: 'Model Performance Comparison' }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: { display: true, text: 'ROI (%)' }
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: 'Growth Scenario' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing charts:', error);
|
||||
this.showChartError('Failed to initialize charts. Please refresh the page.');
|
||||
}
|
||||
}
|
||||
|
||||
showChartError(message) {
|
||||
// Show error message in place of charts
|
||||
const chartContainers = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart', 'cspRevenueChart'];
|
||||
chartContainers.forEach(containerId => {
|
||||
const container = document.getElementById(containerId);
|
||||
if (container) {
|
||||
container.style.display = 'flex';
|
||||
container.style.justifyContent = 'center';
|
||||
container.style.alignItems = 'center';
|
||||
container.style.minHeight = '300px';
|
||||
container.style.backgroundColor = '#f8f9fa';
|
||||
container.style.border = '1px solid #dee2e6';
|
||||
container.style.borderRadius = '4px';
|
||||
container.innerHTML = `<div class="text-muted text-center"><i class="bi bi-exclamation-triangle"></i><br>${message}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateCharts() {
|
||||
try {
|
||||
const scenarios = Object.keys(this.calculator.results);
|
||||
if (scenarios.length === 0 || !this.charts.roiProgression) return;
|
||||
|
||||
const colors = {
|
||||
conservative: '#28a745',
|
||||
moderate: '#ffc107',
|
||||
aggressive: '#dc3545'
|
||||
};
|
||||
|
||||
const modelColors = {
|
||||
direct: { border: '', background: '80' },
|
||||
loan: { border: '', background: '40' }
|
||||
};
|
||||
|
||||
// Get month labels
|
||||
const maxMonths = Math.max(...scenarios.map(s => this.calculator.monthlyData[s].length));
|
||||
const monthLabels = Array.from({ length: maxMonths }, (_, i) => `M${i + 1}`);
|
||||
|
||||
// Update ROI Progression Chart - Direct Investment Only
|
||||
this.charts.roiProgression.data.labels = monthLabels;
|
||||
this.charts.roiProgression.data.datasets = scenarios.filter(s =>
|
||||
this.calculator.results[s] && s.includes('_direct')
|
||||
).map(scenario => {
|
||||
const scenarioBase = scenario.replace('_direct', '');
|
||||
const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase;
|
||||
|
||||
return {
|
||||
label: `${scenarioName}`,
|
||||
data: this.calculator.monthlyData[scenario].map(d => d.roiPercent),
|
||||
borderColor: colors[scenarioBase],
|
||||
backgroundColor: colors[scenarioBase] + '30',
|
||||
borderWidth: 3,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: this.calculator.monthlyData[scenario].map(d =>
|
||||
d.roiPercent >= 0 ? colors[scenarioBase] : '#dc3545'
|
||||
)
|
||||
};
|
||||
});
|
||||
this.charts.roiProgression.update();
|
||||
|
||||
// Update Net Position Chart (Break-Even Analysis) - Direct Investment Only
|
||||
this.charts.netPosition.data.labels = monthLabels;
|
||||
this.charts.netPosition.data.datasets = scenarios.filter(s =>
|
||||
this.calculator.results[s] && s.includes('_direct')
|
||||
).map(scenario => {
|
||||
const scenarioBase = scenario.replace('_direct', '');
|
||||
const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase;
|
||||
|
||||
return {
|
||||
label: `${scenarioName} Net Position`,
|
||||
data: this.calculator.monthlyData[scenario].map(d => d.netPosition),
|
||||
borderColor: colors[scenarioBase],
|
||||
backgroundColor: colors[scenarioBase] + '30',
|
||||
borderWidth: 3,
|
||||
tension: 0.4,
|
||||
fill: {
|
||||
target: 'origin',
|
||||
above: colors[scenarioBase] + '20',
|
||||
below: '#dc354510'
|
||||
}
|
||||
};
|
||||
});
|
||||
this.charts.netPosition.update();
|
||||
|
||||
// Update Model Comparison Chart - Direct comparison of both models
|
||||
const inputs = this.calculator.getInputValues();
|
||||
|
||||
// Get unique scenario names (without model suffix)
|
||||
const baseScenarios = ['conservative', 'moderate', 'aggressive'].filter(s =>
|
||||
this.calculator.scenarios[s].enabled
|
||||
);
|
||||
const comparisonLabels = baseScenarios.map(s => this.calculator.scenarios[s].name);
|
||||
|
||||
// Get net profit data for both models
|
||||
// Update CSP Revenue Breakdown Chart (Direct Investment Only)
|
||||
this.charts.cspRevenue.data.labels = monthLabels;
|
||||
this.charts.cspRevenue.data.datasets = [];
|
||||
|
||||
// Filter to only direct investment scenarios
|
||||
const directScenarios = scenarios.filter(s =>
|
||||
this.calculator.results[s] && s.includes('_direct')
|
||||
);
|
||||
|
||||
// Define revenue types and their styling
|
||||
const revenueTypes = [
|
||||
{
|
||||
key: 'serviceRevenue',
|
||||
label: 'Service Revenue',
|
||||
borderWidth: 2,
|
||||
borderDash: [],
|
||||
opacity: '40'
|
||||
},
|
||||
{
|
||||
key: 'coreRevenue',
|
||||
label: 'Core Service Revenue',
|
||||
borderWidth: 2,
|
||||
borderDash: [3, 3],
|
||||
opacity: '60'
|
||||
},
|
||||
{
|
||||
key: 'cspRevenue',
|
||||
label: 'CSP Total',
|
||||
borderWidth: 3,
|
||||
borderDash: [],
|
||||
opacity: 'FF'
|
||||
},
|
||||
{
|
||||
key: 'servalaRevenue',
|
||||
label: 'Servala Revenue',
|
||||
borderWidth: 1,
|
||||
borderDash: [5, 5],
|
||||
opacity: '80',
|
||||
color: '#6c757d'
|
||||
}
|
||||
];
|
||||
|
||||
// Add datasets organized by revenue type for better legend grouping
|
||||
directScenarios.forEach(scenario => {
|
||||
const scenarioBase = scenario.replace('_direct', '');
|
||||
const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase;
|
||||
const monthlyData = this.calculator.monthlyData[scenario];
|
||||
const scenarioColor = colors[scenarioBase] || '#007bff';
|
||||
|
||||
revenueTypes.forEach(type => {
|
||||
// Skip Servala revenue if it's zero (no revenue sharing)
|
||||
if (type.key === 'servalaRevenue') {
|
||||
const hasServalaRevenue = monthlyData.some(d => (d.servalaRevenue || 0) > 0);
|
||||
if (!hasServalaRevenue) return;
|
||||
}
|
||||
|
||||
// Skip core revenue if it's zero
|
||||
if (type.key === 'coreRevenue') {
|
||||
const hasCoreRevenue = monthlyData.some(d => (d.coreRevenue || 0) > 0);
|
||||
if (!hasCoreRevenue) return;
|
||||
}
|
||||
|
||||
const dataValues = monthlyData.map(d => {
|
||||
if (type.key === 'serviceRevenue') return d.serviceRevenue || d.monthlyRevenue || 0;
|
||||
return d[type.key] || 0;
|
||||
});
|
||||
|
||||
this.charts.cspRevenue.data.datasets.push({
|
||||
label: `${scenarioName} - ${type.label}`,
|
||||
data: dataValues,
|
||||
borderColor: type.color || scenarioColor,
|
||||
backgroundColor: (type.color || scenarioColor) + type.opacity,
|
||||
borderWidth: type.borderWidth,
|
||||
borderDash: type.borderDash,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointBackgroundColor: type.color || scenarioColor,
|
||||
pointBorderColor: '#fff',
|
||||
pointBorderWidth: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this.charts.cspRevenue.update();
|
||||
|
||||
// Update Performance Comparison Chart (ROI comparison for both models)
|
||||
this.charts.performance.data.labels = comparisonLabels;
|
||||
|
||||
// Get ROI data for both models
|
||||
const directROIData = baseScenarios.map(scenario => {
|
||||
const result = this.calculator.results[scenario + '_direct'];
|
||||
return result ? result.roi : 0;
|
||||
});
|
||||
|
||||
const loanROIData = baseScenarios.map(scenario => {
|
||||
const result = this.calculator.results[scenario + '_loan'];
|
||||
return result ? result.roi : 0;
|
||||
});
|
||||
|
||||
this.charts.performance.data.datasets = [
|
||||
{
|
||||
label: 'Direct Investment ROI',
|
||||
data: directROIData,
|
||||
backgroundColor: baseScenarios.map(scenario => colors[scenario] + '80'),
|
||||
borderColor: baseScenarios.map(scenario => colors[scenario]),
|
||||
borderWidth: 2
|
||||
},
|
||||
{
|
||||
label: `Loan Model ROI (${(inputs.loanInterestRate * 100).toFixed(1)}% fixed)`,
|
||||
data: loanROIData,
|
||||
backgroundColor: '#ffc10780',
|
||||
borderColor: '#e0a800',
|
||||
borderWidth: 2
|
||||
}
|
||||
];
|
||||
|
||||
this.charts.performance.update();
|
||||
} catch (error) {
|
||||
console.error('Error updating charts:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to calculate loan payment
|
||||
calculateLoanPayment(principal, annualRate, years) {
|
||||
try {
|
||||
const monthlyRate = annualRate / 12;
|
||||
const numberOfPayments = years * 12;
|
||||
|
||||
if (monthlyRate === 0) {
|
||||
return principal / numberOfPayments;
|
||||
}
|
||||
|
||||
const monthlyPayment = principal * (monthlyRate * Math.pow(1 + monthlyRate, numberOfPayments)) /
|
||||
(Math.pow(1 + monthlyRate, numberOfPayments) - 1);
|
||||
|
||||
return monthlyPayment;
|
||||
} catch (error) {
|
||||
console.error('Error calculating loan payment:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,599 +0,0 @@
|
|||
/**
|
||||
* Export Management Module
|
||||
* Handles PDF and CSV export functionality
|
||||
*/
|
||||
class ExportManager {
|
||||
constructor(calculator, uiManager) {
|
||||
this.calculator = calculator;
|
||||
this.uiManager = uiManager;
|
||||
}
|
||||
|
||||
async exportToPDF() {
|
||||
// Check if jsPDF is available
|
||||
if (typeof window.jspdf === 'undefined') {
|
||||
alert('PDF export library is loading. Please try again in a moment.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show model selection dialog
|
||||
const selectedModels = await this.showModelSelectionDialog();
|
||||
if (!selectedModels) {
|
||||
return; // User cancelled
|
||||
}
|
||||
|
||||
try {
|
||||
const { jsPDF } = window.jspdf;
|
||||
const doc = new jsPDF('p', 'mm', 'a4');
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const margin = 20;
|
||||
|
||||
// Store selected models for use throughout the export
|
||||
this.selectedModels = selectedModels;
|
||||
|
||||
// Color scheme
|
||||
const colors = {
|
||||
primary: [0, 123, 255],
|
||||
success: [40, 167, 69],
|
||||
warning: [255, 193, 7],
|
||||
danger: [220, 53, 69],
|
||||
dark: [33, 37, 41],
|
||||
muted: [108, 117, 125]
|
||||
};
|
||||
|
||||
let currentPage = 1;
|
||||
let yPos = 30;
|
||||
|
||||
// Helper function to add new page with header/footer
|
||||
const addPage = () => {
|
||||
doc.addPage();
|
||||
currentPage++;
|
||||
yPos = 30;
|
||||
this.addPageHeader(doc, margin, colors);
|
||||
};
|
||||
|
||||
// Helper function to check if we need a new page
|
||||
const checkPageBreak = (requiredSpace) => {
|
||||
if (yPos + requiredSpace > pageHeight - 30) {
|
||||
addPage();
|
||||
}
|
||||
};
|
||||
|
||||
// Title Page
|
||||
this.createTitlePage(doc, pageWidth, pageHeight, margin, colors);
|
||||
|
||||
// Executive Summary Page
|
||||
addPage();
|
||||
yPos = this.createExecutiveSummary(doc, yPos, margin, colors);
|
||||
|
||||
// Investment Parameters Page
|
||||
checkPageBreak(60);
|
||||
yPos = this.createParametersSection(doc, yPos, margin, colors);
|
||||
|
||||
// Model Comparison Page
|
||||
checkPageBreak(80);
|
||||
yPos = this.createModelComparison(doc, yPos, margin, colors);
|
||||
|
||||
// Charts Pages
|
||||
await this.addChartsToPDF(doc, colors, margin, addPage, checkPageBreak);
|
||||
|
||||
// Detailed Results Table
|
||||
this.createDetailedResults(doc, colors, margin, addPage);
|
||||
|
||||
// Add footers to all pages
|
||||
this.addFootersToAllPages(doc, currentPage, colors);
|
||||
|
||||
// Save the PDF
|
||||
const filename = `servala-roi-investment-analysis-${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
doc.save(filename);
|
||||
|
||||
} catch (error) {
|
||||
console.error('PDF Export Error:', error);
|
||||
alert('An error occurred while generating the PDF. Please try again or export as CSV instead.');
|
||||
}
|
||||
}
|
||||
|
||||
createTitlePage(doc, pageWidth, pageHeight, margin, colors) {
|
||||
// Background design element
|
||||
doc.setFillColor(...colors.primary);
|
||||
doc.rect(0, 0, pageWidth, 60, 'F');
|
||||
|
||||
// White title text
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(28);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Investment Analysis Report', pageWidth/2, 35, { align: 'center' });
|
||||
|
||||
// Subtitle
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('CSP Partnership ROI Calculator', pageWidth/2, 45, { align: 'center' });
|
||||
|
||||
// Company section
|
||||
doc.setTextColor(...colors.dark);
|
||||
doc.setFontSize(20);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Servala Partnership Opportunity', pageWidth/2, 90, { align: 'center' });
|
||||
|
||||
// Investment summary box
|
||||
const inputs = this.calculator.getInputValues();
|
||||
const boxY = 110;
|
||||
const boxHeight = 40;
|
||||
|
||||
doc.setFillColor(248, 249, 250);
|
||||
doc.roundedRect(margin, boxY, pageWidth - 2*margin, boxHeight, 3, 3, 'F');
|
||||
doc.setDrawColor(...colors.muted);
|
||||
doc.roundedRect(margin, boxY, pageWidth - 2*margin, boxHeight, 3, 3, 'S');
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(...colors.primary);
|
||||
doc.text('Investment Overview', pageWidth/2, boxY + 12, { align: 'center' });
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(...colors.dark);
|
||||
doc.text(`Investment Amount: ${this.formatCurrency(inputs.investmentAmount)}`, pageWidth/2, boxY + 22, { align: 'center' });
|
||||
doc.text(`Analysis Period: ${inputs.timeframe} years`, pageWidth/2, boxY + 32, { align: 'center' });
|
||||
|
||||
// Generated date
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...colors.muted);
|
||||
const currentDate = new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
doc.text(`Generated on: ${currentDate}`, pageWidth/2, pageHeight - 30, { align: 'center' });
|
||||
}
|
||||
|
||||
addPageHeader(doc, margin, colors) {
|
||||
doc.setFillColor(...colors.primary);
|
||||
doc.rect(0, 0, doc.internal.pageSize.getWidth(), 15, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Servala CSP Investment Analysis', margin, 10);
|
||||
}
|
||||
|
||||
createExecutiveSummary(doc, yPos, margin, colors) {
|
||||
doc.setFontSize(18);
|
||||
doc.setTextColor(...colors.primary);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Executive Summary', margin, yPos);
|
||||
yPos += 15;
|
||||
|
||||
const enabledResults = Object.values(this.calculator.results);
|
||||
const directResults = enabledResults.filter(r => r.investmentModel === 'direct' && this.selectedModels.direct);
|
||||
const loanResults = enabledResults.filter(r => r.investmentModel === 'loan' && this.selectedModels.loan);
|
||||
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(...colors.dark);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
// Investment models comparison
|
||||
if (directResults.length > 0 && loanResults.length > 0) {
|
||||
const avgDirectROI = directResults.reduce((sum, r) => sum + r.roi, 0) / directResults.length;
|
||||
const avgLoanROI = loanResults.reduce((sum, r) => sum + r.roi, 0) / loanResults.length;
|
||||
const avgDirectNetPos = directResults.reduce((sum, r) => sum + r.netPosition, 0) / directResults.length;
|
||||
const avgLoanNetPos = loanResults.reduce((sum, r) => sum + r.netPosition, 0) / loanResults.length;
|
||||
|
||||
doc.text('This analysis compares two investment models across multiple growth scenarios:', margin, yPos);
|
||||
yPos += 10;
|
||||
|
||||
// Direct Investment Summary
|
||||
doc.setFillColor(40, 167, 69, 0.1); // Success color with opacity
|
||||
doc.roundedRect(margin, yPos, (doc.internal.pageSize.getWidth() - 3*margin)/2, 35, 2, 2, 'F');
|
||||
|
||||
doc.setTextColor(...colors.success);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Direct Investment Model', margin + 5, yPos + 8);
|
||||
|
||||
doc.setTextColor(...colors.dark);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Average ROI: ${this.uiManager.formatPercentage(avgDirectROI)}`, margin + 5, yPos + 16);
|
||||
doc.text(`Average Net Profit: ${this.formatCurrency(avgDirectNetPos)}`, margin + 5, yPos + 24);
|
||||
doc.text('Performance-based with bonuses', margin + 5, yPos + 32);
|
||||
|
||||
// Loan Model Summary
|
||||
const loanBoxX = margin + (doc.internal.pageSize.getWidth() - 3*margin)/2 + 10;
|
||||
doc.setFillColor(255, 193, 7, 0.1); // Warning color with opacity
|
||||
doc.roundedRect(loanBoxX, yPos, (doc.internal.pageSize.getWidth() - 3*margin)/2, 35, 2, 2, 'F');
|
||||
|
||||
doc.setTextColor(...colors.warning);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
doc.text('Loan Model', loanBoxX + 5, yPos + 8);
|
||||
|
||||
doc.setTextColor(...colors.dark);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Average ROI: ${this.uiManager.formatPercentage(avgLoanROI)}`, loanBoxX + 5, yPos + 16);
|
||||
doc.text(`Average Net Profit: ${this.formatCurrency(avgLoanNetPos)}`, loanBoxX + 5, yPos + 24);
|
||||
doc.text('Fixed returns, guaranteed', loanBoxX + 5, yPos + 32);
|
||||
|
||||
yPos += 45;
|
||||
}
|
||||
|
||||
return yPos;
|
||||
}
|
||||
|
||||
createParametersSection(doc, yPos, margin, colors) {
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(...colors.primary);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Investment Parameters', margin, yPos);
|
||||
yPos += 15;
|
||||
|
||||
const inputs = this.calculator.getInputValues();
|
||||
|
||||
// Create parameter table
|
||||
const params = [
|
||||
['Investment Amount', this.formatCurrency(inputs.investmentAmount)],
|
||||
['Investment Timeframe', `${inputs.timeframe} years`],
|
||||
['Service Revenue per Instance', `${this.formatCurrency(inputs.revenuePerInstance)} / month`],
|
||||
['Core Service Revenue per Instance', `${this.formatCurrency(inputs.coreServiceRevenue)} / month`],
|
||||
['Total Revenue per Instance', `${this.formatCurrency(inputs.revenuePerInstance + inputs.coreServiceRevenue)} / month`],
|
||||
['Loan Interest Rate', `${(inputs.loanInterestRate * 100).toFixed(1)}%`],
|
||||
['Direct Investment Share', `${(inputs.servalaShare * 100).toFixed(0)}% to Servala`],
|
||||
['Grace Period', `${inputs.gracePeriod} months`]
|
||||
];
|
||||
|
||||
doc.setFontSize(11);
|
||||
params.forEach(([label, value]) => {
|
||||
doc.setTextColor(...colors.dark);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(label + ':', margin + 5, yPos);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(value, margin + 80, yPos);
|
||||
yPos += 8;
|
||||
});
|
||||
|
||||
return yPos + 10;
|
||||
}
|
||||
|
||||
createModelComparison(doc, yPos, margin, colors) {
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(...colors.primary);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Investment Model Comparison', margin, yPos);
|
||||
yPos += 15;
|
||||
|
||||
// Create comparison table
|
||||
const scenarios = ['Conservative', 'Moderate', 'Aggressive'];
|
||||
const tableData = [];
|
||||
|
||||
scenarios.forEach(scenarioName => {
|
||||
const directResult = Object.values(this.calculator.results)
|
||||
.find(r => r.scenario === scenarioName && r.investmentModel === 'direct');
|
||||
const loanResult = Object.values(this.calculator.results)
|
||||
.find(r => r.scenario === scenarioName && r.investmentModel === 'loan');
|
||||
|
||||
if (directResult && loanResult) {
|
||||
tableData.push([
|
||||
scenarioName,
|
||||
this.formatCurrency(directResult.netPosition),
|
||||
this.uiManager.formatPercentage(directResult.roi),
|
||||
this.formatCurrency(loanResult.netPosition),
|
||||
this.uiManager.formatPercentage(loanResult.roi)
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Table headers
|
||||
const headers = ['Scenario', 'Direct Net Profit', 'Direct ROI', 'Loan Net Profit', 'Loan ROI'];
|
||||
const colWidths = [30, 35, 25, 35, 25];
|
||||
|
||||
// Draw table
|
||||
this.drawTable(doc, margin, yPos, headers, tableData, colWidths, colors);
|
||||
|
||||
return yPos + (tableData.length + 2) * 8;
|
||||
}
|
||||
|
||||
async addChartsToPDF(doc, colors, margin, addPage, checkPageBreak) {
|
||||
// Add charts by capturing them as images
|
||||
const chartIds = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart', 'modelComparisonChart'];
|
||||
const chartTitles = [
|
||||
'ROI Progression Over Time',
|
||||
'Net Financial Position',
|
||||
'Performance Comparison',
|
||||
'Investment Model Comparison'
|
||||
];
|
||||
|
||||
for (let i = 0; i < chartIds.length; i++) {
|
||||
checkPageBreak(120);
|
||||
|
||||
const canvas = document.getElementById(chartIds[i]);
|
||||
if (canvas) {
|
||||
// Add chart title
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(...colors.primary);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(chartTitles[i], margin, doc.internal.pageSize.getHeight() - 250);
|
||||
|
||||
// Capture chart as image
|
||||
const imgData = canvas.toDataURL('image/png', 1.0);
|
||||
doc.addImage(imgData, 'PNG', margin, doc.internal.pageSize.getHeight() - 240,
|
||||
doc.internal.pageSize.getWidth() - 2*margin, 100);
|
||||
|
||||
if (i < chartIds.length - 1) addPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createDetailedResults(doc, colors, margin, addPage) {
|
||||
addPage();
|
||||
let yPos = 30;
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(...colors.primary);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Detailed Financial Results', margin, yPos);
|
||||
yPos += 15;
|
||||
|
||||
// Create detailed results table
|
||||
const headers = ['Scenario', 'Model', 'Net Profit', 'ROI', 'Break-even'];
|
||||
const colWidths = [35, 25, 35, 25, 30];
|
||||
const tableData = [];
|
||||
|
||||
Object.values(this.calculator.results).forEach(result => {
|
||||
tableData.push([
|
||||
result.scenario,
|
||||
result.investmentModel === 'direct' ? 'Direct' : 'Loan',
|
||||
this.formatCurrency(result.netPosition),
|
||||
this.uiManager.formatPercentage(result.roi),
|
||||
result.breakEvenMonth ? `${result.breakEvenMonth} months` : 'N/A'
|
||||
]);
|
||||
});
|
||||
|
||||
this.drawTable(doc, margin, yPos, headers, tableData, colWidths, colors);
|
||||
}
|
||||
|
||||
drawTable(doc, x, y, headers, data, colWidths, colors) {
|
||||
const rowHeight = 8;
|
||||
const startX = x;
|
||||
let currentY = y;
|
||||
|
||||
// Draw headers
|
||||
doc.setFillColor(...colors.primary);
|
||||
doc.rect(startX, currentY - 6, colWidths.reduce((sum, w) => sum + w, 0), rowHeight, 'F');
|
||||
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(10);
|
||||
|
||||
let currentX = startX;
|
||||
headers.forEach((header, i) => {
|
||||
doc.text(header, currentX + 2, currentY - 1);
|
||||
currentX += colWidths[i];
|
||||
});
|
||||
|
||||
currentY += rowHeight;
|
||||
|
||||
// Draw data rows
|
||||
doc.setTextColor(...colors.dark);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
data.forEach((row, rowIndex) => {
|
||||
if (rowIndex % 2 === 1) {
|
||||
doc.setFillColor(248, 249, 250);
|
||||
doc.rect(startX, currentY - 6, colWidths.reduce((sum, w) => sum + w, 0), rowHeight, 'F');
|
||||
}
|
||||
|
||||
currentX = startX;
|
||||
row.forEach((cell, i) => {
|
||||
doc.text(String(cell), currentX + 2, currentY - 1);
|
||||
currentX += colWidths[i];
|
||||
});
|
||||
|
||||
currentY += rowHeight;
|
||||
});
|
||||
|
||||
// Draw table border
|
||||
doc.setDrawColor(...colors.muted);
|
||||
doc.rect(startX, y - 6, colWidths.reduce((sum, w) => sum + w, 0), (data.length + 1) * rowHeight, 'S');
|
||||
}
|
||||
|
||||
addFootersToAllPages(doc, totalPages, colors) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(...colors.muted);
|
||||
doc.text(`Page ${i} of ${totalPages}`, pageWidth - 30, pageHeight - 10);
|
||||
doc.text('Generated by Servala CSP ROI Calculator', 20, pageHeight - 10);
|
||||
|
||||
// Add a line above footer
|
||||
doc.setDrawColor(...colors.muted);
|
||||
doc.line(20, pageHeight - 15, pageWidth - 20, pageHeight - 15);
|
||||
}
|
||||
}
|
||||
|
||||
async showModelSelectionDialog() {
|
||||
return new Promise((resolve) => {
|
||||
// Create modal dialog
|
||||
const modal = document.createElement('div');
|
||||
modal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10000;
|
||||
`;
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.style.cssText = `
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
`;
|
||||
|
||||
dialog.innerHTML = `
|
||||
<h5 style="margin-bottom: 1rem; color: #007bff;">Select Investment Models for PDF</h5>
|
||||
<p style="margin-bottom: 1.5rem; color: #6c757d;">Choose which models to include in your PDF report:</p>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: flex; align-items: center; margin-bottom: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="pdf-direct-model" checked style="margin-right: 0.5rem;">
|
||||
<span style="color: #28a745; font-weight: bold;">Direct Investment Model</span>
|
||||
</label>
|
||||
<small style="color: #6c757d; margin-left: 1.5rem;">Performance-based revenue sharing with scaling bonuses</small>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<label style="display: flex; align-items: center; margin-bottom: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="pdf-loan-model" checked style="margin-right: 0.5rem;">
|
||||
<span style="color: #ffc107; font-weight: bold;">Loan Model</span>
|
||||
</label>
|
||||
<small style="color: #6c757d; margin-left: 1.5rem;">Fixed interest lending with guaranteed returns</small>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
||||
<button id="pdf-cancel-btn" style="padding: 0.5rem 1rem; border: 1px solid #ccc; background: white; border-radius: 4px; cursor: pointer;">Cancel</button>
|
||||
<button id="pdf-export-btn" style="padding: 0.5rem 1rem; border: none; background: #007bff; color: white; border-radius: 4px; cursor: pointer;">Export PDF</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.appendChild(dialog);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Handle button clicks
|
||||
document.getElementById('pdf-cancel-btn').addEventListener('click', () => {
|
||||
document.body.removeChild(modal);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
document.getElementById('pdf-export-btn').addEventListener('click', () => {
|
||||
const directSelected = document.getElementById('pdf-direct-model').checked;
|
||||
const loanSelected = document.getElementById('pdf-loan-model').checked;
|
||||
|
||||
if (!directSelected && !loanSelected) {
|
||||
alert('Please select at least one investment model.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.removeChild(modal);
|
||||
resolve({
|
||||
direct: directSelected,
|
||||
loan: loanSelected
|
||||
});
|
||||
});
|
||||
|
||||
// Close on background click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
document.body.removeChild(modal);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
formatCurrency(amount) {
|
||||
try {
|
||||
// Get current currency from the page
|
||||
const currencyElement = document.getElementById('currency');
|
||||
const currency = currencyElement ? currencyElement.value : 'CHF';
|
||||
|
||||
// Determine locale based on currency
|
||||
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
|
||||
|
||||
// Consistent currency formatting: currency in front, no decimals for whole numbers
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
} catch (error) {
|
||||
console.error('Error formatting currency:', error);
|
||||
return `CHF ${Math.round(amount).toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
exportToCSV() {
|
||||
try {
|
||||
// Create comprehensive CSV with summary and detailed data
|
||||
let csvContent = 'CSP ROI Calculator Export\n';
|
||||
csvContent += `Generated on: ${new Date().toLocaleDateString()}\n\n`;
|
||||
|
||||
// Add input parameters
|
||||
csvContent += 'INPUT PARAMETERS\n';
|
||||
const inputs = this.calculator.getInputValues();
|
||||
csvContent += `Currency,${inputs.currency}\n`;
|
||||
csvContent += `Investment Amount,${inputs.investmentAmount}\n`;
|
||||
csvContent += `Timeframe (years),${inputs.timeframe}\n`;
|
||||
csvContent += `Investment Model,${inputs.investmentModel === 'loan' ? 'Loan Model' : 'Direct Investment'}\n`;
|
||||
if (inputs.investmentModel === 'loan') {
|
||||
csvContent += `Loan Interest Rate (%),${(inputs.loanInterestRate * 100).toFixed(1)}\n`;
|
||||
}
|
||||
csvContent += `Service Revenue per Instance (${inputs.currency}),${inputs.revenuePerInstance}\n`;
|
||||
csvContent += `Core Service Revenue per Instance (${inputs.currency}),${inputs.coreServiceRevenue}\n`;
|
||||
csvContent += `Total Revenue per Instance (${inputs.currency}),${inputs.revenuePerInstance + inputs.coreServiceRevenue}\n`;
|
||||
if (inputs.investmentModel === 'direct') {
|
||||
csvContent += `Servala Share (%),${(inputs.servalaShare * 100).toFixed(0)}\n`;
|
||||
csvContent += `Grace Period (months),${inputs.gracePeriod}\n`;
|
||||
}
|
||||
csvContent += '\n';
|
||||
|
||||
// Add scenario summary
|
||||
csvContent += 'SCENARIO SUMMARY\n';
|
||||
csvContent += `Scenario,Investment Model,Final Instances,Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),ROI (%),Break-even (months)\n`;
|
||||
|
||||
Object.values(this.calculator.results).forEach(result => {
|
||||
const modelText = result.investmentModel === 'loan' ? 'Loan' : 'Direct';
|
||||
csvContent += `${result.scenario},${modelText},${result.finalInstances},${result.totalRevenue.toFixed(2)},${result.cspRevenue.toFixed(2)},${result.servalaRevenue.toFixed(2)},${result.roi.toFixed(2)},${result.breakEvenMonth || 'N/A'}\n`;
|
||||
});
|
||||
|
||||
csvContent += '\n';
|
||||
|
||||
// Add detailed monthly data
|
||||
csvContent += 'MONTHLY BREAKDOWN\n';
|
||||
csvContent += `Month,Scenario,New Instances,Churned Instances,Total Instances,Service Revenue (${inputs.currency}),Core Revenue (${inputs.currency}),Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),Cumulative CSP Revenue (${inputs.currency}),Cumulative Servala Revenue (${inputs.currency})\n`;
|
||||
|
||||
// Combine all monthly data
|
||||
const allData = [];
|
||||
Object.keys(this.calculator.monthlyData).forEach(scenario => {
|
||||
this.calculator.monthlyData[scenario].forEach(monthData => {
|
||||
allData.push(monthData);
|
||||
});
|
||||
});
|
||||
|
||||
allData.sort((a, b) => a.month - b.month || a.scenario.localeCompare(b.scenario));
|
||||
|
||||
allData.forEach(data => {
|
||||
csvContent += `${data.month},${data.scenario},${data.newInstances},${data.churnedInstances},${data.totalInstances},${(data.serviceRevenue || data.monthlyRevenue || 0).toFixed(2)},${(data.coreRevenue || 0).toFixed(2)},${(data.totalRevenue || data.monthlyRevenue || 0).toFixed(2)},${data.cspRevenue.toFixed(2)},${data.servalaRevenue.toFixed(2)},${data.cumulativeCSPRevenue.toFixed(2)},${data.cumulativeServalaRevenue.toFixed(2)}\n`;
|
||||
});
|
||||
|
||||
// Create and download file
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
|
||||
const filename = `servala-csp-roi-data-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
console.error('CSV Export Error:', error);
|
||||
alert('An error occurred while generating the CSV file. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
/**
|
||||
* Input Utilities Module
|
||||
* Handles input formatting, validation, and parsing
|
||||
*/
|
||||
class InputUtils {
|
||||
static formatNumberWithCommas(num, currency = null) {
|
||||
try {
|
||||
// Get current currency if not provided
|
||||
if (!currency) {
|
||||
const currencyElement = document.getElementById('currency');
|
||||
currency = currencyElement ? currencyElement.value : 'CHF';
|
||||
}
|
||||
|
||||
// Use appropriate locale for number formatting
|
||||
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
|
||||
return parseInt(num).toLocaleString(locale);
|
||||
} catch (error) {
|
||||
console.error('Error formatting number with commas:', error);
|
||||
return String(num);
|
||||
}
|
||||
}
|
||||
|
||||
static parseFormattedNumber(str) {
|
||||
if (typeof str !== 'string') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove all non-numeric characters except decimal points and commas
|
||||
const cleaned = str.replace(/[^\d,.-]/g, '');
|
||||
|
||||
// Handle empty string
|
||||
if (!cleaned) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Remove commas and parse as float
|
||||
const result = parseFloat(cleaned.replace(/,/g, ''));
|
||||
|
||||
// Return 0 for invalid numbers or NaN
|
||||
return isNaN(result) ? 0 : result;
|
||||
} catch (error) {
|
||||
console.error('Error parsing formatted number:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static handleInvestmentAmountInput(input) {
|
||||
if (!input || typeof input.value !== 'string') {
|
||||
console.error('Invalid input element provided to handleInvestmentAmountInput');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Allow only digits, no immediate formatting during typing
|
||||
let value = input.value.replace(/[^\d]/g, '');
|
||||
|
||||
// Handle empty input
|
||||
if (!value) {
|
||||
input.setAttribute('data-value', '0');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the numeric value
|
||||
let numericValue = parseInt(value) || 0;
|
||||
|
||||
// Update the data attribute with the raw numeric value (no limits during typing)
|
||||
input.setAttribute('data-value', numericValue.toString());
|
||||
|
||||
// Update the input value (keep it clean, no commas during typing)
|
||||
input.value = value;
|
||||
|
||||
// Update the slider if it exists (with limits)
|
||||
const slider = document.getElementById('investment-slider');
|
||||
if (slider) {
|
||||
const minValue = parseInt(slider.min) || 100000;
|
||||
const maxValue = parseInt(slider.max) || 2000000;
|
||||
const sliderValue = Math.max(minValue, Math.min(maxValue, numericValue));
|
||||
slider.value = sliderValue;
|
||||
}
|
||||
|
||||
// Trigger calculations with debouncing
|
||||
if (window.ROICalculatorApp && window.ROICalculatorApp.calculator) {
|
||||
clearTimeout(input._calculationTimeout);
|
||||
input._calculationTimeout = setTimeout(() => {
|
||||
window.ROICalculatorApp.calculator.updateCalculations();
|
||||
}, 300); // 300ms delay to avoid excessive calculations during typing
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling investment amount input:', error);
|
||||
// Set a safe default value
|
||||
input.setAttribute('data-value', '500000');
|
||||
input.value = '500000';
|
||||
}
|
||||
}
|
||||
|
||||
static handleInvestmentAmountBlur(input) {
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
// Get the numeric value
|
||||
let numericValue = parseInt(input.getAttribute('data-value')) || 500000;
|
||||
|
||||
// Enforce min/max limits on blur
|
||||
const minValue = 100000;
|
||||
const maxValue = 2000000;
|
||||
|
||||
if (numericValue < minValue) {
|
||||
numericValue = minValue;
|
||||
} else if (numericValue > maxValue) {
|
||||
numericValue = maxValue;
|
||||
}
|
||||
|
||||
// Update the data attribute with the corrected value
|
||||
input.setAttribute('data-value', numericValue.toString());
|
||||
|
||||
// Format and display the value with commas on blur
|
||||
input.value = InputUtils.formatNumberWithCommas(numericValue);
|
||||
|
||||
// Update the slider
|
||||
const slider = document.getElementById('investment-slider');
|
||||
if (slider) {
|
||||
slider.value = numericValue;
|
||||
}
|
||||
|
||||
// Trigger immediate calculation on blur
|
||||
if (window.ROICalculatorApp && window.ROICalculatorApp.calculator) {
|
||||
clearTimeout(input._calculationTimeout);
|
||||
window.ROICalculatorApp.calculator.updateCalculations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling investment amount blur:', error);
|
||||
// Set a safe default value
|
||||
input.setAttribute('data-value', '500000');
|
||||
input.value = '500,000';
|
||||
}
|
||||
}
|
||||
|
||||
static handleInvestmentAmountFocus(input) {
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
// Remove commas when focusing for easier editing
|
||||
const numericValue = input.getAttribute('data-value') || '500000';
|
||||
input.value = numericValue;
|
||||
} catch (error) {
|
||||
console.error('Error handling investment amount focus:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static getCSRFToken() {
|
||||
try {
|
||||
// Try to get CSRF token from meta tag first
|
||||
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
||||
if (metaTag) {
|
||||
return metaTag.getAttribute('content');
|
||||
}
|
||||
|
||||
// Fallback to cookie method
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'csrftoken') {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
|
||||
// If no CSRF token found, return empty string (will cause server error, but won't break JS)
|
||||
console.error('CSRF token not found');
|
||||
return '';
|
||||
} catch (error) {
|
||||
console.error('Error getting CSRF token:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,634 +0,0 @@
|
|||
/**
|
||||
* ROI Calculator Application
|
||||
* Main application class that coordinates all modules
|
||||
*/
|
||||
class ROICalculatorApp {
|
||||
constructor() {
|
||||
this.calculator = null;
|
||||
this.chartManager = null;
|
||||
this.uiManager = null;
|
||||
this.exportManager = null;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.isInitialized) {
|
||||
console.warn('ROI Calculator already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the main calculator instance
|
||||
this.calculator = new ROICalculator();
|
||||
|
||||
// Create UI and chart managers
|
||||
this.uiManager = new UIManager(this.calculator);
|
||||
this.chartManager = new ChartManager(this.calculator);
|
||||
this.exportManager = new ExportManager(this.calculator, this.uiManager);
|
||||
|
||||
// Replace the methods in calculator with manager methods
|
||||
this.calculator.updateSummaryMetrics = () => this.uiManager.updateSummaryMetrics();
|
||||
this.calculator.updateCharts = () => this.chartManager.updateCharts();
|
||||
this.calculator.updateComparisonTable = () => this.uiManager.updateComparisonTable();
|
||||
this.calculator.updateMonthlyBreakdown = () => this.uiManager.updateMonthlyBreakdown();
|
||||
this.calculator.initializeCharts = () => this.chartManager.initializeCharts();
|
||||
this.calculator.formatCurrency = (amount) => this.uiManager.formatCurrency(amount);
|
||||
this.calculator.formatCurrencyDetailed = (amount) => this.uiManager.formatCurrencyDetailed(amount);
|
||||
this.calculator.formatPercentage = (value) => this.uiManager.formatPercentage(value);
|
||||
|
||||
// Re-initialize charts with the chart manager
|
||||
this.chartManager.initializeCharts();
|
||||
this.calculator.charts = this.chartManager.charts;
|
||||
|
||||
// Initialize tooltips
|
||||
this.initializeTooltips();
|
||||
|
||||
// Check export libraries
|
||||
this.checkExportLibraries();
|
||||
|
||||
// Run initial calculation
|
||||
this.calculator.updateCalculations();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('ROI Calculator initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize ROI Calculator:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tooltips with Bootstrap (loaded directly via CDN)
|
||||
initializeTooltips() {
|
||||
// Wait for Bootstrap to be available
|
||||
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
|
||||
try {
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize Bootstrap tooltips:', error);
|
||||
this.initializeNativeTooltips();
|
||||
}
|
||||
} else {
|
||||
// Retry after a short delay for deferred scripts
|
||||
setTimeout(() => this.initializeTooltips(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
initializeNativeTooltips() {
|
||||
try {
|
||||
var tooltipElements = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
tooltipElements.forEach(function (element) {
|
||||
// Ensure the title attribute is set for native tooltips
|
||||
var tooltipContent = element.getAttribute('title');
|
||||
if (!tooltipContent) {
|
||||
// Get tooltip content from data-bs-original-title or title attribute
|
||||
tooltipContent = element.getAttribute('data-bs-original-title');
|
||||
if (tooltipContent) {
|
||||
element.setAttribute('title', tooltipContent);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing native tooltips:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if export libraries are loaded
|
||||
checkExportLibraries() {
|
||||
try {
|
||||
const pdfButton = document.querySelector('button[onclick="exportToPDF()"]');
|
||||
|
||||
if (typeof window.jspdf === 'undefined') {
|
||||
if (pdfButton) {
|
||||
pdfButton.disabled = true;
|
||||
pdfButton.innerHTML = '<i class="bi bi-file-pdf"></i> Loading PDF...';
|
||||
}
|
||||
|
||||
// Retry after a short delay
|
||||
setTimeout(() => {
|
||||
if (typeof window.jspdf !== 'undefined' && pdfButton) {
|
||||
pdfButton.disabled = false;
|
||||
pdfButton.innerHTML = '<i class="bi bi-file-pdf"></i> Export PDF Report';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking export libraries:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods for global function calls
|
||||
updateCalculations() {
|
||||
if (this.calculator) {
|
||||
this.calculator.updateCalculations();
|
||||
this.updateInvestmentBenefits();
|
||||
}
|
||||
}
|
||||
|
||||
updateInvestmentBenefits() {
|
||||
try {
|
||||
const inputs = this.calculator.getInputValues();
|
||||
const investmentAmount = inputs.investmentAmount;
|
||||
const baseInvestment = 500000;
|
||||
|
||||
// Calculate market-realistic instance scaling factor
|
||||
let scalingFactor;
|
||||
if (investmentAmount <= baseInvestment) {
|
||||
scalingFactor = investmentAmount / baseInvestment;
|
||||
} else if (investmentAmount <= 1000000) {
|
||||
const ratio = investmentAmount / baseInvestment;
|
||||
scalingFactor = 1.0 + ((ratio - 1.0) * 0.5); // Linear scaling to 1.5x
|
||||
} else if (investmentAmount <= 1500000) {
|
||||
const progress = (investmentAmount - 1000000) / 500000;
|
||||
scalingFactor = 1.5 + (progress * 0.3); // Up to 1.8x
|
||||
} else {
|
||||
const progress = (investmentAmount - 1500000) / 500000;
|
||||
scalingFactor = 1.8 + (progress * 0.2); // Max 2.0x
|
||||
}
|
||||
|
||||
// Calculate sustainable revenue premium
|
||||
let revenuePremium;
|
||||
if (investmentAmount >= 2000000) {
|
||||
revenuePremium = 20; // Maximum 20% premium
|
||||
} else if (investmentAmount >= 1500000) {
|
||||
revenuePremium = 15; // 15% premium
|
||||
} else if (investmentAmount >= 1000000) {
|
||||
revenuePremium = 10; // 10% premium
|
||||
} else {
|
||||
revenuePremium = 0;
|
||||
}
|
||||
|
||||
// Calculate commercially viable grace period
|
||||
const baseGracePeriod = inputs.gracePeriod;
|
||||
let gracePeriodBonus;
|
||||
if (investmentAmount >= 1500000) {
|
||||
gracePeriodBonus = 3; // 3 months max bonus
|
||||
} else if (investmentAmount >= 1000000) {
|
||||
gracePeriodBonus = 2; // 2 months bonus
|
||||
} else {
|
||||
gracePeriodBonus = Math.floor((investmentAmount - baseInvestment) / 500000);
|
||||
}
|
||||
const totalGracePeriod = Math.min(Math.max(0, baseGracePeriod + gracePeriodBonus), 6); // Max 6 months
|
||||
|
||||
// Calculate realistic max performance bonus
|
||||
let maxBonus;
|
||||
if (investmentAmount >= 2000000) {
|
||||
maxBonus = 15; // 15% max bonus
|
||||
} else if (investmentAmount >= 1500000) {
|
||||
maxBonus = 12; // 12% max bonus
|
||||
} else if (investmentAmount >= 1000000) {
|
||||
maxBonus = 10; // 10% max bonus
|
||||
} else {
|
||||
maxBonus = 8; // 8% max bonus
|
||||
}
|
||||
|
||||
// Update UI elements
|
||||
const instanceScaling = document.getElementById('instance-scaling');
|
||||
if (instanceScaling) {
|
||||
instanceScaling.textContent = scalingFactor.toFixed(1) + 'x';
|
||||
instanceScaling.className = scalingFactor >= 2.0 ? 'benefit-value text-success fw-bold' :
|
||||
scalingFactor >= 1.5 ? 'benefit-value text-primary fw-bold' : 'benefit-value text-secondary fw-bold';
|
||||
}
|
||||
|
||||
const revenuePremiumEl = document.getElementById('revenue-premium');
|
||||
if (revenuePremiumEl) {
|
||||
revenuePremiumEl.textContent = '+' + revenuePremium + '%';
|
||||
revenuePremiumEl.className = revenuePremium >= 40 ? 'benefit-value text-success fw-bold' :
|
||||
revenuePremium >= 20 ? 'benefit-value text-warning fw-bold' : 'benefit-value text-secondary fw-bold';
|
||||
}
|
||||
|
||||
const gracePeriodEl = document.getElementById('grace-period-display');
|
||||
if (gracePeriodEl) {
|
||||
gracePeriodEl.textContent = totalGracePeriod + ' months';
|
||||
gracePeriodEl.className = totalGracePeriod >= 12 ? 'benefit-value text-success fw-bold' :
|
||||
totalGracePeriod >= 9 ? 'benefit-value text-info fw-bold' : 'benefit-value text-secondary fw-bold';
|
||||
}
|
||||
|
||||
const maxBonusEl = document.getElementById('max-bonus');
|
||||
if (maxBonusEl) {
|
||||
maxBonusEl.textContent = maxBonus + '%';
|
||||
maxBonusEl.className = maxBonus >= 30 ? 'benefit-value text-success fw-bold' :
|
||||
maxBonus >= 20 ? 'benefit-value text-warning fw-bold' : 'benefit-value text-secondary fw-bold';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating investment benefits:', error);
|
||||
}
|
||||
}
|
||||
|
||||
exportToPDF() {
|
||||
if (this.exportManager) {
|
||||
this.exportManager.exportToPDF();
|
||||
}
|
||||
}
|
||||
|
||||
exportToCSV() {
|
||||
if (this.exportManager) {
|
||||
this.exportManager.exportToCSV();
|
||||
}
|
||||
}
|
||||
|
||||
updateInvestmentAmount(value) {
|
||||
try {
|
||||
const input = document.getElementById('investment-amount');
|
||||
if (input) {
|
||||
input.setAttribute('data-value', value);
|
||||
input.value = InputUtils.formatNumberWithCommas(value);
|
||||
this.updateCalculations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating investment amount:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateRevenuePerInstance(value) {
|
||||
try {
|
||||
const element = document.getElementById('revenue-per-instance');
|
||||
if (element) {
|
||||
element.value = value;
|
||||
this.updateCalculations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating revenue per instance:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateServalaShare(value) {
|
||||
try {
|
||||
const element = document.getElementById('servala-share');
|
||||
if (element) {
|
||||
element.value = value;
|
||||
this.updateCalculations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating servala share:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateGracePeriod(value) {
|
||||
try {
|
||||
const element = document.getElementById('grace-period');
|
||||
if (element) {
|
||||
element.value = value;
|
||||
this.updateCalculations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating grace period:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateLoanRate(value) {
|
||||
try {
|
||||
const element = document.getElementById('loan-interest-rate');
|
||||
if (element) {
|
||||
element.value = value;
|
||||
this.updateCalculations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating loan rate:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateCoreServiceRevenue(value) {
|
||||
try {
|
||||
const element = document.getElementById('core-service-revenue');
|
||||
if (element) {
|
||||
element.value = value;
|
||||
this.updateCalculations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating core service revenue:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrency(value) {
|
||||
try {
|
||||
const currencyElement = document.getElementById('currency');
|
||||
if (currencyElement) {
|
||||
currencyElement.value = value;
|
||||
}
|
||||
|
||||
// Update all currency-related UI labels
|
||||
this.updateCurrencyLabels(value);
|
||||
|
||||
// Update calculations to reflect new currency formatting
|
||||
this.updateCalculations();
|
||||
} catch (error) {
|
||||
console.error('Error updating currency:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrencyLabels(currency) {
|
||||
try {
|
||||
// Update investment amount prefix
|
||||
const investmentPrefix = document.getElementById('investment-currency-prefix');
|
||||
if (investmentPrefix) {
|
||||
investmentPrefix.textContent = currency;
|
||||
}
|
||||
|
||||
// Update investment min/max labels
|
||||
const investmentMinLabel = document.getElementById('investment-min-label');
|
||||
if (investmentMinLabel) {
|
||||
investmentMinLabel.textContent = `${currency} 100K`;
|
||||
}
|
||||
|
||||
const investmentMaxLabel = document.getElementById('investment-max-label');
|
||||
if (investmentMaxLabel) {
|
||||
investmentMaxLabel.textContent = `${currency} 2M`;
|
||||
}
|
||||
|
||||
// Update revenue per instance suffix
|
||||
const revenueSuffix = document.getElementById('revenue-currency-suffix');
|
||||
if (revenueSuffix) {
|
||||
revenueSuffix.textContent = `${currency}/month`;
|
||||
}
|
||||
|
||||
// Update core service revenue suffix (it's a direct span, not ID-based)
|
||||
const coreRevenueInput = document.getElementById('core-service-revenue');
|
||||
if (coreRevenueInput) {
|
||||
const coreRevenueSpan = coreRevenueInput.parentElement.querySelector('.input-group-text');
|
||||
if (coreRevenueSpan) {
|
||||
coreRevenueSpan.textContent = `${currency}/month`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update all other currency labels throughout the interface
|
||||
const currencyLabels = document.querySelectorAll('.currency-label');
|
||||
currencyLabels.forEach(label => {
|
||||
label.textContent = currency;
|
||||
});
|
||||
|
||||
// Update range slider labels with currency
|
||||
const revenueLabel = document.querySelector('label[for="revenue-per-instance"]');
|
||||
if (revenueLabel) {
|
||||
revenueLabel.innerHTML = revenueLabel.innerHTML.replace(/(CHF|EUR)/, currency);
|
||||
}
|
||||
|
||||
const coreRevenueLabel = document.querySelector('label[for="core-service-revenue"]');
|
||||
if (coreRevenueLabel) {
|
||||
coreRevenueLabel.innerHTML = coreRevenueLabel.innerHTML.replace(/(CHF|EUR)/, currency);
|
||||
}
|
||||
|
||||
// Update investment amount field display if it has a value
|
||||
const investmentInput = document.getElementById('investment-amount');
|
||||
if (investmentInput && investmentInput.getAttribute('data-value')) {
|
||||
const currentValue = investmentInput.getAttribute('data-value');
|
||||
investmentInput.value = InputUtils.formatNumberWithCommas(currentValue, currency);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating currency labels:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateScenarioChurn(scenarioKey, churnRate) {
|
||||
try {
|
||||
if (this.calculator && this.calculator.scenarios[scenarioKey]) {
|
||||
this.calculator.scenarios[scenarioKey].churnRate = parseFloat(churnRate) / 100;
|
||||
this.updateCalculations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating scenario churn:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) {
|
||||
try {
|
||||
if (this.calculator && this.calculator.scenarios[scenarioKey] && this.calculator.scenarios[scenarioKey].phases[phaseIndex]) {
|
||||
this.calculator.scenarios[scenarioKey].phases[phaseIndex].newInstancesPerMonth = parseInt(newInstancesPerMonth);
|
||||
this.updateCalculations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating scenario phase:', error);
|
||||
}
|
||||
}
|
||||
|
||||
resetAdvancedParameters() {
|
||||
if (!confirm('Reset all advanced parameters to default values?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.calculator) return;
|
||||
|
||||
// Reset Conservative
|
||||
this.calculator.scenarios.conservative.churnRate = 0.025;
|
||||
this.calculator.scenarios.conservative.phases = [
|
||||
{ months: 6, newInstancesPerMonth: 15 },
|
||||
{ months: 6, newInstancesPerMonth: 24 },
|
||||
{ months: 12, newInstancesPerMonth: 35 },
|
||||
{ months: 12, newInstancesPerMonth: 40 }
|
||||
];
|
||||
|
||||
// Reset Moderate
|
||||
this.calculator.scenarios.moderate.churnRate = 0.03;
|
||||
this.calculator.scenarios.moderate.phases = [
|
||||
{ months: 6, newInstancesPerMonth: 25 },
|
||||
{ months: 6, newInstancesPerMonth: 45 },
|
||||
{ months: 12, newInstancesPerMonth: 70 },
|
||||
{ months: 12, newInstancesPerMonth: 90 }
|
||||
];
|
||||
|
||||
// Reset Aggressive
|
||||
this.calculator.scenarios.aggressive.churnRate = 0.035;
|
||||
this.calculator.scenarios.aggressive.phases = [
|
||||
{ months: 6, newInstancesPerMonth: 40 },
|
||||
{ months: 6, newInstancesPerMonth: 80 },
|
||||
{ months: 12, newInstancesPerMonth: 120 },
|
||||
{ months: 12, newInstancesPerMonth: 150 }
|
||||
];
|
||||
|
||||
// Update UI inputs
|
||||
const inputMappings = [
|
||||
['conservative-churn', '2.5'],
|
||||
['conservative-phase-0', '15'],
|
||||
['conservative-phase-1', '24'],
|
||||
['conservative-phase-2', '35'],
|
||||
['conservative-phase-3', '40'],
|
||||
['moderate-churn', '3.0'],
|
||||
['moderate-phase-0', '25'],
|
||||
['moderate-phase-1', '45'],
|
||||
['moderate-phase-2', '70'],
|
||||
['moderate-phase-3', '90'],
|
||||
['aggressive-churn', '3.5'],
|
||||
['aggressive-phase-0', '40'],
|
||||
['aggressive-phase-1', '80'],
|
||||
['aggressive-phase-2', '120'],
|
||||
['aggressive-phase-3', '150']
|
||||
];
|
||||
|
||||
inputMappings.forEach(([id, value]) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
this.updateCalculations();
|
||||
} catch (error) {
|
||||
console.error('Error resetting advanced parameters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
toggleScenario(scenarioKey) {
|
||||
try {
|
||||
const checkbox = document.getElementById(scenarioKey + '-enabled');
|
||||
if (!checkbox || !this.calculator) return;
|
||||
|
||||
const enabled = checkbox.checked;
|
||||
this.calculator.scenarios[scenarioKey].enabled = enabled;
|
||||
|
||||
const card = document.getElementById(scenarioKey + '-card');
|
||||
if (card) {
|
||||
if (enabled) {
|
||||
card.classList.add('active');
|
||||
card.classList.remove('disabled');
|
||||
} else {
|
||||
card.classList.remove('active');
|
||||
card.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCalculations();
|
||||
} catch (error) {
|
||||
console.error('Error toggling scenario:', error);
|
||||
}
|
||||
}
|
||||
|
||||
toggleCollapsible(elementId) {
|
||||
try {
|
||||
const content = document.getElementById(elementId);
|
||||
if (!content) return;
|
||||
|
||||
const header = content.previousElementSibling;
|
||||
if (!header) return;
|
||||
|
||||
const chevron = header.querySelector('.bi-chevron-down, .bi-chevron-up');
|
||||
|
||||
if (content.classList.contains('show')) {
|
||||
content.classList.remove('show');
|
||||
if (chevron) {
|
||||
chevron.classList.remove('bi-chevron-up');
|
||||
chevron.classList.add('bi-chevron-down');
|
||||
}
|
||||
} else {
|
||||
content.classList.add('show');
|
||||
if (chevron) {
|
||||
chevron.classList.remove('bi-chevron-down');
|
||||
chevron.classList.add('bi-chevron-up');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling collapsible:', error);
|
||||
}
|
||||
}
|
||||
|
||||
resetCalculator() {
|
||||
if (!confirm('Are you sure you want to reset all parameters to default values?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Reset input values
|
||||
const investmentInput = document.getElementById('investment-amount');
|
||||
if (investmentInput) {
|
||||
investmentInput.setAttribute('data-value', '500000');
|
||||
investmentInput.value = '500,000';
|
||||
}
|
||||
|
||||
const resetMappings = [
|
||||
['investment-slider', 500000],
|
||||
['timeframe', 3],
|
||||
['loan-interest-rate', 5.0],
|
||||
['loan-rate-slider', 5.0],
|
||||
['revenue-per-instance', 50],
|
||||
['revenue-slider', 50],
|
||||
['servala-share', 25],
|
||||
['share-slider', 25],
|
||||
['grace-period', 6],
|
||||
['grace-slider', 6]
|
||||
];
|
||||
|
||||
resetMappings.forEach(([id, value]) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Both models are now calculated simultaneously
|
||||
|
||||
// Reset scenarios
|
||||
['conservative', 'moderate', 'aggressive'].forEach(scenario => {
|
||||
const checkbox = document.getElementById(scenario + '-enabled');
|
||||
const card = document.getElementById(scenario + '-card');
|
||||
|
||||
if (checkbox) checkbox.checked = true;
|
||||
if (this.calculator) this.calculator.scenarios[scenario].enabled = true;
|
||||
if (card) {
|
||||
card.classList.add('active');
|
||||
card.classList.remove('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
// Reset advanced parameters
|
||||
this.resetAdvancedParameters();
|
||||
|
||||
// Both models are now calculated simultaneously, no toggle needed
|
||||
|
||||
// Recalculate
|
||||
this.updateCalculations();
|
||||
} catch (error) {
|
||||
console.error('Error resetting calculator:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// toggleInvestmentModel removed - both models are now calculated simultaneously
|
||||
|
||||
updateMonthlyBreakdownFilters() {
|
||||
try {
|
||||
if (this.uiManager) {
|
||||
this.uiManager.updateMonthlyBreakdown();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating monthly breakdown filters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
if (!confirm('Are you sure you want to logout?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a form to submit logout request
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = window.location.pathname;
|
||||
|
||||
// Add CSRF token from page meta tag or cookie
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrfmiddlewaretoken';
|
||||
csrfInput.value = InputUtils.getCSRFToken();
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
// Add logout parameter
|
||||
const logoutInput = document.createElement('input');
|
||||
logoutInput.type = 'hidden';
|
||||
logoutInput.name = 'logout';
|
||||
logoutInput.value = 'true';
|
||||
form.appendChild(logoutInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the application when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
window.ROICalculatorApp = new ROICalculatorApp();
|
||||
window.ROICalculatorApp.initialize();
|
||||
});
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
/**
|
||||
* UI Management Module
|
||||
* Handles DOM updates, table rendering, and metric display
|
||||
*/
|
||||
class UIManager {
|
||||
constructor(calculator) {
|
||||
this.calculator = calculator;
|
||||
}
|
||||
|
||||
updateSummaryMetrics() {
|
||||
try {
|
||||
const enabledResults = Object.values(this.calculator.results);
|
||||
if (enabledResults.length === 0) {
|
||||
this.setElementText('net-position-direct', 'CHF 0');
|
||||
this.setElementText('net-position-loan', 'CHF 0');
|
||||
this.setElementText('roi-percentage-direct', '0%');
|
||||
this.setElementText('roi-percentage-loan', '0%');
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate direct and loan results
|
||||
const directResults = enabledResults.filter(r => r.investmentModel === 'direct');
|
||||
const loanResults = enabledResults.filter(r => r.investmentModel === 'loan');
|
||||
|
||||
// Calculate averages for direct investment
|
||||
if (directResults.length > 0) {
|
||||
const avgNetPositionDirect = directResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / directResults.length;
|
||||
const avgROIDirect = directResults.reduce((sum, r) => sum + r.roi, 0) / directResults.length;
|
||||
|
||||
this.setElementText('net-position-direct', this.formatCurrency(avgNetPositionDirect));
|
||||
this.setElementText('roi-percentage-direct', this.formatPercentage(avgROIDirect));
|
||||
|
||||
// Update styling for direct metrics
|
||||
const netPositionDirectElement = document.getElementById('net-position-direct');
|
||||
if (netPositionDirectElement) {
|
||||
if (avgNetPositionDirect > 0) {
|
||||
netPositionDirectElement.className = 'fw-bold text-success';
|
||||
} else if (avgNetPositionDirect < 0) {
|
||||
netPositionDirectElement.className = 'fw-bold text-danger';
|
||||
} else {
|
||||
netPositionDirectElement.className = 'fw-bold';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages for loan investment
|
||||
if (loanResults.length > 0) {
|
||||
const avgNetPositionLoan = loanResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / loanResults.length;
|
||||
const avgROILoan = loanResults.reduce((sum, r) => sum + r.roi, 0) / loanResults.length;
|
||||
|
||||
this.setElementText('net-position-loan', this.formatCurrency(avgNetPositionLoan));
|
||||
this.setElementText('roi-percentage-loan', this.formatPercentage(avgROILoan));
|
||||
|
||||
// Update styling for loan metrics
|
||||
const netPositionLoanElement = document.getElementById('net-position-loan');
|
||||
if (netPositionLoanElement) {
|
||||
if (avgNetPositionLoan > 0) {
|
||||
netPositionLoanElement.className = 'fw-bold text-success';
|
||||
} else if (avgNetPositionLoan < 0) {
|
||||
netPositionLoanElement.className = 'fw-bold text-danger';
|
||||
} else {
|
||||
netPositionLoanElement.className = 'fw-bold';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating summary metrics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateComparisonTable() {
|
||||
try {
|
||||
const tbody = document.getElementById('comparison-tbody');
|
||||
if (!tbody) {
|
||||
console.error('Comparison table body not found');
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// Sort results by scenario first, then by model
|
||||
const sortedResults = Object.values(this.calculator.results).sort((a, b) => {
|
||||
const scenarioCompare = a.scenario.localeCompare(b.scenario);
|
||||
if (scenarioCompare !== 0) return scenarioCompare;
|
||||
return a.investmentModel.localeCompare(b.investmentModel);
|
||||
});
|
||||
|
||||
sortedResults.forEach(result => {
|
||||
const isDirect = result.investmentModel === 'direct';
|
||||
const scenarioColor = this.getScenarioColor(result.scenario);
|
||||
|
||||
// Model badge with better styling
|
||||
const modelBadge = isDirect ?
|
||||
'<span class="fw-bold text-success">Direct</span>' :
|
||||
'<span class="fw-bold text-warning">Loan</span>';
|
||||
|
||||
// Key features based on model
|
||||
const keyFeatures = isDirect ?
|
||||
`Grace period: ${result.effectiveGracePeriod || 6} months<br>` +
|
||||
`Performance multiplier: ${result.performanceMultiplier ? result.performanceMultiplier.toFixed(2) : '1.0'}x` :
|
||||
`Fixed ${this.formatPercentage(this.calculator.getInputValues().loanInterestRate * 100)} rate<br>` +
|
||||
'Predictable returns';
|
||||
|
||||
const row = tbody.insertRow();
|
||||
row.innerHTML = `
|
||||
<td><span class="fw-bold" style="color: ${scenarioColor}">${result.scenario}</span></td>
|
||||
<td>${modelBadge}</td>
|
||||
<td>${result.finalInstances ? result.finalInstances.toLocaleString() : 'N/A'}</td>
|
||||
<td class="fw-bold ${result.netPosition >= 0 ? 'text-success' : 'text-danger'}">
|
||||
${this.formatCurrencyDetailed(result.netPosition || 0)}
|
||||
</td>
|
||||
<td class="fw-bold ${result.roi >= 15 ? 'text-success' : result.roi >= 5 ? 'text-warning' : result.roi < 0 ? 'text-danger' : ''}">
|
||||
${this.formatPercentage(result.roi || 0)}
|
||||
</td>
|
||||
<td>${result.breakEvenMonth ? result.breakEvenMonth + ' months' : '<span class="text-muted">No break-even</span>'}</td>
|
||||
<td><small class="text-muted">${keyFeatures}</small></td>
|
||||
`;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating comparison table:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateMonthlyBreakdown() {
|
||||
try {
|
||||
const tbody = document.getElementById('monthly-tbody');
|
||||
if (!tbody) {
|
||||
console.error('Monthly breakdown table body not found');
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// Get filter settings
|
||||
const filters = this.getMonthlyBreakdownFilters();
|
||||
|
||||
// Combine all monthly data and sort by month, then scenario, then model
|
||||
const allData = [];
|
||||
Object.keys(this.calculator.monthlyData).forEach(resultKey => {
|
||||
this.calculator.monthlyData[resultKey].forEach(monthData => {
|
||||
const model = resultKey.includes('_loan') ? 'loan' : 'direct';
|
||||
const scenario = resultKey.replace('_direct', '').replace('_loan', '');
|
||||
|
||||
// Apply filters
|
||||
if (!filters.models[model] || !filters.scenarios[scenario.toLowerCase()]) {
|
||||
return; // Skip this entry if filtered out
|
||||
}
|
||||
|
||||
allData.push({
|
||||
...monthData,
|
||||
model: model,
|
||||
scenarioKey: scenario
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by month first, then scenario, then model (direct first)
|
||||
allData.sort((a, b) => {
|
||||
const monthCompare = a.month - b.month;
|
||||
if (monthCompare !== 0) return monthCompare;
|
||||
|
||||
const scenarioCompare = a.scenarioKey.localeCompare(b.scenarioKey);
|
||||
if (scenarioCompare !== 0) return scenarioCompare;
|
||||
|
||||
return a.model === 'direct' ? -1 : 1; // Direct first
|
||||
});
|
||||
|
||||
allData.forEach(data => {
|
||||
const row = tbody.insertRow();
|
||||
const scenarioColor = this.getScenarioColor(data.scenario);
|
||||
const isDirect = data.model === 'direct';
|
||||
|
||||
// Model styling
|
||||
const modelBadge = isDirect ?
|
||||
'<span class="text-success fw-bold">Direct</span>' :
|
||||
'<span class="text-warning fw-bold">Loan</span>';
|
||||
|
||||
// Net position styling
|
||||
const netPositionClass = (data.netPosition || 0) >= 0 ? 'text-success' : 'text-danger';
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="text-center"><strong>${data.month}</strong></td>
|
||||
<td><span style="color: ${scenarioColor}" class="fw-bold">${data.scenario}</span></td>
|
||||
<td>${modelBadge}</td>
|
||||
<td class="text-end">${data.totalInstances ? data.totalInstances.toLocaleString() : '0'}</td>
|
||||
<td class="text-end">${this.formatCurrencyDetailed(data.serviceRevenue || data.monthlyRevenue || 0)}</td>
|
||||
<td class="text-end">${this.formatCurrencyDetailed(data.coreRevenue || 0)}</td>
|
||||
<td class="text-end fw-bold">${this.formatCurrencyDetailed(data.totalRevenue || data.monthlyRevenue || 0)}</td>
|
||||
<td class="text-end fw-bold">${this.formatCurrencyDetailed(data.cspRevenue || 0)}</td>
|
||||
<td class="text-end text-muted">${this.formatCurrencyDetailed(data.servalaRevenue || 0)}</td>
|
||||
<td class="text-end fw-bold ${netPositionClass}">${this.formatCurrencyDetailed(data.netPosition || 0)}</td>
|
||||
`;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating monthly breakdown:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setElementText(elementId, text) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
formatCurrency(amount, currency = null) {
|
||||
try {
|
||||
// Get current currency if not provided
|
||||
if (!currency) {
|
||||
const currencyElement = document.getElementById('currency');
|
||||
currency = currencyElement ? currencyElement.value : 'CHF';
|
||||
}
|
||||
|
||||
// Determine locale based on currency
|
||||
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
|
||||
|
||||
// Use compact notation for large numbers in metric cards
|
||||
if (amount >= 1000000) {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
notation: 'compact',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1
|
||||
}).format(amount);
|
||||
} else {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error formatting currency:', error);
|
||||
return `${currency || 'CHF'} ${amount.toFixed(0)}`;
|
||||
}
|
||||
}
|
||||
|
||||
formatCurrencyDetailed(amount, currency = null) {
|
||||
try {
|
||||
// Get current currency if not provided
|
||||
if (!currency) {
|
||||
const currencyElement = document.getElementById('currency');
|
||||
currency = currencyElement ? currencyElement.value : 'CHF';
|
||||
}
|
||||
|
||||
// Determine locale based on currency
|
||||
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
|
||||
|
||||
// Use full formatting for detailed views (tables, exports)
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
} catch (error) {
|
||||
console.error('Error formatting detailed currency:', error);
|
||||
return `${currency || 'CHF'} ${amount.toFixed(0)}`;
|
||||
}
|
||||
}
|
||||
|
||||
formatPercentage(value) {
|
||||
try {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1
|
||||
}).format(value / 100);
|
||||
} catch (error) {
|
||||
console.error('Error formatting percentage:', error);
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getScenarioColor(scenarioName) {
|
||||
switch(scenarioName.toLowerCase()) {
|
||||
case 'conservative': return '#28a745';
|
||||
case 'moderate': return '#ffc107';
|
||||
case 'aggressive': return '#dc3545';
|
||||
default: return '#6c757d';
|
||||
}
|
||||
}
|
||||
|
||||
getMonthlyBreakdownFilters() {
|
||||
try {
|
||||
return {
|
||||
models: {
|
||||
direct: document.getElementById('breakdown-direct-enabled')?.checked ?? true,
|
||||
loan: document.getElementById('breakdown-loan-enabled')?.checked ?? true
|
||||
},
|
||||
scenarios: {
|
||||
conservative: document.getElementById('breakdown-conservative-enabled')?.checked ?? true,
|
||||
moderate: document.getElementById('breakdown-moderate-enabled')?.checked ?? true,
|
||||
aggressive: document.getElementById('breakdown-aggressive-enabled')?.checked ?? true
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting monthly breakdown filters:', error);
|
||||
// Return default filters if there's an error
|
||||
return {
|
||||
models: { direct: true, loan: true },
|
||||
scenarios: { conservative: true, moderate: true, aggressive: true }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
{% social_meta_tags %}
|
||||
{% json_ld_structured_data %}
|
||||
|
||||
<link rel="stylesheet" type="text/css" href='{% static "css/bootstrap-icons.min.css" %}'>
|
||||
<link rel="stylesheet" href='{% static "css/bootstrap-icons.min.css" %}'>
|
||||
<link rel="stylesheet" type="text/css" href='{% static "css/servala-main.css" %}'>
|
||||
{% block extra_css %}{% endblock %}
|
||||
|
||||
|
|
@ -25,7 +25,6 @@
|
|||
<script defer src="{% static "js/htmx204.min.js" %}"></script>
|
||||
<script defer src="{% static "js/alpine-collapse.min.js" %}"></script>
|
||||
<script defer src="{% static "js/servala-main.js" %}"></script>
|
||||
<script defer src="{% static "js/bootstrap.bundle.min.js" %}"></script>
|
||||
<script defer src="{% static "js/servala-addons.js" %}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</head>
|
||||
|
|
@ -35,49 +34,15 @@
|
|||
<div class="bg-primary text-white py-2 text-center">
|
||||
<div class="container">
|
||||
<a href="https://www.vshn.ch/en/blog/vshn-launches-servala-open-cloud-native-service-hub/" class="text-white text-decoration-none" target="_blank">
|
||||
VSHN launches Servala – The Sovereign App Store
|
||||
VSHN launches Servala – Open Cloud Native Service Hub
|
||||
</a>
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="site-header position-relative"
|
||||
x-data="{
|
||||
isMenuOpen: false,
|
||||
isAtTop: true,
|
||||
toggleMenu() {
|
||||
this.isMenuOpen = !this.isMenuOpen;
|
||||
},
|
||||
closeMenu() {
|
||||
if (window.innerWidth < 992 && this.isMenuOpen) {
|
||||
this.isMenuOpen = false;
|
||||
}
|
||||
},
|
||||
handleScroll() {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
this.isAtTop = scrollTop <= 200;
|
||||
}
|
||||
}"
|
||||
x-init="
|
||||
let scrollThrottle = false;
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!scrollThrottle) {
|
||||
requestAnimationFrame(() => {
|
||||
handleScroll();
|
||||
scrollThrottle = false;
|
||||
});
|
||||
scrollThrottle = true;
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth >= 992 && isMenuOpen) {
|
||||
isMenuOpen = false;
|
||||
}
|
||||
});
|
||||
">
|
||||
<div class="header-nav"
|
||||
:class="isAtTop ? 'header-nav--top' : 'header-nav--fixed'"
|
||||
id="headerNav">
|
||||
|
||||
<header x-data="{sideNav: false, atTop: true}" class="site-header position-relative">
|
||||
<div class="header-nav" :class="{ 'header-nav--top': atTop, 'header-nav--fixed': !atTop }"
|
||||
x-on:scroll.window="atTop = (window.pageYOffset > 200) ? false : true;">
|
||||
<div class="container-xl mx-auto px-3 px-lg-0 position-relative">
|
||||
<div class="nav__wrapper d-flex justify-content-between align-items-center">
|
||||
<div class="nav__brand logo">
|
||||
|
|
@ -85,40 +50,31 @@
|
|||
<img src="{% static "img/header-logo.png" %}" alt="Servala Logo" width="191" height="43">
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav__menu"
|
||||
:class="isMenuOpen ? 'nav__menu-active' : 'nav__menu-hidden'"
|
||||
id="navMenu">
|
||||
<div x-cloak class="nav__menu" :class="sideNav ? 'nav__menu-active' : 'nav__menu-hidden'">
|
||||
<nav class="navbar d-lg-flex justify-content-lg-end align-items-lg-center">
|
||||
<ul class="navbar__menu menu mr-lg-27">
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:homepage' %}" @click="closeMenu()">Home</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:service_list' %}" @click="closeMenu()">Services</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:provider_list' %}" @click="closeMenu()">Cloud Provider</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:partner_list' %}" @click="closeMenu()">Partner</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:article_list' %}" @click="closeMenu()">Articles</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:about' %}" @click="closeMenu()">About</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:homepage' %}">Home</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:service_list' %}">Services</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:article_list' %}">Articles</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:provider_list' %}">Cloud Providers</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:partner_list' %}">Consulting Partners</a></li>
|
||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:about' %}">About</a></li>
|
||||
</ul>
|
||||
<ul class="menu-cta mb-0">
|
||||
<li class="mr-17"><a class="btn btn-outline-light btn-outline-primary" href="{% url 'services:contact' %}" role="button" @click="closeMenu()">Contact</a></li>
|
||||
<li class="mr-17"><a class="btn btn-outline-light btn-outline-primary" href="{% url 'services:contact' %}" role="button">Contact</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="nav__toggle">
|
||||
<button @click="toggleMenu()"
|
||||
name="menu"
|
||||
class="nav__button"
|
||||
role="button"
|
||||
:aria-expanded="isMenuOpen.toString()"
|
||||
aria-controls="navMenu">
|
||||
<button @click="sideNav = !sideNav" name="menu" class="nav__button" role="button">
|
||||
<svg class="nav__button-svg" width="22" height="24">
|
||||
<line class="button-svg__line"
|
||||
:class="isMenuOpen ? 'svg-line-top' : ''"
|
||||
x1="0" x2="22" y1="6" y2="6"></line>
|
||||
<line class="button-svg__line"
|
||||
:class="isMenuOpen ? 'svg-line-center' : ''"
|
||||
x1="0" x2="22" y1="12" y2="12"></line>
|
||||
<line class="button-svg__line"
|
||||
:class="isMenuOpen ? 'svg-line-bottom' : ''"
|
||||
x1="0" x2="22" y1="18" y2="18"></line>
|
||||
<line class="button-svg__line" :class="{ 'svg-line-top': sideNav === true }" id="top" x1="0" x2="22"
|
||||
y1="6" y2="6"></line>
|
||||
<line class="button-svg__line" :class="{ 'svg-line-center': sideNav === true }" id="middle" x1="0"
|
||||
x2="22" y1="12" y2="12">
|
||||
</line>
|
||||
<line class="button-svg__line" :class="{ 'svg-line-bottom': sideNav === true }" id="bottom" x1="0"
|
||||
x2="22" y1="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -126,7 +82,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -180,7 +136,7 @@
|
|||
</a>
|
||||
</div>
|
||||
<div class="fs-14 fw-semibold">
|
||||
<p>Unlock the Power of Sovereign Managed Applications</p>
|
||||
<p>Unlock the Power of Cloud Native Applications</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center space-x-20">
|
||||
<a href="https://www.linkedin.com/company/servala/">
|
||||
|
|
@ -192,7 +148,7 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-12 col-lg-2 mb-60 mb-lg-0">
|
||||
<div class="space-y-20">
|
||||
<h4 class="fs-base fw-semibold">Contents</h4>
|
||||
|
|
@ -206,7 +162,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-12 col-lg-2 mb-60 mb-lg-0">
|
||||
<div class="space-y-20">
|
||||
<h4 class="fs-base fw-semibold">Company</h4>
|
||||
|
|
@ -218,7 +174,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="space-y-20 w-lg-90">
|
||||
<h4 class="fs-base fw-semibold">Contact</h4>
|
||||
|
|
@ -275,7 +231,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,749 +0,0 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}CSP ROI Calculator{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" type="text/css" href='{% static "css/roi-calculator.css" %}'>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static "js/chart.umd.min.js" %}"></script>
|
||||
<script src="{% static "js/jspdf.umd.min.js" %}"></script>
|
||||
<!-- ROI Calculator Modules -->
|
||||
<script src="{% static "js/roi-calculator/input-utils.js" %}"></script>
|
||||
<script src="{% static "js/roi-calculator/calculator-core.js" %}"></script>
|
||||
<script src="{% static "js/roi-calculator/chart-manager.js" %}"></script>
|
||||
<script src="{% static "js/roi-calculator/ui-manager.js" %}"></script>
|
||||
<script src="{% static "js/roi-calculator/export-manager.js" %}"></script>
|
||||
<script src="{% static "js/roi-calculator/roi-calculator-app.js" %}"></script>
|
||||
<script>
|
||||
// Global function wrappers for HTML onclick handlers
|
||||
function updateCalculations() { window.ROICalculatorApp?.updateCalculations(); }
|
||||
function exportToPDF() { window.ROICalculatorApp?.exportToPDF(); }
|
||||
function exportToCSV() { window.ROICalculatorApp?.exportToCSV(); }
|
||||
function handleInvestmentAmountInput(input) { InputUtils.handleInvestmentAmountInput(input); }
|
||||
function handleInvestmentAmountFocus(input) { InputUtils.handleInvestmentAmountFocus(input); }
|
||||
function handleInvestmentAmountBlur(input) { InputUtils.handleInvestmentAmountBlur(input); }
|
||||
function updateInvestmentAmount(value) { window.ROICalculatorApp?.updateInvestmentAmount(value); }
|
||||
function updateRevenuePerInstance(value) { window.ROICalculatorApp?.updateRevenuePerInstance(value); }
|
||||
function updateServalaShare(value) { window.ROICalculatorApp?.updateServalaShare(value); }
|
||||
function updateGracePeriod(value) { window.ROICalculatorApp?.updateGracePeriod(value); }
|
||||
function updateLoanRate(value) { window.ROICalculatorApp?.updateLoanRate(value); }
|
||||
function updateCoreServiceRevenue(value) { window.ROICalculatorApp?.updateCoreServiceRevenue(value); }
|
||||
function updateCurrency() {
|
||||
const currencyElement = document.getElementById('currency');
|
||||
const value = currencyElement ? currencyElement.value : 'CHF';
|
||||
window.ROICalculatorApp?.updateCurrency(value);
|
||||
}
|
||||
function updateScenarioChurn(scenarioKey, churnRate) { window.ROICalculatorApp?.updateScenarioChurn(scenarioKey, churnRate); }
|
||||
function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) { window.ROICalculatorApp?.updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth); }
|
||||
function resetAdvancedParameters() { window.ROICalculatorApp?.resetAdvancedParameters(); }
|
||||
function toggleScenario(scenarioKey) { window.ROICalculatorApp?.toggleScenario(scenarioKey); }
|
||||
function updateMonthlyBreakdownFilters() { window.ROICalculatorApp?.updateMonthlyBreakdownFilters(); }
|
||||
function toggleCollapsible(elementId) { window.ROICalculatorApp?.toggleCollapsible(elementId); }
|
||||
function resetCalculator() { window.ROICalculatorApp?.resetCalculator(); }
|
||||
// toggleInvestmentModel function removed - both models calculated simultaneously
|
||||
function logout() { window.ROICalculatorApp?.logout(); }
|
||||
|
||||
// Manual toggle functions for collapse elements
|
||||
function toggleAdvancedControls() {
|
||||
const element = document.getElementById('advancedControls');
|
||||
const button = document.getElementById('advancedToggleBtn');
|
||||
|
||||
console.log('Toggling advanced controls, current classes:', element.className);
|
||||
|
||||
if (element.style.display === 'none' || element.style.display === '') {
|
||||
element.style.display = 'block';
|
||||
button.innerHTML = '<i class="bi bi-gear"></i> Less';
|
||||
console.log('Showing advanced controls');
|
||||
} else {
|
||||
element.style.display = 'none';
|
||||
button.innerHTML = '<i class="bi bi-gear"></i> More';
|
||||
console.log('Hiding advanced controls');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDataCollapse() {
|
||||
const element = document.getElementById('dataCollapse');
|
||||
const button = document.getElementById('dataToggleBtn');
|
||||
|
||||
console.log('Toggling data collapse, current style:', element.style.display);
|
||||
|
||||
if (element.style.display === 'none' || element.style.display === '') {
|
||||
element.style.display = 'block';
|
||||
button.classList.remove('collapsed');
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
console.log('Showing data collapse');
|
||||
} else {
|
||||
element.style.display = 'none';
|
||||
button.classList.add('collapsed');
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
console.log('Hiding data collapse');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize collapse states
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Ensure both sections start collapsed
|
||||
const advancedControls = document.getElementById('advancedControls');
|
||||
const dataCollapse = document.getElementById('dataCollapse');
|
||||
|
||||
if (advancedControls) {
|
||||
advancedControls.style.display = 'none';
|
||||
}
|
||||
if (dataCollapse) {
|
||||
dataCollapse.style.display = 'none';
|
||||
}
|
||||
|
||||
console.log('Collapse elements initialized as hidden');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid p-0" style="min-height: 100vh;">
|
||||
<!-- Minimal Header with Controls -->
|
||||
<div class="bg-light border-bottom sticky-top" style="z-index: 1030;">
|
||||
<div class="container-fluid">
|
||||
<!-- Title Row -->
|
||||
<div class="row py-2 border-bottom">
|
||||
<div class="col-md-6">
|
||||
<h4 class="mb-0">CSP ROI Calculator</h4>
|
||||
<small class="text-muted">Real-time investment analysis</small>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<a href="{% url 'services:roi_calculator_help' %}" class="btn btn-sm btn-outline me-1" target="_blank">
|
||||
<i class="bi bi-question-circle"></i> Help
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-1" onclick="exportToPDF()">
|
||||
<i class="bi bi-file-pdf"></i> PDF
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success me-1" onclick="exportToCSV()">
|
||||
<i class="bi bi-file-csv"></i> CSV
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline me-1" onclick="resetCalculator()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Reset
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="logout()">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Configuration Section -->
|
||||
<div class="py-4">
|
||||
<div class="row">
|
||||
<!-- Left Column: Configuration Fields -->
|
||||
<div class="col-lg-8 col-xl-7">
|
||||
<div class="main-config-fields">
|
||||
<!-- Investment Amount -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold mb-2">Initial Investment</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<span class="input-group-text" id="investment-currency-prefix">CHF</span>
|
||||
<input type="text" class="form-control" id="investment-amount"
|
||||
data-value="500000" value="500,000"
|
||||
oninput="handleInvestmentAmountInput(this)"
|
||||
onfocus="handleInvestmentAmountFocus(this)"
|
||||
onblur="handleInvestmentAmountBlur(this)"
|
||||
placeholder="Enter amount (100,000 - 2,000,000)">
|
||||
</div>
|
||||
<input type="range" class="form-range mt-3" id="investment-slider"
|
||||
min="100000" max="2000000" step="50000" value="500000"
|
||||
onchange="updateInvestmentAmount(this.value)">
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<small class="text-muted" id="investment-min-label">CHF 100K</small>
|
||||
<small class="text-muted" id="investment-max-label">CHF 2M</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Currency, Analysis Period & Service Revenue Row -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold mb-2">Currency</label>
|
||||
<select class="form-select form-select-lg" id="currency" onchange="updateCurrency()">
|
||||
<option value="CHF" selected>CHF (Swiss Franc)</option>
|
||||
<option value="EUR">EUR (Euro)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold mb-2">Analysis Period</label>
|
||||
<select class="form-select form-select-lg" id="timeframe" onchange="updateCalculations()">
|
||||
<option value="1">1 Year</option>
|
||||
<option value="2">2 Years</option>
|
||||
<option value="3" selected>3 Years</option>
|
||||
<option value="4">4 Years</option>
|
||||
<option value="5">5 Years</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold mb-2">Service Revenue per Instance</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<input type="number" class="form-control" id="revenue-per-instance"
|
||||
min="20" max="200" step="5" value="50" onchange="updateCalculations()">
|
||||
<span class="input-group-text" id="revenue-currency-suffix">CHF/month</span>
|
||||
</div>
|
||||
<input type="range" class="form-range mt-3" id="revenue-slider"
|
||||
min="20" max="200" step="5" value="50"
|
||||
onchange="updateRevenuePerInstance(this.value)">
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<small class="text-muted" id="revenue-min-label">CHF 20</small>
|
||||
<small class="text-muted" id="revenue-max-label">CHF 200</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Growth Scenarios -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold mb-3">Growth Scenarios</label>
|
||||
<div class="d-flex gap-4 flex-wrap">
|
||||
<div class="form-check form-check-lg">
|
||||
<input class="form-check-input" type="checkbox" id="conservative-enabled" checked onchange="toggleScenario('conservative')">
|
||||
<label class="form-check-label fw-medium" for="conservative-enabled" data-bs-toggle="tooltip" title="Conservative: 2% churn, steady growth">
|
||||
<span class="text-success fs-4">●</span> Conservative
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-check-lg">
|
||||
<input class="form-check-input" type="checkbox" id="moderate-enabled" checked onchange="toggleScenario('moderate')">
|
||||
<label class="form-check-label fw-medium" for="moderate-enabled" data-bs-toggle="tooltip" title="Moderate: 3% churn, balanced growth">
|
||||
<span class="text-warning fs-4">●</span> Moderate
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-check-lg">
|
||||
<input class="form-check-input" type="checkbox" id="aggressive-enabled" checked onchange="toggleScenario('aggressive')">
|
||||
<label class="form-check-label fw-medium" for="aggressive-enabled" data-bs-toggle="tooltip" title="Aggressive: 5% churn, rapid growth">
|
||||
<span class="text-danger fs-4">●</span> Aggressive
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Investment Benefits Display -->
|
||||
<div class="row mb-4" id="investment-benefits">
|
||||
<div class="col-12">
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h6 class="mb-0"><i class="bi bi-trophy"></i> Your Investment Benefits</h6>
|
||||
</div>
|
||||
<div class="card-body py-3">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<div class="benefit-metric">
|
||||
<div class="benefit-value text-primary fw-bold" id="instance-scaling">1.0x</div>
|
||||
<div class="benefit-label small text-muted">Instance Scaling</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<div class="benefit-metric">
|
||||
<div class="benefit-value text-success fw-bold" id="revenue-premium">+0%</div>
|
||||
<div class="benefit-label small text-muted">Revenue Premium</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<div class="benefit-metric">
|
||||
<div class="benefit-value text-info fw-bold" id="grace-period-display">6 months</div>
|
||||
<div class="benefit-label small text-muted">Grace Period</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<div class="benefit-metric">
|
||||
<div class="benefit-value text-warning fw-bold" id="max-bonus">15%</div>
|
||||
<div class="benefit-label small text-muted">Max Performance Bonus</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
<button class="btn btn-outline-info btn-lg" type="button" onclick="toggleAdvancedControls()" id="advancedToggleBtn">
|
||||
<i class="bi bi-gear"></i> Advanced Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Real-Time Results -->
|
||||
<div class="col-lg-4 col-xl-5">
|
||||
<div class="results-panel h-100">
|
||||
<div class="card h-100 border-0 shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-graph-up"></i> Real-Time Results</h5>
|
||||
<small class="opacity-75">Live calculations based on your parameters</small>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column justify-content-center">
|
||||
<!-- Direct Investment Results -->
|
||||
<div class="result-item mb-4">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="result-icon bg-success text-white rounded-circle me-3 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-rocket"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-0 text-success fw-bold">Direct Investment</h6>
|
||||
<small class="text-muted">Performance-based returns</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-metrics bg-light rounded p-3">
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<div class="h4 mb-1 text-success fw-bold" id="net-position-direct">CHF 0</div>
|
||||
<small class="text-muted">Net Profit</small>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="h4 mb-1 text-primary fw-bold" id="roi-percentage-direct">0%</div>
|
||||
<small class="text-muted">Total ROI</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loan Model Results -->
|
||||
<div class="result-item">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="result-icon bg-warning text-dark rounded-circle me-3 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-bank"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-0 text-warning fw-bold">Loan Model</h6>
|
||||
<small class="text-muted">Fixed guaranteed returns</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-metrics bg-light rounded p-3">
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<div class="h4 mb-1 text-success fw-bold" id="net-position-loan">CHF 0</div>
|
||||
<small class="text-muted">Net Profit</small>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="h4 mb-1 text-primary fw-bold" id="roi-percentage-loan">0%</div>
|
||||
<small class="text-muted">Total ROI</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Controls Section -->
|
||||
<div class="collapse" id="advancedControls">
|
||||
<div class="bg-light border-top py-4">
|
||||
<div class="container-fluid">
|
||||
<h6 class="text-primary mb-4"><i class="bi bi-gear"></i> Advanced Configuration</h6>
|
||||
|
||||
<!-- Investment Model Parameters -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h6 class="text-secondary mb-3">Investment Model Settings</h6>
|
||||
</div>
|
||||
|
||||
<!-- Loan Rate -->
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold mb-2">Loan Interest Rate</label>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="loan-interest-rate"
|
||||
min="3" max="8" step="0.1" value="5.0" onchange="updateCalculations()">
|
||||
<span class="input-group-text">% annual</span>
|
||||
</div>
|
||||
<input type="range" class="form-range mt-2" id="loan-rate-slider"
|
||||
min="3" max="8" step="0.1" value="5.0" onchange="updateLoanRate(this.value)">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small class="text-muted">3%</small>
|
||||
<small class="text-muted">8%</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Servala Share -->
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold mb-2">Servala Revenue Share</label>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="servala-share"
|
||||
min="10" max="40" step="1" value="25" onchange="updateCalculations()">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
<input type="range" class="form-range mt-2" id="share-slider"
|
||||
min="10" max="40" step="1" value="25" onchange="updateServalaShare(this.value)">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small class="text-muted">10%</small>
|
||||
<small class="text-muted">40%</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grace Period -->
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold mb-2">Grace Period</label>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="grace-period"
|
||||
min="0" max="24" step="1" value="6" onchange="updateCalculations()">
|
||||
<span class="input-group-text">months</span>
|
||||
</div>
|
||||
<input type="range" class="form-range mt-2" id="grace-slider"
|
||||
min="0" max="24" step="1" value="6" onchange="updateGracePeriod(this.value)">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small class="text-muted">0</small>
|
||||
<small class="text-muted">24</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Service Revenue -->
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold mb-2">Core Service Revenue</label>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="core-service-revenue"
|
||||
min="0" max="500" step="5" value="0" onchange="updateCalculations()">
|
||||
<span class="input-group-text">CHF/month</span>
|
||||
</div>
|
||||
<input type="range" class="form-range mt-2" id="core-revenue-slider"
|
||||
min="0" max="500" step="5" value="0" onchange="updateCoreServiceRevenue(this.value)">
|
||||
<div class="d-flex justify-content-between">
|
||||
<small class="text-muted">CHF 0</small>
|
||||
<small class="text-muted">CHF 500</small>
|
||||
</div>
|
||||
<small class="text-muted">Additional compute/storage revenue per instance</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scenario Parameters -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h6 class="text-secondary mb-3">Growth Scenario Tuning</h6>
|
||||
</div>
|
||||
|
||||
<!-- Conservative Scenario -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h6 class="mb-0"><i class="bi bi-shield-check"></i> Conservative Scenario</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Churn Rate -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold mb-2">Monthly Churn Rate</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" class="form-control" id="conservative-churn"
|
||||
min="0" max="10" step="0.1" value="2.0" onchange="updateScenarioChurn('conservative', this.value)">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase Growth Parameters -->
|
||||
<label class="form-label fw-semibold mb-2">Instance Growth per Phase</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 1 (6mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="conservative-phase-0"
|
||||
min="10" max="200" value="50" onchange="updateScenarioPhase('conservative', 0, this.value)">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 2 (6mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="conservative-phase-1"
|
||||
min="10" max="200" value="75" onchange="updateScenarioPhase('conservative', 1, this.value)">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 3 (12mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="conservative-phase-2"
|
||||
min="10" max="300" value="100" onchange="updateScenarioPhase('conservative', 2, this.value)">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 4 (12mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="conservative-phase-3"
|
||||
min="10" max="300" value="150" onchange="updateScenarioPhase('conservative', 3, this.value)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Moderate Scenario -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h6 class="mb-0"><i class="bi bi-speedometer2"></i> Moderate Scenario</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Churn Rate -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold mb-2">Monthly Churn Rate</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" class="form-control" id="moderate-churn"
|
||||
min="0" max="10" step="0.1" value="3.0" onchange="updateScenarioChurn('moderate', this.value)">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase Growth Parameters -->
|
||||
<label class="form-label fw-semibold mb-2">Instance Growth per Phase</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 1 (6mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="moderate-phase-0"
|
||||
min="20" max="300" value="100" onchange="updateScenarioPhase('moderate', 0, this.value)">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 2 (6mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="moderate-phase-1"
|
||||
min="20" max="400" value="200" onchange="updateScenarioPhase('moderate', 1, this.value)">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 3 (12mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="moderate-phase-2"
|
||||
min="20" max="500" value="300" onchange="updateScenarioPhase('moderate', 2, this.value)">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 4 (12mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="moderate-phase-3"
|
||||
min="20" max="600" value="400" onchange="updateScenarioPhase('moderate', 3, this.value)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aggressive Scenario -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h6 class="mb-0"><i class="bi bi-rocket"></i> Aggressive Scenario</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Churn Rate -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold mb-2">Monthly Churn Rate</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" class="form-control" id="aggressive-churn"
|
||||
min="0" max="15" step="0.1" value="5.0" onchange="updateScenarioChurn('aggressive', this.value)">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase Growth Parameters -->
|
||||
<label class="form-label fw-semibold mb-2">Instance Growth per Phase</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 1 (6mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="aggressive-phase-0"
|
||||
min="50" max="500" value="200" onchange="updateScenarioPhase('aggressive', 0, this.value)">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 2 (6mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="aggressive-phase-1"
|
||||
min="50" max="600" value="400" onchange="updateScenarioPhase('aggressive', 1, this.value)">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 3 (12mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="aggressive-phase-2"
|
||||
min="50" max="800" value="600" onchange="updateScenarioPhase('aggressive', 2, this.value)">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Phase 4 (12mo)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="aggressive-phase-3"
|
||||
min="50" max="1000" value="800" onchange="updateScenarioPhase('aggressive', 3, this.value)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Button -->
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="resetAdvancedParameters()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Reset to Defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CHARTS - Maximum Space -->
|
||||
<div class="container-fluid px-3 py-3" style="background: #f8f9fa;">
|
||||
<!-- Loading Spinner -->
|
||||
<div class="loading-spinner text-center py-5" id="loading-spinner" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Calculating...</span>
|
||||
</div>
|
||||
<p class="mt-2">Calculating scenarios...</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- PRIMARY CHART - Full Width, Large Height -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0 pb-0">
|
||||
<h5 class="mb-1"><i class="bi bi-graph-up-arrow text-primary"></i> ROI Progression Over Time</h5>
|
||||
<p class="small text-muted mb-0">Investment profitability timeline - when you'll break even and achieve target returns</p>
|
||||
</div>
|
||||
<div class="card-body pt-3">
|
||||
<canvas id="instanceGrowthChart" style="height: 500px; width: 100%;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECONDARY CHARTS - Side by Side, Large -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-6 mb-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white border-0 pb-0">
|
||||
<h5 class="mb-1"><i class="bi bi-cash-stack text-success"></i> Net Financial Position</h5>
|
||||
<p class="small text-muted mb-0">Cumulative profit/loss over time</p>
|
||||
</div>
|
||||
<div class="card-body pt-3">
|
||||
<canvas id="revenueChart" style="height: 400px; width: 100%;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-6 mb-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white border-0 pb-0">
|
||||
<h5 class="mb-1"><i class="bi bi-bar-chart text-warning"></i> Performance Comparison</h5>
|
||||
<p class="small text-muted mb-0">ROI performance across growth scenarios</p>
|
||||
</div>
|
||||
<div class="card-body pt-3">
|
||||
<canvas id="cashFlowChart" style="height: 400px; width: 100%;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSP REVENUE BREAKDOWN CHART - Full Width -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0 pb-0">
|
||||
<h5 class="mb-1"><i class="bi bi-cash-stack text-success"></i> CSP Revenue Breakdown</h5>
|
||||
<p class="small text-muted mb-0">Direct investment revenue breakdown: Service fees, core infrastructure sales, total CSP revenue, and Servala share over time</p>
|
||||
</div>
|
||||
<div class="card-body pt-3">
|
||||
<canvas id="cspRevenueChart" style="height: 400px; width: 100%;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DATA TABLE - Collapsible to Save Space -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="accordion" id="dataAccordion">
|
||||
<div class="accordion-item border-0 shadow-sm">
|
||||
<h2 class="accordion-header" id="dataHeading">
|
||||
<button class="accordion-button collapsed" type="button" onclick="toggleDataCollapse()" id="dataToggleBtn">
|
||||
<i class="bi bi-table me-2"></i> Detailed Financial Analysis
|
||||
</button>
|
||||
</h2>
|
||||
<div id="dataCollapse" class="accordion-collapse collapse" aria-labelledby="dataHeading" data-bs-parent="#dataAccordion">
|
||||
<div class="accordion-body">
|
||||
<!-- Improved Scenario Performance Summary -->
|
||||
<h6 class="mb-3">Investment Model Comparison by Scenario</h6>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-sm table-hover" id="comparison-table">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Scenario</th>
|
||||
<th>Investment Model</th>
|
||||
<th>Final Scale</th>
|
||||
<th>Your Net Profit</th>
|
||||
<th>Total ROI</th>
|
||||
<th>Break-even Time</th>
|
||||
<th>Key Features</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="comparison-tbody">
|
||||
<!-- Dynamic content -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Financial Flow -->
|
||||
<h6 class="mb-3">Monthly Financial Breakdown</h6>
|
||||
|
||||
<!-- Breakdown Filter Controls -->
|
||||
<div class="row mb-3 breakdown-filters">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold mb-2">Investment Models</label>
|
||||
<div class="d-flex gap-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="breakdown-direct-enabled" checked onchange="updateMonthlyBreakdownFilters()">
|
||||
<label class="form-check-label small fw-medium text-success" for="breakdown-direct-enabled">
|
||||
Direct Investment
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="breakdown-loan-enabled" checked onchange="updateMonthlyBreakdownFilters()">
|
||||
<label class="form-check-label small fw-medium text-warning" for="breakdown-loan-enabled">
|
||||
Loan Model
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold mb-2">Growth Scenarios</label>
|
||||
<div class="d-flex gap-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="breakdown-conservative-enabled" checked onchange="updateMonthlyBreakdownFilters()">
|
||||
<label class="form-check-label small fw-medium" for="breakdown-conservative-enabled">
|
||||
<span class="text-success">•</span> Conservative
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="breakdown-moderate-enabled" checked onchange="updateMonthlyBreakdownFilters()">
|
||||
<label class="form-check-label small fw-medium" for="breakdown-moderate-enabled">
|
||||
<span class="text-warning">•</span> Moderate
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="breakdown-aggressive-enabled" checked onchange="updateMonthlyBreakdownFilters()">
|
||||
<label class="form-check-label small fw-medium" for="breakdown-aggressive-enabled">
|
||||
<span class="text-danger">•</span> Aggressive
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive" style="max-height: 500px; overflow-y: auto;">
|
||||
<table class="table table-sm table-striped" id="monthly-table">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th>Scenario</th>
|
||||
<th>Model</th>
|
||||
<th>Instances</th>
|
||||
<th>Service Revenue</th>
|
||||
<th>Core Revenue</th>
|
||||
<th>Total Revenue</th>
|
||||
<th>Your Share</th>
|
||||
<th>Servala Share</th>
|
||||
<th>Cumulative Net Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="monthly-tbody">
|
||||
<!-- Dynamic content -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Authentication Required{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-lock text-primary" style="font-size: 3rem;"></i>
|
||||
<h2 class="h4 mt-3">Authentication Required</h2>
|
||||
<p class="text-muted">Please enter the password to access the CSP ROI Calculator</p>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %}" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if not password_error %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Access Calculator</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center">
|
||||
<p class="text-muted">The calculator is temporarily unavailable due to configuration issues.</p>
|
||||
<a href="/" class="btn btn-outline-secondary">Return to Homepage</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,755 +0,0 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}ROI Calculator Help - Servala Investment Models{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" type="text/css" href='{% static "css/roi-calculator.css" %}'>
|
||||
<style>
|
||||
.help-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.help-section h2 {
|
||||
color: #007bff;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.help-section h3 {
|
||||
color: #28a745;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.comparison-table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.model-card {
|
||||
border-left: 4px solid;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.loan-model {
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
.direct-model {
|
||||
border-left-color: #28a745;
|
||||
}
|
||||
|
||||
/* Enhanced navigation styling */
|
||||
.list-group-item {
|
||||
border: none !important;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.list-group-flush {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.list-group-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
padding-left: 1rem !important;
|
||||
}
|
||||
|
||||
.list-group-item.active {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Section spacing */
|
||||
.help-section {
|
||||
scroll-margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1>ROI Calculator Help</h1>
|
||||
<p class="text-muted">Understanding Servala's Investment Models</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'services:csp_roi_calculator' %}" class="btn btn-primary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Calculator
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<!-- Investment Scaling Benefits Section (MOST IMPORTANT) -->
|
||||
<div class="help-section" id="investment-benefits">
|
||||
<h2><i class="bi bi-graph-up text-primary"></i> Investment Benefits & Market-Realistic Returns</h2>
|
||||
<div class="alert alert-info">
|
||||
<h5><i class="bi bi-bar-chart"></i> Conservative Scaling Based on Industry Standards</h5>
|
||||
<p class="mb-0">Our calculator uses proven, market-realistic scaling based on European managed services industry data. Returns are conservative and align with current investment expectations.</p>
|
||||
</div>
|
||||
|
||||
<h3>Market-Realistic Investment Benefits</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped comparison-table">
|
||||
<thead class="table-success">
|
||||
<tr>
|
||||
<th>Investment Amount</th>
|
||||
<th>Instance Multiplier</th>
|
||||
<th>Revenue Premium</th>
|
||||
<th>Performance Bonus Cap</th>
|
||||
<th>Grace Period Bonus</th>
|
||||
<th>Expected 3-Year ROI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>500,000</strong></td>
|
||||
<td>1.0x</td>
|
||||
<td>Standard rates</td>
|
||||
<td>8%</td>
|
||||
<td>Base period</td>
|
||||
<td class="text-success"><strong>40-60%</strong></td>
|
||||
</tr>
|
||||
<tr class="table-warning">
|
||||
<td><strong>1,000,000</strong></td>
|
||||
<td><span class="text-primary">1.5x</span></td>
|
||||
<td><span class="text-primary">+10% per instance</span></td>
|
||||
<td>10%</td>
|
||||
<td>+2 months</td>
|
||||
<td class="text-success"><strong>60-80%</strong></td>
|
||||
</tr>
|
||||
<tr class="table-info">
|
||||
<td><strong>1,500,000</strong></td>
|
||||
<td><span class="text-success">1.8x</span></td>
|
||||
<td><span class="text-success">+15% per instance</span></td>
|
||||
<td>12%</td>
|
||||
<td>+3 months</td>
|
||||
<td class="text-success"><strong>70-90%</strong></td>
|
||||
</tr>
|
||||
<tr class="table-success">
|
||||
<td><strong>2,000,000</strong></td>
|
||||
<td><span class="text-success fw-bold">2.0x</span></td>
|
||||
<td><span class="text-success fw-bold">+20% per instance</span></td>
|
||||
<td><span class="text-success fw-bold">15%</span></td>
|
||||
<td>+3 months</td>
|
||||
<td class="text-success"><strong>80-100%</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Comparison Section -->
|
||||
<div class="help-section" id="comparison">
|
||||
<h2><i class="bi bi-bar-chart"></i> Investment Model Comparison</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="model-card loan-model">
|
||||
<h5><i class="bi bi-bank"></i> Loan Model</h5>
|
||||
<p><strong>3-8% Annual Returns</strong></p>
|
||||
<p>Fixed interest lending with guaranteed monthly payments. Low risk, predictable returns.</p>
|
||||
<ul class="text-muted small">
|
||||
<li>Guaranteed monthly payments</li>
|
||||
<li>No performance risk</li>
|
||||
<li>Fixed 3-8% annual returns</li>
|
||||
<li>Contractual protection</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="model-card direct-model">
|
||||
<h5><i class="bi bi-rocket"></i> Direct Investment</h5>
|
||||
<p><strong>40-100% Market-Realistic Returns</strong></p>
|
||||
<p>Performance-based revenue sharing with conservative scaling and sustainable grace periods.</p>
|
||||
<ul class="text-success small">
|
||||
<li>Linear scaling up to 2.0x instances</li>
|
||||
<li>Revenue premiums up to 20%</li>
|
||||
<li>Performance bonuses up to 15%</li>
|
||||
<li>Grace periods max 6 months</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Direct Comparison - 3 Years ROI:</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Risk Level</th>
|
||||
<th>Expected ROI</th>
|
||||
<th>Break-even</th>
|
||||
<th>Profit Potential</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Loan Model (1M)</strong></td>
|
||||
<td><span class="badge bg-success">Low</span></td>
|
||||
<td>15-25% over 3 years</td>
|
||||
<td>12-18 months</td>
|
||||
<td>150,000 - 250,000</td>
|
||||
</tr>
|
||||
<tr class="table-warning">
|
||||
<td><strong>Direct Investment (1M)</strong></td>
|
||||
<td><span class="badge bg-warning">Moderate</span></td>
|
||||
<td>60-80% over 3 years</td>
|
||||
<td>18-24 months</td>
|
||||
<td>600,000 - 800,000</td>
|
||||
</tr>
|
||||
<tr class="table-success">
|
||||
<td><strong>Direct Investment (2M)</strong></td>
|
||||
<td><span class="badge bg-danger">High</span></td>
|
||||
<td>80-100% over 3 years</td>
|
||||
<td>20-26 months</td>
|
||||
<td>1,600,000 - 2,000,000</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calculator Guide Section -->
|
||||
<div class="help-section" id="calculator-guide">
|
||||
<h2><i class="bi bi-calculator"></i> How to Use the Calculator</h2>
|
||||
|
||||
<h3>Quick Start Guide</h3>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5>Essential Settings</h5>
|
||||
<ol>
|
||||
<li><strong>Investment Amount:</strong> Use slider or type amount (100K - 2M)</li>
|
||||
<li><strong>Timeframe:</strong> Choose 1-5 years for your projection</li>
|
||||
<li><strong>Currency:</strong> Select CHF or EUR</li>
|
||||
<li><strong>Growth Scenario:</strong> Enable scenarios that match your market</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5>Understanding Results</h5>
|
||||
<ul>
|
||||
<li><strong>Net Position:</strong> Your profit after investment</li>
|
||||
<li><strong>ROI Percentage:</strong> Return on investment rate</li>
|
||||
<li><strong>Investment Benefits:</strong> Real-time scaling display</li>
|
||||
<li><strong>Break-even:</strong> When you start profiting</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Advanced Parameters (Optional)</h3>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<ul>
|
||||
<li><strong>Service Revenue/Instance:</strong> Monthly Servala service fee (20-200)</li>
|
||||
<li><strong>Core Revenue/Instance:</strong> Additional infrastructure revenue (0-500)</li>
|
||||
<li><strong>Loan Rate:</strong> Annual interest for loan model (3-8%)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<ul>
|
||||
<li><strong>Servala Share:</strong> Revenue split for direct investment (10-40%)</li>
|
||||
<li><strong>Grace Period:</strong> 100% revenue retention period (0-24 months)</li>
|
||||
<li><strong>Churn Rates:</strong> Customer loss by scenario (0-15%)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Growth Scenarios Section -->
|
||||
<div class="help-section" id="scenarios">
|
||||
<h2><i class="bi bi-speedometer2"></i> Growth Scenarios</h2>
|
||||
<p>Choose the scenarios that best match your market conditions and sales capabilities:</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-success">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">Safe (Conservative)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>2.5% monthly churn</strong></p>
|
||||
<p>Steady growth: 15-40 new instances/month</p>
|
||||
<p>Best for: Established markets, risk-averse CSPs</p>
|
||||
<p class=\"small text-muted\">~5-8 new clients/month × 3-5 instances each</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-warning">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h5 class="mb-0">Balanced (Moderate)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>3% monthly churn</strong></p>
|
||||
<p>Balanced growth: 25-90 new instances/month</p>
|
||||
<p>Best for: Competitive markets, balanced approach</p>
|
||||
<p class=\"small text-muted\">~8-15 new clients/month × 3-6 instances each</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0">Fast (Aggressive)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>3.5% monthly churn</strong></p>
|
||||
<p>Rapid growth: 40-150 new instances/month</p>
|
||||
<p>Best for: High-growth strategies, active sales</p>
|
||||
<p class=\"small text-muted\">~12-25 new clients/month × 3-6 instances each</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Understanding Charts Section -->
|
||||
<div class="help-section" id="charts">
|
||||
<h2><i class="bi bi-graph-up"></i> Reading the Charts</h2>
|
||||
|
||||
<h3>Key Charts Explained</h3>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5>1. ROI Progression Over Time</h5>
|
||||
<p>Shows when your investment becomes profitable and how returns develop monthly. Look for the point where lines cross zero.</p>
|
||||
|
||||
<h5>2. Net Financial Position</h5>
|
||||
<p>Your cumulative profit/loss over time. Above zero = profitable, below zero = still recovering investment.</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5>3. Performance Comparison</h5>
|
||||
<p>ROI percentages across different growth scenarios - compare best and worst-case outcomes.</p>
|
||||
|
||||
<h5>4. CSP Revenue Breakdown</h5>
|
||||
<p>Monthly revenue analysis showing service revenue, core revenue, CSP total, and Servala revenue share.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Chart Legend</h4>
|
||||
<ul>
|
||||
<li><strong>Solid Lines:</strong> Direct Investment Model</li>
|
||||
<li><strong>Dashed Lines:</strong> Loan Model</li>
|
||||
<li><strong>Green:</strong> Conservative Scenario | <strong>Yellow:</strong> Moderate | <strong>Red:</strong> Aggressive</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Market Context & Risk Factors Section -->
|
||||
<div class=\"help-section\" id=\"market-context\">
|
||||
<h2><i class=\"bi bi-exclamation-triangle text-warning\"></i> Market Context & Risk Factors</h2>
|
||||
|
||||
<div class=\"alert alert-warning\">
|
||||
<h6><i class=\"bi bi-info-circle\"></i> Important Disclaimer</h6>
|
||||
<p class=\"mb-0\">These projections are based on European managed services market data (13-15% CAGR) and current industry standards. Actual results may vary significantly based on market conditions, execution, and competitive factors.</p>
|
||||
</div>
|
||||
|
||||
<h3>Key Risk Factors</h3>
|
||||
<div class=\"row\">
|
||||
<div class=\"col-md-6\">
|
||||
<h5>Market Risks</h5>
|
||||
<ul>
|
||||
<li><strong>Competition:</strong> US hyperscalers (AWS, Azure, Google) may respond aggressively</li>
|
||||
<li><strong>Market Maturity:</strong> European managed services adoption varies by region</li>
|
||||
<li><strong>Economic Conditions:</strong> Recession or funding limitations could impact growth</li>
|
||||
<li><strong>Regulatory Changes:</strong> Data sovereignty laws may change</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class=\"col-md-6\">
|
||||
<h5>Execution Risks</h5>
|
||||
<ul>
|
||||
<li><strong>Technology Development:</strong> Platform development may face delays</li>
|
||||
<li><strong>Partner Adoption:</strong> CSPs may be slower to adopt than projected</li>
|
||||
<li><strong>Talent Acquisition:</strong> Skilled technical resources are scarce</li>
|
||||
<li><strong>Customer Churn:</strong> Actual churn rates may exceed projections</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Conservative Scenario Modeling</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped comparison-table">
|
||||
<thead class="table-success">
|
||||
<tr>
|
||||
<th>Scenario</th>
|
||||
<th>Market Conditions</th>
|
||||
<th>Expected Growth</th>
|
||||
<th>Break-even Time</th>
|
||||
<th>3-Year ROI Range</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="table-danger">
|
||||
<td><strong>Pessimistic</strong></td>
|
||||
<td>Economic downturn, strong competition</td>
|
||||
<td>5-10% annually</td>
|
||||
<td>30-36 months</td>
|
||||
<td>20-40%</td>
|
||||
</tr>
|
||||
<tr class="table-warning">
|
||||
<td><strong>Realistic</strong></td>
|
||||
<td>Normal market conditions</td>
|
||||
<td>15-25% annually</td>
|
||||
<td>18-24 months</td>
|
||||
<td>50-80%</td>
|
||||
</tr>
|
||||
<tr class="table-success">
|
||||
<td><strong>Optimistic</strong></td>
|
||||
<td>Favorable market, rapid adoption</td>
|
||||
<td>25-35% annually</td>
|
||||
<td>12-18 months</td>
|
||||
<td>80-120%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class=\"alert alert-info mt-3\">
|
||||
<h6><i class=\"bi bi-lightbulb\"></i> Investment Recommendation</h6>
|
||||
<p class=\"mb-0\">Consider these projections as best-case scenarios under favorable conditions. Prudent investors should plan for the \"Realistic\" scenario while hoping for \"Optimistic\" outcomes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market Analysis & Validation Section -->
|
||||
<div class="help-section" id="market-analysis">
|
||||
<h2><i class="bi bi-graph-up-arrow text-success"></i> Market Analysis & ROI Validation</h2>
|
||||
|
||||
<div class="alert alert-success">
|
||||
<h6><i class="bi bi-check-circle"></i> Market-Validated Projections</h6>
|
||||
<p class="mb-0">Our ROI calculator uses data-driven projections based on comprehensive European managed services market research. All growth scenarios and return expectations are benchmarked against industry standards and competitive analysis.</p>
|
||||
</div>
|
||||
|
||||
<h3>European Managed Services Market Reality</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5><i class="bi bi-currency-euro"></i> Market Size & Growth</h5>
|
||||
<ul>
|
||||
<li><strong>Market Value</strong>: €51-85 billion in 2024 <a href="https://www.grandviewresearch.com/horizon/outlook/managed-services-market/europe" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
||||
<li><strong>Projected Growth</strong>: €113-255 billion by 2030-2033 <a href="https://www.marketdataforecast.com/market-reports/europe-managed-services-market" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
||||
<li><strong>CAGR</strong>: 13-15% annually (matches Servala's assumptions) <a href="https://www.mordorintelligence.com/industry-reports/europe-managed-services-market" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
||||
<li><strong>SME Growth</strong>: 10.6% CAGR (fastest growing segment) <a href="https://www.grandviewresearch.com/industry-analysis/managed-services-market" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5><i class="bi bi-cloud"></i> Cloud Adoption Trends</h5>
|
||||
<ul>
|
||||
<li><strong>EU Enterprise Adoption</strong>: 45% use cloud services in 2023 <a href="https://ec.europa.eu/eurostat/statistics-explained/index.php/Cloud_computing_-_statistics_on_the_use_by_enterprises" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
||||
<li><strong>Multi-Cloud Strategy</strong>: 92% of organizations use multiple providers <a href="https://www.cloudzero.com/blog/cloud-computing-statistics/" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
||||
<li><strong>Self-Service Growth</strong>: Strong demand for automated provisioning <a href="https://www.appvia.io/blog/why-self-service-is-key-to-cloud-adoption" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
||||
<li><strong>Swiss Leadership</strong>: 95% have adopted some cloud services <a href="https://finance.yahoo.com/news/swiss-public-cloud-market-grows-090000699.html" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Competitive Landscape Analysis</h3>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<h6><i class="bi bi-exclamation-triangle"></i> European Provider Challenge</h6>
|
||||
<p class="mb-0">European cloud providers' market share declined from 27% to 13% over the past five years as US hyperscalers (AWS, Azure, GCP) now control 72% of the regional market. <a href="https://www.telecoms.com/public-cloud/european-cloud-players-face-declining-market-share-as-us-hyperscalers-clean-up" target="_blank" class="text-primary">Source: Telecoms.com</a></p>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped comparison-table">
|
||||
<thead class="table-success">
|
||||
<tr>
|
||||
<th>Market Factor</th>
|
||||
<th>Current Reality</th>
|
||||
<th>Servala Opportunity</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Hyperscaler Dominance</strong></td>
|
||||
<td>AWS (31%), Azure (25%), GCP (11%)</td>
|
||||
<td>European alternative with data sovereignty</td>
|
||||
<td><a href="https://holori.com/cloud-market-share-2024-aws-azure-gcp/" target="_blank" class="text-primary">Holori 2024</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Data Sovereignty</strong></td>
|
||||
<td>GDPR, NIS2 compliance requirements</td>
|
||||
<td>Built-in European regulatory compliance</td>
|
||||
<td><a href="https://thenextweb.com/news/european-cloud-alternative-to-aws-azure-and-gcp" target="_blank" class="text-primary">TNW Analysis</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Vendor Lock-in Concerns</strong></td>
|
||||
<td>Growing enterprise resistance</td>
|
||||
<td>Open-source, multi-cloud architecture</td>
|
||||
<td><a href="https://www.computerweekly.com/feature/Introducing-the-EuroStack-initiative-Could-this-turn-the-tide-on-hyperscale-cloud-in-Europe" target="_blank" class="text-primary">Computer Weekly</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Regional Provider Decline</strong></td>
|
||||
<td>Market share falling despite 167% revenue growth</td>
|
||||
<td>Platform to help regain competitiveness</td>
|
||||
<td><a href="https://www.telecoms.com/public-cloud/european-cloud-players-face-declining-market-share-as-us-hyperscalers-clean-up" target="_blank" class="text-primary">Telecoms.com</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>ROI Projections: Market Benchmarking</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5><i class="bi bi-calculator"></i> Growth Scenario Validation</h5>
|
||||
<div class="card border-success">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-success">Conservative Scenario</h6>
|
||||
<p><strong>15-40 instances/month</strong></p>
|
||||
<ul class="small">
|
||||
<li>5-8 new clients/month × 3-5 instances each</li>
|
||||
<li>Aligns with EU enterprise cloud adoption (45%)</li>
|
||||
<li>Conservative vs. Servala's current 65+ instances/customer</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-warning mt-2">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-warning">Aggressive Scenario</h6>
|
||||
<p><strong>40-150 instances/month</strong></p>
|
||||
<ul class="small">
|
||||
<li>12-25 new clients/month × 3-6 instances each</li>
|
||||
<li>Leverages self-service signup trends</li>
|
||||
<li>Supported by 92% multi-cloud adoption rate</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5><i class="bi bi-graph-up"></i> Return Expectations</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-info">
|
||||
<tr>
|
||||
<th>Investment Level</th>
|
||||
<th>Servala ROI</th>
|
||||
<th>Market Benchmark</th>
|
||||
<th>Assessment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>500K (3 years)</td>
|
||||
<td>40-60%</td>
|
||||
<td>SaaS CAC: $1.18-1.50/ARR</td>
|
||||
<td><span class="badge bg-success">Realistic</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1M (3 years)</td>
|
||||
<td>60-80%</td>
|
||||
<td>Managed services: 13-15% CAGR</td>
|
||||
<td><span class="badge bg-success">Conservative</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2M (3 years)</td>
|
||||
<td>80-100%</td>
|
||||
<td>High-growth SaaS: 50-100%</td>
|
||||
<td><span class="badge bg-warning">Ambitious</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-2">
|
||||
<h6><i class="bi bi-info-circle"></i> SaaS Benchmarks</h6>
|
||||
<p class="mb-0 small">Industry CAC ranges $400-$5,000 per customer (low to high-touch). Servala's self-service model targets the lower end while premium revenue per instance supports healthy unit economics. <a href="https://churnfree.com/blog/average-customer-acquisition-cost-saas/" target="_blank" class="text-primary">Source: ChurnFree</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Market Timing & Strategic Positioning</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-primary">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h6 class="mb-0"><i class="bi bi-shield-check"></i> Regulatory Tailwinds</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="small mb-0">
|
||||
<li><strong>GDPR Compliance</strong>: European data residency requirements</li>
|
||||
<li><strong>NIS2 Directive</strong>: Enhanced cybersecurity mandates</li>
|
||||
<li><strong>Digital Sovereignty</strong>: Growing political support for European solutions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-success">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h6 class="mb-0"><i class="bi bi-graph-up"></i> Market Dynamics</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="small mb-0">
|
||||
<li><strong>Self-Service Demand</strong>: Growing preference for automated provisioning</li>
|
||||
<li><strong>Multi-Cloud Strategy</strong>: 92% of enterprises avoid single vendor</li>
|
||||
<li><strong>SME Growth</strong>: Fastest segment at 10.6% CAGR</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-warning">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h6 class="mb-0"><i class="bi bi-target"></i> Competitive Advantage</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="small mb-0">
|
||||
<li><strong>Open Source</strong>: No vendor lock-in vs. proprietary platforms</li>
|
||||
<li><strong>European Focus</strong>: Built for EU regulatory environment</li>
|
||||
<li><strong>Proven Traction</strong>: 2,000+ instances, 30+ customers</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success mt-4">
|
||||
<h6><i class="bi bi-check-circle-fill"></i> Market Validation Summary</h6>
|
||||
<p class="mb-2">Our market analysis confirms that Servala's ROI projections are <strong>conservative and market-realistic</strong>:</p>
|
||||
<ul class="mb-0">
|
||||
<li><strong>Growth scenarios</strong> align with European cloud adoption trends and self-service demand</li>
|
||||
<li><strong>ROI expectations</strong> (40-100%) match industry benchmarks for SaaS and managed services</li>
|
||||
<li><strong>Market timing</strong> leverages regulatory tailwinds and competitive gaps in the European market</li>
|
||||
<li><strong>Business model</strong> addresses proven market needs with validated technology (2,000+ instances running)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<h6>Additional Research Sources</h6>
|
||||
<div class="row text-small">
|
||||
<div class="col-md-4">
|
||||
<p><strong>Market Research:</strong><br>
|
||||
<a href="https://www.statista.com/topics/8472/cloud-computing-in-europe/" target="_blank" class="text-muted">Statista - Cloud Computing in Europe</a><br>
|
||||
<a href="https://www.forrester.com/report/the-state-of-cloud-in-europe-2024/RES181812" target="_blank" class="text-muted">Forrester - State of Cloud Europe 2024</a></p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p><strong>Industry Analysis:</strong><br>
|
||||
<a href="https://www.gminsights.com/industry-analysis/managed-services-market" target="_blank" class="text-muted">GM Insights - Managed Services Market</a><br>
|
||||
<a href="https://straitsresearch.com/report/managed-services-market" target="_blank" class="text-muted">Straits Research - Market Trends</a></p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p><strong>Competitive Intelligence:</strong><br>
|
||||
<a href="https://www.g2.com/articles/cloud-computing-statistics" target="_blank" class="text-muted">G2 - Cloud Computing Statistics</a><br>
|
||||
<a href="https://spacelift.io/blog/cloud-computing-statistics" target="_blank" class="text-muted">Spacelift - Industry Statistics</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Currency Support Section -->
|
||||
<div class="help-section" id="currency-support">
|
||||
<h2><i class="bi bi-cash-stack"></i> Currency Support</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5><i class="bi bi-cash"></i> Swiss Franc (CHF) - Default</h5>
|
||||
<ul>
|
||||
<li>Swiss locale formatting (de-CH)</li>
|
||||
<li>Traditional Swiss business format</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5><i class="bi bi-currency-euro"></i> Euro (EUR)</h5>
|
||||
<ul>
|
||||
<li>European locale formatting (de-DE)</li>
|
||||
<li>EU business format compliance</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3">
|
||||
<h6><i class="bi bi-info-circle"></i> Important</h6>
|
||||
<p class="mb-0">Currency selection only changes display format - no conversion is performed. Enter all amounts in your chosen currency.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technical Details Section (LOWER PRIORITY) -->
|
||||
<div class="help-section" id="technical-details">
|
||||
<h2><i class="bi bi-gear"></i> Technical Details</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h4>Loan Model Details</h4>
|
||||
<ul>
|
||||
<li><strong>Payment Calculation:</strong> Standard amortization formula</li>
|
||||
<li><strong>Interest Rates:</strong> 3-8% annually</li>
|
||||
<li><strong>Risk Level:</strong> Very low - contractually guaranteed</li>
|
||||
<li><strong>Break-even:</strong> Typically 12-18 months</li>
|
||||
</ul>
|
||||
|
||||
<h5>Monthly Payment Formula</h5>
|
||||
<code>Monthly Payment = P × [r(1+r)^n] / [(1+r)^n - 1]</code>
|
||||
<p class="small text-muted mt-2">Where P = Principal, r = Monthly rate, n = Total payments</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h4>Direct Investment Details</h4>
|
||||
<ul>
|
||||
<li><strong>Revenue Streams:</strong> Service fees + Core infrastructure sales</li>
|
||||
<li><strong>Performance Tracking:</strong> Automatic baseline comparison</li>
|
||||
<li><strong>Grace Periods:</strong> 100% revenue retention periods</li>
|
||||
<li><strong>Churn Reduction:</strong> Investment-based customer success</li>
|
||||
</ul>
|
||||
|
||||
<h5>Performance Multiplier</h5>
|
||||
<p>Automatically calculated: Actual instances ÷ Baseline instances</p>
|
||||
<p class="text-muted small">1.0x = baseline, 1.5x = 50% above baseline, 2.0x = double baseline</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Section (LOWEST PRIORITY) -->
|
||||
<div class="help-section" id="faq">
|
||||
<h2><i class="bi bi-question-circle"></i> Frequently Asked Questions</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5>Basic Questions</h5>
|
||||
<h6>What does "Net Position" mean?</h6>
|
||||
<p>Your profit after subtracting your initial investment. Positive = profitable.</p>
|
||||
|
||||
<h6>What is Core Service Revenue?</h6>
|
||||
<p>Additional income from selling compute/storage per instance. <strong>100% retained by CSP</strong> - not shared with Servala.</p>
|
||||
|
||||
<h6>What happens during grace periods?</h6>
|
||||
<p>You keep 100% of service revenue + all core revenue. Larger investments get longer grace periods.</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5>Advanced Questions</h5>
|
||||
<h6>How are performance bonuses calculated?</h6>
|
||||
<p>Bonuses apply when you exceed 110% of baseline growth, providing up to 15% additional revenue share for large investments.</p>
|
||||
|
||||
<h6>What is the Performance Multiplier?</h6>
|
||||
<p>Automatically calculated metric: actual results ÷ baseline expectations. Cannot be manually configured.</p>
|
||||
|
||||
<h6>How accurate are projections?</h6>
|
||||
<p>Based on industry benchmarks and historical data, but actual results may vary with market conditions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-5 mb-4">
|
||||
<a href="{% url 'services:csp_roi_calculator' %}" class="btn btn-success btn-lg me-3">
|
||||
<i class="bi bi-calculator"></i> Start Calculating Your ROI
|
||||
</a>
|
||||
<a href="{% url 'services:csp_roi_calculator' %}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Calculator
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
{% load static %}
|
||||
{% load contact_tags %}
|
||||
|
||||
{% block title %}About The Sovereign App Store{% endblock %}
|
||||
{% block title %}About Open Cloud Native Services Hub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section bg-primary-subtle">
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
<header class="section-primary__header text-center">
|
||||
<h1 class="section-h1 fs-40 fs-lg-64 mb-24">About Servala</h1>
|
||||
<div class="text-gray-300 w-lg-37 mx-auto">
|
||||
<p class="mb-0">The Sovereign App Store. Unlock the Power of Sovereign Managed Applications.</p>
|
||||
<p class="mb-0">Open Cloud Native Service Hub. Unlock the Power of Cloud Native Applications.</p>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
|
@ -238,7 +238,7 @@
|
|||
</div>
|
||||
<div class="w-lg-30">
|
||||
<div class="page-header__image-wrapper">
|
||||
<img class="page-header__image" src="{% static "img/sir-vala-text.png" %}" alt="Cartoon serval cat named Sir Vala with blue eyes and a happy expression. It's the mascot of Servala - The Sovereign App Store" title="Sir Vala - Mascot of Servala">
|
||||
<img class="page-header__image" src="{% static "img/sir-vala-text.png" %}" alt="Cartoon serval cat named Sir Vala with blue eyes and a happy expression. It's the mascot of Servala - Open Cloud Native Service Hub" title="Sir Vala - Mascot of Servala">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -251,4 +251,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}The Sovereign App Store{% endblock %}
|
||||
{% block title %}Open Cloud Native Services Hub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section section-hero bg-primary-subtle">
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
<div class="section-hero-mask"></div>
|
||||
<div class="px-3 px-lg-0 pt-80 pb-120 position-relative">
|
||||
<header class="section-hero__header">
|
||||
<h1 class="section-h1 fs-40 fs-lg-64">Sovereign App Store</h1>
|
||||
<h1 class="section-h1 fs-40 fs-lg-64">Servala - Open Cloud Native Service Hub</h1>
|
||||
<div class="section-hero__desc">
|
||||
<p>Unlock the Power of Sovereign Managed Applications.</p>
|
||||
<p>Unlock the Power of Cloud Native Applications.</p>
|
||||
<p>Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.</p>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -41,22 +41,16 @@
|
|||
<div class="row">
|
||||
{% for service in featured_services %}
|
||||
<div class="col-12 col-md-6 col-lg-3 mb-20 mb-lg-0">
|
||||
<div class="card h-100 d-flex flex-column clickable-card"
|
||||
<div class="card h-100 d-flex flex-column clickable-card"
|
||||
onclick="cardClicked(event, '{{ service.get_absolute_url }}')">
|
||||
<div class="card__content d-flex flex-column flex-grow-1">
|
||||
<div class="card__header">
|
||||
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
|
||||
<div class="me-3 d-flex align-items-center" style="height: 100%;">
|
||||
<a href="{{ service.get_absolute_url }}" class="clickable-link">
|
||||
{% if service.get_logo %}
|
||||
<img src="{{ service.get_logo.url }}"
|
||||
alt="{{ service.name }}"
|
||||
<img src="{{ service.logo.url }}"
|
||||
alt="{{ service.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
{% else %}
|
||||
<div class="text-muted" style="height: 100px; width: 250px; display: flex; align-items: center; justify-content: center; border: 1px solid #dee2e6; border-radius: 0.375rem;">
|
||||
{{ service.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -104,20 +98,18 @@
|
|||
<div class="row">
|
||||
{% for provider in featured_providers %}
|
||||
<div class="col-12 col-md-6 col-lg-3 mb-20 mb-lg-0">
|
||||
<div class="card h-100 d-flex flex-column clickable-card"
|
||||
<div class="card h-100 d-flex flex-column clickable-card"
|
||||
onclick="cardClicked(event, '{{ provider.get_absolute_url }}')">
|
||||
<div class="card__content d-flex flex-column flex-grow-1">
|
||||
<div class="card__header">
|
||||
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
|
||||
{% if provider.get_logo %}
|
||||
<div class="me-3 d-flex align-items-center" style="height: 100%;">
|
||||
<a href="{{ provider.get_absolute_url }}" class="clickable-link">
|
||||
<img src="{{ provider.get_logo.url }}"
|
||||
alt="{{ provider.name }}"
|
||||
<img src="{{ provider.logo.url }}"
|
||||
alt="{{ provider.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3 class="card__title">
|
||||
<a href="{{ provider.get_absolute_url }}" class="text-decoration-none clickable-link">{{ provider.name }}</a>
|
||||
|
|
@ -147,9 +139,9 @@
|
|||
<div class="">
|
||||
<header class="section__header w-100 d-flex justify-content-between align-items-center">
|
||||
<div class="section__header-text">
|
||||
<h2 class="section__header-h2">Partners</h2>
|
||||
<h2 class="section__header-h2">Consulting Partners</h2>
|
||||
<div class="section__desc">
|
||||
<p>Explore all available Partners on Servala, with new ones added regularly.</p>
|
||||
<p>Explore all available Consulting Partners on Servala, with new ones added regularly.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-lg-block">
|
||||
|
|
@ -160,20 +152,18 @@
|
|||
<div class="row">
|
||||
{% for partner in featured_partners %}
|
||||
<div class="col-12 col-md-6 col-lg-3 mb-20 mb-lg-0">
|
||||
<div class="card h-100 d-flex flex-column clickable-card"
|
||||
<div class="card h-100 d-flex flex-column clickable-card"
|
||||
onclick="cardClicked(event, '{{ partner.get_absolute_url }}')">
|
||||
<div class="card__content d-flex flex-column flex-grow-1">
|
||||
<div class="card__header">
|
||||
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
|
||||
{% if partner.get_logo %}
|
||||
<div class="me-3 d-flex align-items-center" style="height: 100%;">
|
||||
<a href="{{ partner.get_absolute_url }}" class="clickable-link">
|
||||
<img src="{{ partner.get_logo.url }}"
|
||||
alt="{{ partner.name }}"
|
||||
<img src="{{ partner.logo.url }}"
|
||||
alt="{{ partner.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3 class="card__title">
|
||||
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none clickable-link">{{ partner.name }}</a>
|
||||
|
|
@ -208,7 +198,7 @@
|
|||
</div>
|
||||
<div class="col-12 col-lg-8">
|
||||
<header class="section-primary__header">
|
||||
<h2 class="section-h1 fs-40 fs-lg-60">Servala - The Sovereign App Store</h2>
|
||||
<h2 class="section-h1 fs-40 fs-lg-60">Servala - Open Cloud Native Service Hub</h2>
|
||||
<div class="section-primary__desc">
|
||||
<p>Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.</p>
|
||||
<p>Discover:</p>
|
||||
|
|
@ -216,7 +206,7 @@
|
|||
<div>
|
||||
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:service_list' %}" role="button">Services</a>
|
||||
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:provider_list' %}" role="button">Cloud Providers</a>
|
||||
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:partner_list' %}" role="button">Partners</a>
|
||||
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:partner_list' %}" role="button">Consulting Partners</a>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
|
@ -257,4 +247,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
@ -6,11 +6,6 @@
|
|||
{% block meta_description %}{{ article.excerpt }}{% endblock %}
|
||||
{% block meta_keywords %}{{ article.meta_keywords }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- RSS Feed -->
|
||||
<link rel="alternate" type="application/rss+xml" title="Servala Articles RSS Feed" href="{% url 'services:article_rss' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section bg-primary-subtle">
|
||||
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
|
||||
|
|
@ -21,13 +16,25 @@
|
|||
<div class="d-flex justify-content-center align-items-center gap-3 text-sm">
|
||||
<span>By {{ article.author.get_full_name|default:article.author.username }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ article.article_date|date:"M d, Y" }}</span>
|
||||
<span>{{ article.created_at|date:"M d, Y" }}</span>
|
||||
{% if article.updated_at != article.created_at %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if article.image %}
|
||||
<section class="section py-0">
|
||||
<div class="container-xl mx-auto">
|
||||
<div class="article-hero-image">
|
||||
<img src="{{ article.image.url }}" alt="{{ article.title }}" class="img-fluid w-100" style="max-height: 400px; object-fit: cover;">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="section">
|
||||
<div class="container-xl mx-auto px-3 px-lg-0 pt-60 pt-lg-80 pb-40">
|
||||
<div class="row">
|
||||
|
|
@ -46,9 +53,9 @@
|
|||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Service</h5>
|
||||
{% if article.related_service.get_logo %}
|
||||
{% if article.related_service.logo %}
|
||||
<div class="mb-3 d-flex" style="height: 60px;">
|
||||
<img src="{{ article.related_service.get_logo.url }}" alt="{{ article.related_service.name }} logo"
|
||||
<img src="{{ article.related_service.logo.url }}" alt="{{ article.related_service.name }} logo"
|
||||
class="img-fluid" style="max-height: 50px; object-fit: contain;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -63,16 +70,13 @@
|
|||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Partner</h5>
|
||||
{% if article.related_consulting_partner.get_logo %}
|
||||
{% if article.related_consulting_partner.logo %}
|
||||
<div class="mb-3 d-flex" style="height: 60px;">
|
||||
<img src="{{ article.related_consulting_partner.get_logo.url }}" alt="{{ article.related_consulting_partner.name }} logo"
|
||||
<img src="{{ article.related_consulting_partner.logo.url }}" alt="{{ article.related_consulting_partner.name }} logo"
|
||||
class="img-fluid" style="max-height: 50px; object-fit: contain;">
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="card-text">{{ article.related_consulting_partner.name }}</p>
|
||||
<div class="mb-2">
|
||||
<span class="badge bg-primary">{{ article.related_consulting_partner.get_category_display_badge }}</span>
|
||||
</div>
|
||||
<a href="{{ article.related_consulting_partner.get_absolute_url }}" class="btn btn-primary btn-sm">View Partner</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -83,9 +87,9 @@
|
|||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Provider</h5>
|
||||
{% if article.related_cloud_provider.get_logo %}
|
||||
{% if article.related_cloud_provider.logo %}
|
||||
<div class="mb-3 d-flex" style="height: 60px;">
|
||||
<img src="{{ article.related_cloud_provider.get_logo.url }}" alt="{{ article.related_cloud_provider.name }} logo"
|
||||
<img src="{{ article.related_cloud_provider.logo.url }}" alt="{{ article.related_cloud_provider.name }} logo"
|
||||
class="img-fluid" style="max-height: 50px; object-fit: contain;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -105,32 +109,15 @@
|
|||
<h3>Related Articles</h3>
|
||||
<div class="row">
|
||||
{% for related_article in related_articles %}
|
||||
<div class="col-12 col-md-4 mb-30">
|
||||
<div class="card h-100 d-flex flex-column clickable-card" onclick="cardClicked(event, '{{ related_article.get_absolute_url }}')">
|
||||
{% if related_article.get_image %}
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<div class="card__image flex-shrink-0">
|
||||
<img src="{{ related_article.get_image.url }}" alt="{{ related_article.title }}" class="img-fluid">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="card h-100 clickable-card" onclick="cardClicked(event, '{{ related_article.get_absolute_url }}')">
|
||||
{% if related_article.image %}
|
||||
<img src="{{ related_article.image.url }}" class="card-img-top mb-2" alt="{{ related_article.title }}" style="height: 200px; object-fit: cover;">
|
||||
{% endif %}
|
||||
<div class="card__content d-flex flex-column flex-grow-1">
|
||||
<div class="card__header">
|
||||
<h3 class="card__title">
|
||||
{{ related_article.title }}
|
||||
</h3>
|
||||
<p class="card__subtitle">
|
||||
<span class="text-muted">
|
||||
By {{ related_article.author.get_full_name|default:related_article.author.username }}
|
||||
</span>
|
||||
<span class="text-muted ms-2">
|
||||
{{ related_article.article_date|date:"M d, Y" }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="card__desc flex-grow-1">
|
||||
<p class="mb-0">{{ related_article.excerpt|truncatewords:15 }}</p>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title">{{ related_article.title }}</h5>
|
||||
<p class="card-text flex-grow-1">{{ related_article.excerpt|truncatewords:15 }}</p>
|
||||
<small class="text-muted">{{ related_article.created_at|date:"M d, Y" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,29 +5,13 @@
|
|||
{% block title %}Articles{% endblock %}
|
||||
{% block meta_description %}Explore all articles on Servala, covering cloud services, consulting partners, and cloud provider insights.{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- RSS Feed -->
|
||||
<link rel="alternate" type="application/rss+xml" title="Servala Articles RSS Feed" href="{% url 'services:article_rss' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section bg-primary-subtle">
|
||||
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
|
||||
<header class="section-primary__header text-center">
|
||||
<h1 class="section-h1 fs-40 fs-lg-64 mb-24">Articles</h1>
|
||||
<div class="text-gray-300 w-lg-37 mx-auto">
|
||||
<p class="mb-3">Discover insights, guides, and updates about cloud services, consulting partners, and technology trends.</p>
|
||||
<!-- RSS Feed Link -->
|
||||
<div class="mb-0">
|
||||
<a href="{% url 'services:article_rss' %}" class="btn btn-outline-light btn-sm" title="Subscribe to RSS Feed">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="me-2">
|
||||
<path d="M4 11a9 9 0 0 1 9 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 4a16 16 0 0 1 16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="5" cy="19" r="1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Subscribe to RSS Feed
|
||||
</a>
|
||||
</div>
|
||||
<p class="mb-0">Discover insights, guides, and updates about cloud services, consulting partners, and technology trends.</p>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
|
@ -161,11 +145,11 @@
|
|||
<div class="col-12 col-md-6 col-lg-4 mb-30">
|
||||
<div class="card {% if article.is_featured %}card-featured{% endif %} h-100 d-flex flex-column clickable-card"
|
||||
onclick="cardClicked(event, '{{ article.get_absolute_url }}')">
|
||||
{% if article.get_image or article.is_featured %}
|
||||
{% if article.image or article.is_featured %}
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
{% if article.get_image %}
|
||||
{% if article.image %}
|
||||
<div class="card__image flex-shrink-0">
|
||||
<img src="{{ article.get_image.url }}" alt="{{ article.title }}" class="img-fluid">
|
||||
<img src="{{ article.image.url }}" alt="{{ article.title }}" class="img-fluid">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if article.is_featured %}
|
||||
|
|
@ -185,7 +169,7 @@
|
|||
By {{ article.author.get_full_name|default:article.author.username }}
|
||||
</span>
|
||||
<span class="text-muted ms-2">
|
||||
{{ article.article_date|date:"M d, Y" }}
|
||||
{{ article.created_at|date:"M d, Y" }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@
|
|||
<div class="w-lg-34 bg-purple-50 rounded-16 p-24 d-flex flex-column">
|
||||
<div class="d-flex align-items-center mb-24">
|
||||
<div class="card__image mb-0">
|
||||
{% if selected_offering.service.get_logo %}
|
||||
<img class="img-fluid" src="{{ selected_offering.service.get_logo.url }}" alt="Service Logo">
|
||||
{% if selected_offering.service.logo %}
|
||||
<img class="img-fluid" src="{{ selected_offering.service.logo.url }}" alt="Service Logo">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__header ps-16">
|
||||
|
|
|
|||
|
|
@ -1,104 +1,12 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
{% load contact_tags %}
|
||||
{% load json_ld_tags %}
|
||||
|
||||
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if debug %}
|
||||
<!-- Development: Load individual modules for easier debugging -->
|
||||
<script src="{% static 'js/price-calculator/dom-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/pricing-data-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/plan-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/addon-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/ui-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/order-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/price-calculator.js' %}"></script>
|
||||
<script>
|
||||
// Initialize calculator when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Check if we're on a page that needs the price calculator
|
||||
if (document.getElementById('cpuRange')) {
|
||||
try {
|
||||
window.priceCalculator = new PriceCalculator();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize price calculator:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Global function for traditional plan selection (used by template buttons)
|
||||
function selectPlan(element) {
|
||||
if (!element) return;
|
||||
|
||||
const planId = element.getAttribute('data-plan-id');
|
||||
const planName = element.getAttribute('data-plan-name');
|
||||
|
||||
// Find the plan dropdown in the contact form
|
||||
const planDropdown = document.getElementById('id_choice');
|
||||
if (planDropdown) {
|
||||
// Find the option with matching plan id and select it
|
||||
for (let i = 0; i < planDropdown.options.length; i++) {
|
||||
const optionValue = planDropdown.options[i].value;
|
||||
if (optionValue.startsWith(planId + '|')) {
|
||||
planDropdown.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
<!-- Production: Load compressed bundle -->
|
||||
{% compress js %}
|
||||
<script src="{% static 'js/price-calculator/dom-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/pricing-data-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/plan-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/addon-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/ui-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/order-manager.js' %}"></script>
|
||||
<script src="{% static 'js/price-calculator/price-calculator.js' %}"></script>
|
||||
<script>
|
||||
// Initialize calculator when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Check if we're on a page that needs the price calculator
|
||||
if (document.getElementById('cpuRange')) {
|
||||
try {
|
||||
window.priceCalculator = new PriceCalculator();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize price calculator:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Global function for traditional plan selection (used by template buttons)
|
||||
function selectPlan(element) {
|
||||
if (!element) return;
|
||||
|
||||
const planId = element.getAttribute('data-plan-id');
|
||||
const planName = element.getAttribute('data-plan-name');
|
||||
|
||||
// Find the plan dropdown in the contact form
|
||||
const planDropdown = document.getElementById('id_choice');
|
||||
if (planDropdown) {
|
||||
// Find the option with matching plan id and select it
|
||||
for (let i = 0; i < planDropdown.options.length; i++) {
|
||||
const optionValue = planDropdown.options[i].value;
|
||||
if (optionValue.startsWith(planId + '|')) {
|
||||
planDropdown.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endcompress %}
|
||||
{% endif %}
|
||||
<script defer src="{% static "js/price-calculator.js" %}"></script>
|
||||
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
|
||||
|
||||
{% json_ld_structured_data %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
@ -121,9 +29,9 @@ function selectPlan(element) {
|
|||
<div class="pr-lg-6">
|
||||
<!-- Logo -->
|
||||
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
|
||||
{% if offering.service.get_logo %}
|
||||
{% if offering.service.logo %}
|
||||
<a href="{{ offering.service.get_absolute_url }}">
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.get_logo.url }}"
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.logo.url }}"
|
||||
alt="{{ offering.service.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
</a>
|
||||
{% endif %}
|
||||
|
|
@ -139,7 +47,7 @@ function selectPlan(element) {
|
|||
<div class="mb-40">
|
||||
<h3 class="fw-semibold mb-12">Runs on</h3>
|
||||
<a href="{{ offering.cloud_provider.get_absolute_url }}">
|
||||
<img class="img-fluid" src="{{ offering.cloud_provider.get_logo.url }}" alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;">
|
||||
<img class="img-fluid" src="{{ offering.cloud_provider.logo.url }}" alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
|
@ -296,317 +204,65 @@ function selectPlan(element) {
|
|||
<!-- Price Calculator -->
|
||||
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
|
||||
{% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %}
|
||||
<!-- Interactive Price Calculator -->
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
|
||||
<div class="bg-light rounded-4 p-4 mb-4">
|
||||
<div class="row">
|
||||
<!-- Calculator Controls -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<!-- CPU Slider -->
|
||||
<div class="mb-4">
|
||||
<label for="cpuRange" class="form-label d-flex justify-content-between">
|
||||
<span>vCPUs</span>
|
||||
<span class="fw-bold" id="cpuValue">0.5</span>
|
||||
</label>
|
||||
<input type="range" class="form-range" id="cpuRange" min="0.25" max="32" value="0.5" step="0.25">
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span id="cpuMinDisplay">0.25</span>
|
||||
<span id="cpuMaxDisplay">32</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Slider -->
|
||||
<div class="mb-4">
|
||||
<label for="memoryRange" class="form-label d-flex justify-content-between">
|
||||
<span>Memory (GB)</span>
|
||||
<span class="fw-bold" id="memoryValue">1</span>
|
||||
</label>
|
||||
<input type="range" class="form-range" id="memoryRange" min="0.25" max="128" value="1" step="0.25">
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span id="memoryMinDisplay">0.25 GB</span>
|
||||
<span id="memoryMaxDisplay">128 GB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Slider -->
|
||||
<div class="mb-4">
|
||||
<label for="storageRange" class="form-label d-flex justify-content-between">
|
||||
<span>Storage (GB)</span>
|
||||
<span class="fw-bold" id="storageValue">20</span>
|
||||
</label>
|
||||
<input type="range" class="form-range" id="storageRange" min="10" max="1000" value="20" step="10">
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span id="storageMinDisplay">10 GB</span>
|
||||
<span id="storageMaxDisplay">1000 GB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replicas Slider -->
|
||||
<div class="mb-4">
|
||||
<label for="instancesRange" class="form-label d-flex justify-content-between">
|
||||
<span>Replicas</span>
|
||||
<span class="fw-bold" id="instancesValue">1</span>
|
||||
</label>
|
||||
<input type="range" class="form-range" id="instancesRange" min="1" max="1" value="1" step="1">
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span id="instancesMinDisplay">1</span>
|
||||
<span id="instancesMaxDisplay">1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Level Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Service Level</label>
|
||||
<div class="btn-group w-100" role="group" id="serviceLevelGroup">
|
||||
<input type="radio" class="btn-check" name="serviceLevel" id="serviceLevelBestEffort" value="Best Effort" checked>
|
||||
<label class="btn btn-outline-primary" for="serviceLevelBestEffort">Best Effort</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="serviceLevel" id="serviceLevelGuaranteed" value="Guaranteed Availability">
|
||||
<label class="btn btn-outline-primary" for="serviceLevelGuaranteed">Guaranteed Availability</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Addons Section - Hidden by default, shown by JS if addons exist -->
|
||||
<div class="mb-4" id="addonsSection" style="display: none;">
|
||||
<label class="form-label">Add-ons</label>
|
||||
<div id="addonsContainer">
|
||||
<!-- Add-ons will be dynamically populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direct Plan Selection -->
|
||||
<div class="mb-4">
|
||||
<label for="planSelect" class="form-label">Or choose a specific plan</label>
|
||||
<select class="form-select" id="planSelect">
|
||||
<option value="">Auto-select best matching plan</option>
|
||||
</select>
|
||||
<p><small class="form-text text-muted">Selecting a plan will override the slider configuration</small></p>
|
||||
<p><small class="form-text text-muted"><i class="bi bi-info-circle me-1"></i> Interested in a custom plan? Let us know via the <a href="#form">contact form</a>.</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Panel -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 border-primary">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-primary mb-4">Your Plan</h5>
|
||||
|
||||
<!-- Plan Match Status -->
|
||||
<div id="planMatchStatus" class="alert alert-info mb-3">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<span>Finding best matching plan...</span>
|
||||
</div>
|
||||
|
||||
<!-- Selected Plan Details -->
|
||||
<div id="selectedPlanDetails" style="display: none;">
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="badge me-2" id="planGroup"></span>
|
||||
<strong id="planName"></strong>
|
||||
</div>
|
||||
<small class="text-muted" id="planDescription"></small>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-3">
|
||||
<small class="text-muted">vCPUs</small>
|
||||
<div class="fw-bold" id="planCpus"></div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<small class="text-muted">Memory</small>
|
||||
<div class="fw-bold" id="planMemory"></div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<small class="text-muted">Replicas</small>
|
||||
<div class="fw-bold" id="planInstances"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<small class="text-muted">Service Level</small>
|
||||
<div class="fw-bold">
|
||||
<a href="https://products.vshn.ch/service_levels.html" target="_blank" class="text-decoration-none" id="planServiceLevel"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Breakdown -->
|
||||
<div class="border-top pt-3">
|
||||
<!-- Managed Service Section -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="d-flex align-items-center flex-shrink-1" style="min-width: 0; max-width: calc(100% - 120px);">
|
||||
<span class="text-nowrap me-1">Managed Service</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted flex-shrink-0" type="button" data-bs-toggle="collapse" data-bs-target="#managedServiceIncludes" aria-expanded="false" aria-controls="managedServiceIncludes" title="Show what's included" id="managedServiceToggleButton">
|
||||
<i class="bi bi-info-circle" id="managedServiceToggleIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="fw-bold text-nowrap flex-shrink-0" style="min-width: 110px; text-align: right;">CHF <span id="managedServicePrice">0.00</span></span>
|
||||
</div>
|
||||
|
||||
<!-- What's included in managed service (collapsible) -->
|
||||
<div class="collapse" id="managedServiceIncludes">
|
||||
<div class="ps-3 border-start border-2 border-subtle">
|
||||
<div id="managedServiceIncludesContainer">
|
||||
<!-- Required add-ons will be dynamically added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage - separate billable item -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="text-nowrap flex-shrink-1" style="min-width: 0;">Storage - <span id="storageAmount">20</span> GB</span>
|
||||
<span class="fw-bold text-nowrap flex-shrink-0" style="min-width: 110px; text-align: right;">CHF <span id="storagePrice">0.00</span></span>
|
||||
</div>
|
||||
|
||||
<!-- Optional Addons Pricing -->
|
||||
<div id="addonPricingContainer">
|
||||
<!-- Optional addon pricing will be dynamically added here -->
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="fs-5 fw-bold text-nowrap flex-shrink-1" style="min-width: 0;">Total Monthly Price</span>
|
||||
<span class="fs-4 fw-bold text-primary text-nowrap flex-shrink-0" style="min-width: 120px; text-align: right;">CHF <span id="totalPrice">0.00</span></span>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Monthly pricing based on 30 days (720 hours). Metering is conducted per hour. Introductory pricing subject to change.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Match Found -->
|
||||
<div id="noMatchFound" style="display: none;" class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
No matching plan found for your requirements. Please adjust your configuration.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans & Pricing</h3>
|
||||
<div class="mb-3">
|
||||
<label for="currencySelect" class="form-label">Select Currency:</label>
|
||||
<select id="currencySelect" class="form-select w-auto d-inline-block">
|
||||
<option value="CHF">CHF</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Order Button -->
|
||||
<div class="text-center mt-4">
|
||||
<a href="#order-form" class="btn btn-primary btn-lg px-5 py-3 fw-semibold">
|
||||
<i class="bi bi-cart me-2"></i>Order This Configuration
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Order Form Section -->
|
||||
<div id="order-form" class="pt-40" style="scroll-margin-top: 30px;">
|
||||
<h4 class="fs-22 fw-semibold lh-1 mb-12">Order Your Configuration</h4>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% embedded_contact_form source="Configuration Order" service=offering.service offering_id=offering.id %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered" id="plansTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plan Name</th>
|
||||
<th>Description</th>
|
||||
<th>Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Plan rows will be populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif offering.plans.all %}
|
||||
<!-- Traditional Plans -->
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
|
||||
<div class="bg-light rounded-4 p-4 mb-4">
|
||||
<div class="row">
|
||||
{% for plan in offering.plans.all %}
|
||||
<div class="col-12 {% if offering.plans.all|length == 1 %}col-lg-8 mx-auto{% elif offering.plans.all|length == 2 %}col-lg-6{% else %}col-lg-4{% endif %} mb-4">
|
||||
<div class="card h-100 {% if plan.is_best %}border-success border-2 shadow-sm{% else %}border-primary shadow-sm{% endif %} position-relative">
|
||||
{% if plan.is_best %}
|
||||
<!-- Best Plan Badge -->
|
||||
<div class="position-absolute top-0 start-50 translate-middle" style="z-index: 10;">
|
||||
<span class="badge bg-success px-3 py-2 fs-6 fw-bold shadow-sm text-nowrap">
|
||||
<i class="bi bi-star-fill me-1"></i>Best Choice
|
||||
</span>
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans</h3>
|
||||
<div class="row">
|
||||
{% for plan in offering.plans.all %}
|
||||
<div class="col-12 col-lg-6 {% if not forloop.last %}mb-20 mb-lg-0{% endif %}">
|
||||
<div class="bg-purple-50 rounded-16 border-all p-24">
|
||||
<div class="bg-white border-all rounded-7 p-20 mb-20">
|
||||
<h3 class="text-purple fs-22 fw-semibold lh-1-7 mb-0">{{ plan.name }}</h3>
|
||||
{% if plan.plan_description %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.plan_description.text|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body pt-3 d-flex flex-column">
|
||||
<h5 class="card-title {% if plan.is_best %}text-success{% else %}text-primary{% endif %} mb-3 fw-bold">
|
||||
<i class="bi bi-{% if plan.is_best %}award{% else %}box{% endif %} me-2"></i>{{ plan.name }}
|
||||
</h5>
|
||||
|
||||
<!-- Plan Description -->
|
||||
{% if plan.plan_description %}
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Description</small>
|
||||
<div class="text-dark">
|
||||
{{ plan.plan_description.text|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if plan.description %}
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Details</small>
|
||||
<div class="text-dark">
|
||||
{{ plan.description|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pricing Information -->
|
||||
{% if plan.plan_prices.exists %}
|
||||
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex flex-column">
|
||||
<div class="mb-2">
|
||||
<small class="{% if plan.is_best %}text-success fw-semibold{% else %}text-muted{% endif %}">Pricing</small>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Monthly Price</span>
|
||||
<div class="text-end">
|
||||
{% for price in plan.plan_prices.all %}
|
||||
<div class="fs-5 fw-bold {% if plan.is_best %}text-success{% else %}text-primary{% endif %}">{{ price.currency }} {{ price.amount }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Prices exclude VAT. Monthly pricing based on 30 days.
|
||||
</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex align-items-center justify-content-center">
|
||||
<div class="text-center text-muted">
|
||||
<i class="bi bi-envelope me-2"></i>Contact us for pricing details
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Plan Action Button -->
|
||||
<div class="text-center mt-auto pt-3">
|
||||
<a href="#plan-order-form" class="btn {% if plan.is_best %}btn-success btn-lg px-4 py-2 shadow{% else %}btn-primary btn-lg px-4 py-2{% endif %} fw-semibold w-100" data-plan-id="{{ plan.id }}" data-plan-name="{{ plan.name }}" onclick="selectPlan(this)">
|
||||
<i class="bi bi-{% if plan.is_best %}star-fill{% else %}cart{% endif %} me-2"></i>Select Plan
|
||||
</a>
|
||||
</div>
|
||||
{% if plan.description %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.description|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plan.pricing %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.pricing|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||
<div class="alert alert-info">
|
||||
<p>No plans available yet.</p>
|
||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan Order Forms -->
|
||||
<div id="plan-order-form" class="pt-40" style="scroll-margin-top: 30px;">
|
||||
<h4 class="fs-22 fw-semibold lh-1 mb-12">Order Your Plan</h4>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
|
||||
{% empty %}
|
||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||
<div class="alert alert-info">
|
||||
<p>No plans available yet.</p>
|
||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- No Plans Available -->
|
||||
|
|
@ -616,6 +272,17 @@ function selectPlan(element) {
|
|||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if offering.plans.exists and not pricing_data_by_group_and_service_level %}
|
||||
<div id="form" class="pt-40">
|
||||
<h4 class="fs-22 fw-semibold lh-1 mb-12">I'm interested in a plan</h4>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -150,8 +150,8 @@
|
|||
<div class="card__header">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
{% if offering.service.get_logo %}
|
||||
<img src="{{ offering.service.get_logo.url }}"
|
||||
{% if offering.service.logo %}
|
||||
<img src="{{ offering.service.logo.url }}"
|
||||
alt="{{ offering.service.name }}"
|
||||
style="max-height: 50px; max-width: 100px; object-fit: contain;">
|
||||
{% endif %}
|
||||
|
|
@ -163,9 +163,9 @@
|
|||
</a>
|
||||
</h3>
|
||||
<div class="d-flex align-items-center">
|
||||
{% if offering.cloud_provider.get_logo %}
|
||||
{% if offering.cloud_provider.logo %}
|
||||
<a href="{{ offering.get_absolute_url }}" class="me-2">
|
||||
<img src="{{ offering.cloud_provider.get_logo.url }}"
|
||||
<img src="{{ offering.cloud_provider.logo.url }}"
|
||||
alt="{{ offering.cloud_provider.name }}"
|
||||
style="max-height: 30px; max-width: 100px; object-fit: contain;">
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@
|
|||
<div class="pr-lg-6">
|
||||
<!-- Logo -->
|
||||
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
|
||||
{% if partner.get_logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ partner.get_logo.url }}" alt="{{ partner.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% if partner.logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ partner.logo.url }}" alt="{{ partner.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
|
||||
{% if partner.address %}
|
||||
<li>
|
||||
<div class="d-flex align-items-start text-gray-500 lh-32">
|
||||
<div class="d-flex align-items-start text-gray-500 h-32 lh-32">
|
||||
<span class="pr-10 pt-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-geo-alt-fill" viewBox="0 0 16 16">
|
||||
<path d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10m0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6" fill="#9A63EC"/>
|
||||
|
|
@ -99,6 +99,27 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Providers -->
|
||||
{% if partner.cloud_providers.exists %}
|
||||
<div class="mb-40">
|
||||
<h3 class="fw-semibold mb-12">Cloud Providers</h3>
|
||||
<ul class="list-unstyled space-y-12 fs-19 ps-0">
|
||||
{% for provider in partner.cloud_providers.all %}
|
||||
<li>
|
||||
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ provider.get_absolute_url }}">
|
||||
<span class="pr-10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cloud-fill" viewBox="0 0 16 16">
|
||||
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383" fill="#9A63EC"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{{ provider.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Related Articles -->
|
||||
{% if related_articles %}
|
||||
<div class="mb-40">
|
||||
|
|
@ -132,7 +153,7 @@
|
|||
<h2 class="fs-50 fw-semibold lh-1 mb-12">{{ partner.name }}</h2>
|
||||
</header>
|
||||
<div class="fs-19 text-gray-500">
|
||||
<button class="btn btn-tertiary btn-sm mr-12">{{ partner.get_category_display_badge }}</button>
|
||||
<button class="btn btn-tertiary btn-sm mr-12">Servala Consulting Partner</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -147,28 +168,25 @@
|
|||
<!-- Services -->
|
||||
{% if services %}
|
||||
<div class="pt-40">
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12" id="services" style="scroll-margin-top: 100px;">
|
||||
{% if partner.category == 'TRAINING' %}
|
||||
Training for Services
|
||||
{% else %}
|
||||
Consulting for Services
|
||||
{% endif %}
|
||||
</h3>
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12" id="services" style="scroll-margin-top: 100px;">Consulting for Services</h3>
|
||||
<div class="row">
|
||||
{% for service in services %}
|
||||
<div class="col-12 col-md-6 mb-30">
|
||||
<div class="card h-100 d-flex flex-column clickable-card"
|
||||
onclick="cardClicked(event, '{{ service.get_absolute_url }}')">
|
||||
{% if service.get_logo %}
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<div class="card h-100 d-flex flex-column">
|
||||
{% if service.logo %}
|
||||
<div class="d-flex justify-content-between">
|
||||
{% if service.logo %}
|
||||
<div class="card__image flex-shrink-0">
|
||||
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
<a href="{{ service.get_absolute_url }}">
|
||||
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card__content d-flex flex-column flex-grow-1">
|
||||
<div class="card__header">
|
||||
<h3 class="card__title">{{ service.name }}</h3>
|
||||
<h3 class="card__title"><a href="{{ service.get_absolute_url }}" class="text-decoration-none">{{ service.name }}</a></h3>
|
||||
<p class="card__subtitle">
|
||||
{% for category in service.categories.all %}
|
||||
<span>{{ category.full_path }}</span>
|
||||
|
|
|
|||
|
|
@ -94,23 +94,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<div class="pt-24 mb-24">
|
||||
<div class="d-flex justify-content-between align-items-center h-33 mb-5px" role="button">
|
||||
<h3 class="sidebar-title mb-0">Category</h3>
|
||||
</div>
|
||||
<div>
|
||||
<select class="form-select" id="category" name="category" @change="submitForm()">
|
||||
<option value="">All Categories</option>
|
||||
{% for category_value, category_label in partner_categories %}
|
||||
<option value="{{ category_value }}" {% if request.GET.category == category_value %}selected{% endif %}>
|
||||
{{ category_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Actions -->
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'services:partner_list' %}" class="btn btn-outline-secondary btn-sm">Clear</a>
|
||||
|
|
@ -127,21 +110,17 @@
|
|||
<div class="col-12 col-md-6 col-lg-4 mb-30">
|
||||
<div class="card h-100 d-flex flex-column clickable-card"
|
||||
onclick="cardClicked(event, '{{ partner.get_absolute_url }}')">
|
||||
{% if partner.category %}
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<span class="btn btn-secondary btn-sm">{{ partner.get_category_display_badge }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card__content d-flex flex-column flex-grow-1">
|
||||
<div class="card__header">
|
||||
{% if partner.get_logo %}
|
||||
<div class="d-flex align-items-center justify-content-start" style="height: 80px; margin-bottom: 1rem; width: 100%;">
|
||||
<a href="{{ partner.get_absolute_url }}" class="clickable-link" style="display: block; width: 250px; height: 100px;">
|
||||
<img src="{{ partner.get_logo.url }}" alt="{{ partner.name }} logo"
|
||||
style="width: 100%; height: 100%; object-fit: contain; object-position: left center; display: block;">
|
||||
</a>
|
||||
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
|
||||
<div class="me-3">
|
||||
<a href="{{ partner.get_absolute_url }}" class="clickable-link">
|
||||
<img src="{{ partner.logo.url }}"
|
||||
alt="{{ partner.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h3 class="card__title">
|
||||
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none clickable-link">{{ partner.name }}</a>
|
||||
</h3>
|
||||
|
|
@ -156,9 +135,7 @@
|
|||
{% if partner.website %}
|
||||
<a href="{{ partner.website }}" class="btn btn-primary btn-sm clickable-button" target="_blank">Visit Website</a>
|
||||
{% endif %}
|
||||
<a href="{{ partner.get_absolute_url }}#services" class="btn btn-primary btn-sm clickable-button">
|
||||
{% if partner.category == 'TRAINING' %}Available Trainings{% else %}Available Services{% endif %}
|
||||
</a>
|
||||
<a href="{{ partner.get_absolute_url }}#services" class="btn btn-primary btn-sm clickable-button">Available Services</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
{% block title %}Complete Price List{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static "js/chart.umd.min.js" %}"></script>
|
||||
<script src="{% static "js/chart.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
|
|
@ -159,11 +159,65 @@
|
|||
<div class="col-12">
|
||||
<h1 class="mb-4">Complete Price List - All Service Variants</h1>
|
||||
|
||||
<!-- Pricing Model Explanation - Internal Product Manager View -->
|
||||
<!-- Pricing Model Explanation -->
|
||||
<div class="card mb-4">
|
||||
<a href="https://vshnwiki.atlassian.net/wiki/x/BQDYGg" target="_blank">See VSHN Wiki for a detailed explanation</a>
|
||||
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#pricingExplanation" aria-expanded="false" aria-controls="pricingExplanation" style="cursor: pointer;">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>How Our Pricing Works
|
||||
<small class="text-muted ms-2">(Click to expand)</small>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="collapse" id="pricingExplanation">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Price Components</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2">
|
||||
<span class="badge">Compute Plan Price</span>
|
||||
<span class="ms-2">Base infrastructure cost for CPU, memory, and storage</span>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<span class="badge">SLA Base</span>
|
||||
<span class="ms-2">Fixed cost for the service level agreement</span>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<span class="badge">Units × SLA Per Unit</span>
|
||||
<span class="ms-2">Variable cost based on scale/usage</span>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<span class="badge">Mandatory Add-ons</span>
|
||||
<span class="ms-2">Required additional services (backup, monitoring, etc.)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Final Price Formula</h6>
|
||||
<div class="bg-light p-3 rounded">
|
||||
<code>
|
||||
<span style="color: #0d6efd;">Compute Plan Price</span> +
|
||||
<span style="color: #6f42c1;">SLA Base</span> +
|
||||
<span style="color: #fd7e14;">(Units × SLA Per Unit)</span> +
|
||||
<span style="color: #dc3545;">Mandatory Add-ons</span> =
|
||||
<strong style="color: #198754;">Final Price</strong>
|
||||
</code>
|
||||
</div>
|
||||
<p class="mt-3 mb-0">
|
||||
<small class="text-muted">
|
||||
This transparent pricing model ensures you understand exactly what you're paying for.
|
||||
The table below breaks down each component for every service variant we offer.
|
||||
<br><br>
|
||||
<strong>Price Comparisons:</strong> When enabled, you'll see:
|
||||
<br>• <span class="badge bg-secondary">External Providers</span> - Competitor prices from AWS, Google Cloud, etc.
|
||||
<br>• <span class="badge bg-success">Other Servala Providers</span> - Same service specs on different cloud providers within our network
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Filter Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
|
|
@ -172,18 +226,16 @@
|
|||
<div class="card-body">
|
||||
<form method="get" class="row g-3" id="filter-form">
|
||||
<div class="col-md-3">
|
||||
<label for="cloud_provider" class="form-label">Cloud Provider <span class="text-danger">*</span></label>
|
||||
<label for="cloud_provider" class="form-label">Cloud Provider</label>
|
||||
<select name="cloud_provider" id="cloud_provider" class="form-select filter-select">
|
||||
<option value="">-- Select Cloud Provider --</option>
|
||||
{% for provider in all_cloud_providers %}
|
||||
<option value="{{ provider }}" {% if provider == filter_cloud_provider %}selected{% endif %}>{{ provider }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="service" class="form-label">Service <span class="text-danger">*</span></label>
|
||||
<label for="service" class="form-label">Service</label>
|
||||
<select name="service" id="service" class="form-select filter-select">
|
||||
<option value="">-- Select Service --</option>
|
||||
{% for service in all_services %}
|
||||
<option value="{{ service }}" {% if service == filter_service %}selected{% endif %}>{{ service }}</option>
|
||||
{% endfor %}
|
||||
|
|
@ -243,17 +295,16 @@
|
|||
{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level or show_discount_details or show_addon_details or show_price_comparison %}
|
||||
<div class="alert alert-info">
|
||||
<strong>Active Filters:</strong>
|
||||
<ul class="mt-2 mb-4">
|
||||
{% if filter_cloud_provider %}<li>Cloud Provider: {{ filter_cloud_provider }}</li>{% endif %}
|
||||
{% if filter_service %}<li>Service: {{ filter_service }}</li>{% endif %}
|
||||
{% if filter_compute_plan_group %}<li>Group: {{ filter_compute_plan_group }}</li>{% endif %}
|
||||
{% if filter_service_level %}<li>Service Level: {{ filter_service_level }}</li>{% endif %}
|
||||
{% if show_discount_details %}<li>Discount Details</li>{% endif %}
|
||||
{% if show_addon_details %}<li>Addon Details</li>{% endif %}
|
||||
{% if show_price_comparison %}<li>Price Comparisons</li>{% endif %}
|
||||
</ul>
|
||||
{% if filter_cloud_provider %}<span class="badge me-1">Cloud Provider: {{ filter_cloud_provider }}</span>{% endif %}
|
||||
{% if filter_service %}<span class="badge me-1">Service: {{ filter_service }}</span>{% endif %}
|
||||
{% if filter_compute_plan_group %}<span class="badge me-1">Group: {{ filter_compute_plan_group }}</span>{% endif %}
|
||||
{% if filter_service_level %}<span class="badge me-1">Service Level: {{ filter_service_level }}</span>{% endif %}
|
||||
{% if show_discount_details %}<span class="badge me-1">Discount Details</span>{% endif %}
|
||||
{% if show_addon_details %}<span class="badge me-1">Addon Details</span>{% endif %}
|
||||
{% if show_price_comparison %}<span class="badge me-1">Price Comparisons</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if pricing_data_by_group_and_service_level %}
|
||||
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
|
||||
<div class="mb-5 border rounded p-3">
|
||||
|
|
@ -262,11 +313,18 @@
|
|||
{# Display group description and node_label from first available plan #}
|
||||
{% for service_level, pricing_data in service_levels.items %}
|
||||
{% if pricing_data and forloop.first %}
|
||||
{% with pricing_data.0 as representative_plan %}
|
||||
{% with pricing_data.0 as representative_plan %}
|
||||
{% if representative_plan.compute_plan_group_description %}
|
||||
<p class="text-muted mb-2"><strong>Description:</strong> {{ representative_plan.compute_plan_group_description }}</p>
|
||||
{% endif %}
|
||||
{% if representative_plan.compute_plan_group_node_label %}
|
||||
<p class="text-muted mb-3"><strong>Node Label:</strong> <code>{{ representative_plan.compute_plan_group_node_label }}</code></p>
|
||||
{% endif %}
|
||||
|
||||
{# Display storage pricing for this cloud provider #}
|
||||
{% if representative_plan.storage_plans %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted mb-2"><strong>Storage Options</strong></p>
|
||||
<p class="text-muted mb-2"><strong>Storage Options:</strong></p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-secondary">
|
||||
|
|
@ -307,19 +365,30 @@
|
|||
{% if pricing_data %}
|
||||
{# Display common values for this service level #}
|
||||
{% with pricing_data.0 as first_row %}
|
||||
<div class="mb-3">
|
||||
<ul class="list-unstyled">
|
||||
<li><strong>Cloud Provider:</strong> {{ first_row.cloud_provider }}</li>
|
||||
<li><strong>Service:</strong> {{ first_row.service }}</li>
|
||||
<li><strong>CPU/Memory Ratio:</strong> {{ first_row.cpu_mem_ratio }}</li>
|
||||
<li><strong>Variable Unit:</strong> {{ first_row.variable_unit }}</li>
|
||||
<li><strong>Replica Enforce:</strong> {{ first_row.replica_enforce }}</li>
|
||||
</ul>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-2">
|
||||
<strong>Cloud Provider:</strong> {{ first_row.cloud_provider }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<strong>Service:</strong> {{ first_row.service }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<strong>CPU/Memory Ratio:</strong> {{ first_row.cpu_mem_ratio }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<strong>Variable Unit:</strong> {{ first_row.variable_unit }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<strong>Replica Enforce:</strong> {{ first_row.replica_enforce }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Display add-on summary #}
|
||||
{% if show_addon_details and first_row.mandatory_addons or first_row.optional_addons %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Available Add-ons for {{ first_row.service }}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if first_row.mandatory_addons %}
|
||||
<div class="mb-3">
|
||||
|
|
@ -613,9 +682,9 @@
|
|||
<td class="fw-bold">
|
||||
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
|
||||
{% if comparison.difference > 0 %}
|
||||
(+{{ comparison.difference|floatformat:2 }}, +{% widthratio comparison.difference row.final_price 100 %}%)
|
||||
<span class="badge bg-success ms-1">+{{ comparison.difference|floatformat:2 }}</span>
|
||||
{% elif comparison.difference < 0 %}
|
||||
({{ comparison.difference|floatformat:2 }}, {% widthratio comparison.difference row.final_price 100 %}%)
|
||||
<span class="badge bg-danger ms-1">{{ comparison.difference|floatformat:2 }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -661,11 +730,11 @@
|
|||
<td class="fw-bold">
|
||||
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
|
||||
{% if comparison.difference > 0 %}
|
||||
(+{{ comparison.difference|floatformat:2 }}, +{% widthratio comparison.difference row.final_price 100 %}%)
|
||||
<span class="badge bg-danger ms-1">+{{ comparison.difference|floatformat:2 }}</span>
|
||||
{% elif comparison.difference < 0 %}
|
||||
({{ comparison.difference|floatformat:2 }}, {% widthratio comparison.difference row.final_price 100 %}%)
|
||||
<span class="badge bg-success ms-1">{{ comparison.difference|floatformat:2 }}</span>
|
||||
{% elif comparison.difference == 0 %}
|
||||
(Same)
|
||||
<span class="badge bg-info ms-1">Same</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -703,7 +772,7 @@
|
|||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<h4>No pricing data available</h4>
|
||||
<p>{% if not filter_cloud_provider and not filter_service %}Please select both a <strong>Cloud Provider</strong> and <strong>Service</strong> from the filters above to view pricing data.{% elif not filter_cloud_provider %}Please select a <strong>Cloud Provider</strong> from the filters above.{% elif not filter_service %}Please select a <strong>Service</strong> from the filters above.{% elif filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}No data matches the selected filters. Try adjusting your filter criteria.{% else %}Please ensure you have active compute plans with prices and VSHNAppCat price configurations.{% endif %}</p>
|
||||
<p>{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}No data matches the selected filters. Try adjusting your filter criteria.{% else %}Please ensure you have active compute plans with prices and VSHNAppCat price configurations.{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@
|
|||
<div class="pr-lg-6">
|
||||
<!-- Logo -->
|
||||
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
|
||||
{% if provider.get_logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ provider.get_logo.url }}" alt="{{ provider.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% if provider.logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ provider.logo.url }}" alt="{{ provider.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
|
@ -173,12 +173,12 @@
|
|||
{% for offering in ordered_offerings %}
|
||||
<div class="col-12 col-md-6 mb-30">
|
||||
<div class="card h-100 d-flex flex-column">
|
||||
{% if offering.service.get_logo or offering.service.is_featured %}
|
||||
{% if offering.service.logo or offering.service.is_featured %}
|
||||
<div class="d-flex justify-content-between">
|
||||
{% if offering.service.get_logo %}
|
||||
{% if offering.service.logo %}
|
||||
<div class="card__image flex-shrink-0">
|
||||
<a href="{{ offering.get_absolute_url }}">
|
||||
<img src="{{ offering.service.get_logo.url }}" alt="{{ offering.service.name }} logo" class="img-fluid">
|
||||
<img src="{{ offering.service.logo.url }}" alt="{{ offering.service.name }} logo" class="img-fluid">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@
|
|||
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
|
||||
<div class="me-3 d-flex align-items-center" style="height: 100%;">
|
||||
<a href="{{ provider.get_absolute_url }}" class="clickable-link">
|
||||
{% if provider.get_logo %}
|
||||
<img src="{{ provider.get_logo.url }}"
|
||||
{% if provider.logo %}
|
||||
<img src="{{ provider.logo.url }}"
|
||||
alt="{{ provider.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@
|
|||
<div class="pr-lg-6">
|
||||
<!-- Logo -->
|
||||
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
|
||||
{% if service.get_logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% if service.logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ service.logo.url }}" alt="{{ service.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
|
@ -51,59 +51,26 @@
|
|||
{% endif %}
|
||||
|
||||
<!-- Consulting Partners -->
|
||||
{% with consulting_partners=service.consulting_partners.all|dictsort:"order" %}
|
||||
{% regroup consulting_partners by category as partners_by_category %}
|
||||
{% for category_group in partners_by_category %}
|
||||
{% if category_group.grouper == "CONSULTING" and category_group.list %}
|
||||
<div class="mb-40">
|
||||
<h3 class="fw-semibold mb-12">Consulting Partners</h3>
|
||||
<p>If you want to get the most out of your {{ service.name }} service, our consulting partners can help you optimize your setup and application:</p>
|
||||
<ul class="list-unstyled space-y-12 fs-19 ps-0">
|
||||
{% for partner in category_group.list %}
|
||||
<li>
|
||||
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ partner.get_absolute_url }}">
|
||||
<span class="pr-10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people-fill" viewBox="0 0 16 16">
|
||||
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5" fill="#9A63EC"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{{ partner.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Training Partners -->
|
||||
{% with training_partners=service.consulting_partners.all|dictsort:"order" %}
|
||||
{% regroup training_partners by category as partners_by_category %}
|
||||
{% for category_group in partners_by_category %}
|
||||
{% if category_group.grouper == "TRAINING" and category_group.list %}
|
||||
<div class="mb-40">
|
||||
<h3 class="fw-semibold mb-12">Training Partners</h3>
|
||||
<p>Looking to upskill your team on {{ service.name }}? Our training partners offer comprehensive courses and workshops:</p>
|
||||
<ul class="list-unstyled space-y-12 fs-19 ps-0">
|
||||
{% for partner in category_group.list %}
|
||||
<li>
|
||||
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ partner.get_absolute_url }}">
|
||||
<span class="pr-10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-mortarboard-fill" viewBox="0 0 16 16">
|
||||
<path d="M8.211 2.047a.5.5 0 0 0-.422 0l-7.5 3.5a.5.5 0 0 0 .025.917l7.5 3a.5.5 0 0 0 .372 0L14 7.14V13a1 1 0 0 0-1 1v2h3v-2a1 1 0 0 0-1-1V6.739l.686-.275a.5.5 0 0 0 .025-.917l-7.5-3.5Z" fill="#9A63EC"/>
|
||||
<path d="M4.176 9.032a.5.5 0 0 0-.656.327l-.5 1.7a.5.5 0 0 0 .294.605l4.5 1.8a.5.5 0 0 0 .372 0l4.5-1.8a.5.5 0 0 0 .294-.605l-.5-1.7a.5.5 0 0 0-.656-.327L8 10.466 4.176 9.032Z" fill="#9A63EC"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{{ partner.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% if service.consulting_partners.exists %}
|
||||
<div class="mb-40">
|
||||
<h3 class="fw-semibold mb-12">Consulting Partners</h3>
|
||||
<p>If you want to get the most out of your {{ service.name }}, our consulting partners can help you optimize your setup and application:</p>
|
||||
<ul class="list-unstyled space-y-12 fs-19 ps-0">
|
||||
{% for partner in service.consulting_partners.all %}
|
||||
<li>
|
||||
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ partner.get_absolute_url }}">
|
||||
<span class="pr-10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people-fill" viewBox="0 0 16 16">
|
||||
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5" fill="#9A63EC"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{{ partner.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- External Links -->
|
||||
{% if service.external_links.exists %}
|
||||
|
|
@ -215,9 +182,9 @@
|
|||
class="text-decoration-none" style="display: block;">
|
||||
<div class="card h-100 clickable-card">
|
||||
<div class="card-body text-center">
|
||||
{% if offering.cloud_provider.get_logo %}
|
||||
{% if offering.cloud_provider.logo %}
|
||||
<div class="mb-3 d-flex align-items-center justify-content-center" style="height: 80px;">
|
||||
<img src="{{ offering.cloud_provider.get_logo.url }}" alt="{{ offering.cloud_provider.name }} logo"
|
||||
<img src="{{ offering.cloud_provider.logo.url }}" alt="{{ offering.cloud_provider.name }} logo"
|
||||
class="img-fluid" style="max-height: 60px; object-fit: contain;">
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
|||
|
|
@ -152,11 +152,11 @@
|
|||
<div class="col-12 col-md-6 col-lg-4 mb-30">
|
||||
<div class="card {% if service.is_featured %}card-featured{% endif %} h-100 d-flex flex-column clickable-card"
|
||||
onclick="cardClicked(event, '{% if request.GET.cloud_provider %}{% for offering in service.offerings.all %}{% if offering.cloud_provider.id|stringformat:"i" == request.GET.cloud_provider %}{% url "services:offering_detail" offering.cloud_provider.slug service.slug %}{% endif %}{% endfor %}{% else %}{{ service.get_absolute_url }}{% endif %}')">
|
||||
{% if service.get_logo or service.is_featured or service.is_coming_soon %}
|
||||
{% if service.logo or service.is_featured or service.is_coming_soon %}
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
{% if service.get_logo %}
|
||||
{% if service.logo %}
|
||||
<div class="card__image flex-shrink-0">
|
||||
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if service.is_featured %}
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from ..models.images import ImageLibrary
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def image_library_img(slug_or_id, css_class="", alt_text="", width=None, height=None):
|
||||
"""
|
||||
Render an image from the image library by slug or ID.
|
||||
Automatically handles SVG files with proper rendering.
|
||||
|
||||
Usage:
|
||||
{% image_library_img "my-image-slug" css_class="img-fluid" %}
|
||||
{% image_library_img image_id css_class="logo" width="100" height="100" %}
|
||||
"""
|
||||
try:
|
||||
# Try to get by slug first, then by ID
|
||||
if isinstance(slug_or_id, str):
|
||||
image = ImageLibrary.objects.get(slug=slug_or_id)
|
||||
else:
|
||||
image = ImageLibrary.objects.get(pk=slug_or_id)
|
||||
|
||||
# Use provided alt_text or fall back to image's alt_text
|
||||
final_alt_text = alt_text or image.alt_text
|
||||
|
||||
# Check if it's an SVG file
|
||||
if image.is_svg():
|
||||
# For SVG files, use object tag for better rendering
|
||||
attrs = {
|
||||
"data": image.image.url,
|
||||
"type": "image/svg+xml",
|
||||
"alt": final_alt_text,
|
||||
}
|
||||
|
||||
if css_class:
|
||||
attrs["class"] = css_class
|
||||
|
||||
if width:
|
||||
attrs["width"] = width
|
||||
|
||||
if height:
|
||||
attrs["height"] = height
|
||||
|
||||
# Build the object tag with img fallback
|
||||
attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
|
||||
return format_html(
|
||||
'<object {}><img src="{}" alt="{}" class="{}"/></object>',
|
||||
attr_string,
|
||||
image.image.url,
|
||||
final_alt_text,
|
||||
css_class or "",
|
||||
)
|
||||
else:
|
||||
# For raster images, use img tag
|
||||
attrs = {
|
||||
"src": image.image.url,
|
||||
"alt": final_alt_text,
|
||||
}
|
||||
|
||||
if css_class:
|
||||
attrs["class"] = css_class
|
||||
|
||||
if width:
|
||||
attrs["width"] = width
|
||||
|
||||
if height:
|
||||
attrs["height"] = height
|
||||
|
||||
# Build the HTML
|
||||
attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
|
||||
return format_html("<img {}/>", attr_string)
|
||||
|
||||
except ImageLibrary.DoesNotExist:
|
||||
# Return empty string or placeholder if image not found
|
||||
return format_html(
|
||||
'<img src="/static/images/placeholder.png" alt="Image not found" class="{}"/>',
|
||||
css_class,
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def image_library_url(slug_or_id):
|
||||
"""
|
||||
Get the URL of an image from the image library.
|
||||
|
||||
Usage:
|
||||
{% image_library_url "my-image-slug" %}
|
||||
{% image_library_url image_id %}
|
||||
"""
|
||||
try:
|
||||
if isinstance(slug_or_id, str):
|
||||
image = ImageLibrary.objects.get(slug=slug_or_id)
|
||||
else:
|
||||
image = ImageLibrary.objects.get(pk=slug_or_id)
|
||||
|
||||
return image.image.url
|
||||
|
||||
except ImageLibrary.DoesNotExist:
|
||||
return "/static/images/placeholder.png"
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def image_library_info(slug_or_id):
|
||||
"""
|
||||
Get information about an image from the image library.
|
||||
|
||||
Usage:
|
||||
{% image_library_info "my-image-slug" as img_info %}
|
||||
{{ img_info.name }} - {{ img_info.width }}x{{ img_info.height }}
|
||||
"""
|
||||
try:
|
||||
if isinstance(slug_or_id, str):
|
||||
image = ImageLibrary.objects.get(slug=slug_or_id)
|
||||
else:
|
||||
image = ImageLibrary.objects.get(pk=slug_or_id)
|
||||
|
||||
return {
|
||||
"name": image.name,
|
||||
"alt_text": image.alt_text,
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"file_size": image.get_file_size_display(),
|
||||
"category": image.get_category_display(),
|
||||
"tags": image.get_tags_list(),
|
||||
"url": image.image.url,
|
||||
}
|
||||
|
||||
except ImageLibrary.DoesNotExist:
|
||||
return {
|
||||
"name": "Image not found",
|
||||
"alt_text": "Image not found",
|
||||
"width": None,
|
||||
"height": None,
|
||||
"file_size": "Unknown",
|
||||
"category": "Unknown",
|
||||
"tags": [],
|
||||
"url": "/static/images/placeholder.png",
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
from datetime import datetime, time
|
||||
# hub/services/templatetags/json_ld_tags.py
|
||||
from django import template
|
||||
from django.urls import resolve, Resolver404
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils import timezone as django_timezone
|
||||
|
||||
import json
|
||||
|
||||
register = template.Library()
|
||||
|
|
@ -53,7 +51,7 @@ def json_ld_structured_data(context):
|
|||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Servala - The Sovereign App Store",
|
||||
"name": "Servala - Open Cloud Native Service Hub",
|
||||
"url": base_url,
|
||||
}
|
||||
json_ld = json.dumps(data, indent=2)
|
||||
|
|
@ -65,7 +63,7 @@ def json_ld_structured_data(context):
|
|||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Servala - The Sovereign App Store",
|
||||
"name": "Servala - Open Cloud Native Service Hub",
|
||||
"url": base_url,
|
||||
"description": "Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
|
||||
"potentialAction": {
|
||||
|
|
@ -108,38 +106,29 @@ def json_ld_structured_data(context):
|
|||
}
|
||||
|
||||
elif view_name == "service_detail" and "service" in context:
|
||||
data = organization_data
|
||||
service = context["service"]
|
||||
service_url = request.build_absolute_uri()
|
||||
|
||||
# service = context["service"]
|
||||
# service_url = request.build_absolute_uri()
|
||||
# # Check if service has offerings with pricing
|
||||
# has_offerings = hasattr(service, "offerings") and service.offerings.exists()
|
||||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
"name": service.name,
|
||||
"description": service.description,
|
||||
"url": service_url,
|
||||
"category": "Cloud Service",
|
||||
}
|
||||
|
||||
# if has_offerings:
|
||||
# # Use Product type when we have offerings (which provide the required offers data)
|
||||
# data = {
|
||||
# "@context": "https://schema.org",
|
||||
# "@type": "Product",
|
||||
# "name": service.name,
|
||||
# "description": service.description,
|
||||
# "url": service_url,
|
||||
# "category": "Cloud Service",
|
||||
# }
|
||||
# Add image if available
|
||||
if hasattr(service, "logo") and service.logo:
|
||||
data["image"] = request.build_absolute_uri(service.logo.url)
|
||||
|
||||
# # Add image if available
|
||||
# if hasattr(service, "get_logo") and service.get_logo:
|
||||
# data["image"] = request.build_absolute_uri(service.get_logo.url)
|
||||
|
||||
# # Add offerings
|
||||
# data["offers"] = {
|
||||
# "@type": "AggregateOffer",
|
||||
# "availability": "https://schema.org/InStock",
|
||||
# "offerCount": service.offerings.count(),
|
||||
# }
|
||||
# else:
|
||||
# # Use Organization data when no offerings are available
|
||||
# # This avoids Google Search Console errors for Product without required fields
|
||||
# data = organization_data
|
||||
# Add offerings if available
|
||||
if hasattr(service, "offerings") and service.offerings.exists():
|
||||
data["offers"] = {
|
||||
"@type": "AggregateOffer",
|
||||
"availability": "https://schema.org/InStock",
|
||||
"offerCount": service.offerings.count(),
|
||||
}
|
||||
|
||||
elif view_name == "provider_detail" and "provider" in context:
|
||||
provider = context["provider"]
|
||||
|
|
@ -154,8 +143,8 @@ def json_ld_structured_data(context):
|
|||
}
|
||||
|
||||
# Add image if available
|
||||
if hasattr(provider, "get_logo") and provider.get_logo:
|
||||
data["logo"] = request.build_absolute_uri(provider.get_logo.url)
|
||||
if hasattr(provider, "logo") and provider.logo:
|
||||
data["logo"] = request.build_absolute_uri(provider.logo.url)
|
||||
|
||||
# Add contact information if available
|
||||
contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"}
|
||||
|
|
@ -190,8 +179,8 @@ def json_ld_structured_data(context):
|
|||
}
|
||||
|
||||
# Add image if available
|
||||
if hasattr(partner, "get_logo") and partner.get_logo:
|
||||
data["logo"] = request.build_absolute_uri(partner.get_logo.url)
|
||||
if hasattr(partner, "logo") and partner.logo:
|
||||
data["logo"] = request.build_absolute_uri(partner.logo.url)
|
||||
|
||||
# Add contact information if available
|
||||
contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"}
|
||||
|
|
@ -217,151 +206,35 @@ def json_ld_structured_data(context):
|
|||
offering = context["offering"]
|
||||
offering_url = request.build_absolute_uri()
|
||||
|
||||
# Check if we have pricing data available
|
||||
has_pricing_data = False
|
||||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
"name": f"{offering.service.name} on {offering.cloud_provider.name}",
|
||||
"description": offering.description or offering.service.description,
|
||||
"url": offering_url,
|
||||
"category": "Cloud Service",
|
||||
}
|
||||
|
||||
# Add brand (service)
|
||||
data["brand"] = {"@type": "Brand", "name": offering.service.name}
|
||||
|
||||
# Add image if available
|
||||
if hasattr(offering.service, "logo") and offering.service.logo:
|
||||
data["image"] = request.build_absolute_uri(offering.service.logo.url)
|
||||
|
||||
# Add offers if available
|
||||
if hasattr(offering, "plans") and offering.plans.exists():
|
||||
# Get all plans with pricing
|
||||
plans_with_prices = offering.plans.filter(
|
||||
plan_prices__isnull=False
|
||||
).distinct()
|
||||
has_pricing_data = plans_with_prices.exists()
|
||||
|
||||
if has_pricing_data:
|
||||
# Use Product type with complete pricing information
|
||||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
"name": f"Managed {offering.service.name} on {offering.cloud_provider.name}",
|
||||
"description": offering.description or offering.service.description,
|
||||
"url": offering_url,
|
||||
"category": "Cloud Service",
|
||||
}
|
||||
|
||||
# Add brand (service)
|
||||
data["brand"] = {"@type": "Brand", "name": offering.service.name}
|
||||
|
||||
# Add image if available
|
||||
if hasattr(offering.service, "get_logo") and offering.service.get_logo:
|
||||
data["image"] = request.build_absolute_uri(
|
||||
offering.service.get_logo.url
|
||||
)
|
||||
|
||||
# Create individual offers for each plan with pricing
|
||||
offers = []
|
||||
all_prices = []
|
||||
|
||||
for plan in plans_with_prices:
|
||||
plan_prices = plan.plan_prices.all()
|
||||
if plan_prices.exists():
|
||||
first_price = plan_prices.first()
|
||||
all_prices.extend([p.amount for p in plan_prices])
|
||||
|
||||
offer = {
|
||||
"@type": "Offer",
|
||||
"name": plan.name,
|
||||
"price": str(first_price.amount),
|
||||
"priceCurrency": first_price.currency,
|
||||
"availability": "https://schema.org/InStock",
|
||||
"url": offering_url + "#plan-order-form",
|
||||
"seller": {"@type": "Organization", "name": "VSHN"},
|
||||
}
|
||||
offers.append(offer)
|
||||
|
||||
# Add aggregate offer with all required pricing fields
|
||||
if all_prices and offers:
|
||||
# Use the currency from the first plan's first price
|
||||
first_plan_with_prices = plans_with_prices.first()
|
||||
first_currency = first_plan_with_prices.plan_prices.first().currency
|
||||
|
||||
data["offers"] = {
|
||||
"@type": "AggregateOffer",
|
||||
"availability": "https://schema.org/InStock",
|
||||
"offerCount": len(offers),
|
||||
"offers": offers,
|
||||
"lowPrice": str(min(all_prices)),
|
||||
"highPrice": str(max(all_prices)),
|
||||
"priceCurrency": first_currency,
|
||||
"seller": {"@type": "Organization", "name": "VSHN"},
|
||||
}
|
||||
|
||||
# Note: aggregateRating and review fields are not included as this is a B2B
|
||||
# service marketplace without a review system. These could be added in the future
|
||||
# if customer reviews/ratings are implemented.
|
||||
else:
|
||||
# No pricing data available - use Organization data instead of Product
|
||||
# to avoid Google Search Console errors for missing required Product fields
|
||||
data = organization_data
|
||||
|
||||
elif view_name == "article_list":
|
||||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
"name": "Servala Articles",
|
||||
"url": f"{base_url}/articles/",
|
||||
"description": "Read our latest articles about cloud services, best practices, and industry insights.",
|
||||
"isPartOf": {"@type": "WebSite", "name": "Servala", "url": base_url},
|
||||
}
|
||||
|
||||
elif view_name == "article_detail" and "article" in context:
|
||||
article = context["article"]
|
||||
article_url = request.build_absolute_uri()
|
||||
|
||||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": article.title,
|
||||
"description": article.excerpt,
|
||||
"url": article_url,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": article.author.get_full_name() or article.author.username,
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Servala",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": f"{base_url}/static/img/servala-logo.png",
|
||||
data["offers"] = {
|
||||
"@type": "AggregateOffer",
|
||||
"availability": "https://schema.org/InStock",
|
||||
"offerCount": offering.plans.count(),
|
||||
"seller": {
|
||||
"@type": "Organization",
|
||||
"name": offering.cloud_provider.name,
|
||||
"url": request.build_absolute_uri(
|
||||
offering.cloud_provider.get_absolute_url()
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Add publication date using article_date field
|
||||
article_datetime = django_timezone.make_aware(
|
||||
datetime.combine(article.article_date, time.min)
|
||||
)
|
||||
data["datePublished"] = article_datetime.isoformat()
|
||||
|
||||
# Add modification date
|
||||
if article.updated_at:
|
||||
data["dateModified"] = article.updated_at.isoformat()
|
||||
|
||||
# Add image using the model's get_image property or get_og_image
|
||||
if article.get_og_image:
|
||||
data["image"] = request.build_absolute_uri(article.get_og_image.url)
|
||||
elif article.get_image:
|
||||
data["image"] = request.build_absolute_uri(article.get_image.url)
|
||||
|
||||
# Add keywords from meta_keywords field
|
||||
if article.meta_keywords:
|
||||
data["keywords"] = article.meta_keywords
|
||||
|
||||
# Add main entity of page
|
||||
data["mainEntityOfPage"] = {
|
||||
"@type": "WebPage",
|
||||
"@id": article_url,
|
||||
}
|
||||
|
||||
# Add about field based on related entities
|
||||
if article.related_consulting_partner:
|
||||
data["about"] = {
|
||||
"@type": "Organization",
|
||||
"name": article.related_consulting_partner.name,
|
||||
}
|
||||
elif article.related_cloud_provider:
|
||||
data["about"] = {
|
||||
"@type": "Organization",
|
||||
"name": article.related_cloud_provider.name,
|
||||
}
|
||||
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -63,9 +63,9 @@ def social_meta_tags(context):
|
|||
article = context["article"]
|
||||
title = f"Servala - {article.title}"
|
||||
description = article.excerpt
|
||||
# Use OG image if available, otherwise fall back to article image, then default
|
||||
if article.get_og_image:
|
||||
image_url = request.build_absolute_uri(article.get_og_image.url)
|
||||
# Use article image if available, otherwise default
|
||||
if article.image:
|
||||
image_url = request.build_absolute_uri(article.image.url)
|
||||
|
||||
# Determine og:type based on view
|
||||
og_type = "website" # default
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from decimal import Decimal
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
|
@ -9,12 +10,16 @@ from ..models.services import Service
|
|||
from ..models.pricing import (
|
||||
ComputePlan,
|
||||
ComputePlanPrice,
|
||||
StoragePlan,
|
||||
StoragePlanPrice,
|
||||
ProgressiveDiscountModel,
|
||||
DiscountTier,
|
||||
VSHNAppCatPrice,
|
||||
VSHNAppCatBaseFee,
|
||||
VSHNAppCatUnitRate,
|
||||
VSHNAppCatAddon,
|
||||
VSHNAppCatAddonBaseFee,
|
||||
VSHNAppCatAddonUnitRate,
|
||||
ExternalPricePlans,
|
||||
)
|
||||
|
||||
|
|
@ -158,8 +163,7 @@ class PricingEdgeCasesTestCase(TestCase):
|
|||
)
|
||||
|
||||
# Should return None when price doesn't exist
|
||||
# For BASE_FEE addons, service_level is required
|
||||
price = addon.get_price(Currency.CHF, service_level="standard")
|
||||
price = addon.get_price(Currency.CHF)
|
||||
self.assertIsNone(price)
|
||||
|
||||
def test_compute_plan_with_validity_dates(self):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
from .feeds import ArticleRSSFeed
|
||||
|
||||
app_name = "services"
|
||||
|
||||
|
|
@ -20,7 +19,6 @@ urlpatterns = [
|
|||
path("provider/<slug:slug>/", views.provider_detail, name="provider_detail"),
|
||||
path("partner/<slug:slug>/", views.partner_detail, name="partner_detail"),
|
||||
path("articles/", views.article_list, name="article_list"),
|
||||
path("articles/rss/", ArticleRSSFeed(), name="article_rss"),
|
||||
path("article/<slug:slug>/", views.article_detail, name="article_detail"),
|
||||
path("contact/", views.leads.contact, name="contact"),
|
||||
path("contact/thank-you/", views.thank_you, name="thank_you"),
|
||||
|
|
@ -31,14 +29,4 @@ urlpatterns = [
|
|||
views.pricelist,
|
||||
name="pricelist",
|
||||
),
|
||||
path(
|
||||
"csp-roi-calculator/",
|
||||
views.csp_roi_calculator,
|
||||
name="csp_roi_calculator",
|
||||
),
|
||||
path(
|
||||
"csp-roi-calculator/help/",
|
||||
views.roi_calculator_help,
|
||||
name="roi_calculator_help",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,243 +0,0 @@
|
|||
from django.core.files.base import ContentFile
|
||||
from django.utils.text import slugify
|
||||
from ..models.images import ImageLibrary
|
||||
import os
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None
|
||||
from PIL import Image as PILImage
|
||||
|
||||
|
||||
def create_image_from_file(
|
||||
file_path, name, description="", alt_text="", category="other", tags=""
|
||||
):
|
||||
"""
|
||||
Create an ImageLibrary entry from a local file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the image file
|
||||
name: Name for the image
|
||||
description: Optional description
|
||||
alt_text: Alternative text for accessibility
|
||||
category: Image category
|
||||
tags: Comma-separated tags
|
||||
|
||||
Returns:
|
||||
ImageLibrary instance or None if failed
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
print(f"File not found: {file_path}")
|
||||
return None
|
||||
|
||||
# Generate slug
|
||||
slug = slugify(name)
|
||||
|
||||
# Check if image already exists
|
||||
if ImageLibrary.objects.filter(slug=slug).exists():
|
||||
print(f"Image with slug '{slug}' already exists")
|
||||
return ImageLibrary.objects.get(slug=slug)
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=name,
|
||||
slug=slug,
|
||||
description=description,
|
||||
alt_text=alt_text or name,
|
||||
category=category,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# Read and save the image file
|
||||
with open(file_path, "rb") as f:
|
||||
image_lib.image.save(
|
||||
os.path.basename(file_path), ContentFile(f.read()), save=True
|
||||
)
|
||||
|
||||
print(f"Created image library entry: {name}")
|
||||
return image_lib
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating image library entry: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def create_image_from_url(
|
||||
url, name, description="", alt_text="", category="other", tags=""
|
||||
):
|
||||
"""
|
||||
Create an ImageLibrary entry from a URL.
|
||||
|
||||
Args:
|
||||
url: URL to the image
|
||||
name: Name for the image
|
||||
description: Optional description
|
||||
alt_text: Alternative text for accessibility
|
||||
category: Image category
|
||||
tags: Comma-separated tags
|
||||
|
||||
Returns:
|
||||
ImageLibrary instance or None if failed
|
||||
"""
|
||||
if requests is None:
|
||||
print("requests library is not installed. Cannot download from URL.")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Generate slug
|
||||
slug = slugify(name)
|
||||
|
||||
# Check if image already exists
|
||||
if ImageLibrary.objects.filter(slug=slug).exists():
|
||||
print(f"Image with slug '{slug}' already exists")
|
||||
return ImageLibrary.objects.get(slug=slug)
|
||||
|
||||
# Download the image
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=name,
|
||||
slug=slug,
|
||||
description=description,
|
||||
alt_text=alt_text or name,
|
||||
category=category,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# Save the image
|
||||
filename = url.split("/")[-1]
|
||||
if "?" in filename:
|
||||
filename = filename.split("?")[0]
|
||||
|
||||
image_lib.image.save(filename, ContentFile(response.content), save=True)
|
||||
|
||||
print(f"Created image library entry from URL: {name}")
|
||||
return image_lib
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating image library entry from URL: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_image_by_slug(slug):
|
||||
"""
|
||||
Get an image from the library by slug.
|
||||
|
||||
Args:
|
||||
slug: Slug of the image
|
||||
|
||||
Returns:
|
||||
ImageLibrary instance or None if not found
|
||||
"""
|
||||
try:
|
||||
return ImageLibrary.objects.get(slug=slug)
|
||||
except ImageLibrary.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def get_images_by_category(category):
|
||||
"""
|
||||
Get all images from a specific category.
|
||||
|
||||
Args:
|
||||
category: Category name
|
||||
|
||||
Returns:
|
||||
QuerySet of ImageLibrary instances
|
||||
"""
|
||||
return ImageLibrary.objects.filter(category=category)
|
||||
|
||||
|
||||
def get_images_by_tags(tags):
|
||||
"""
|
||||
Get images that contain any of the specified tags.
|
||||
|
||||
Args:
|
||||
tags: List of tags or comma-separated string
|
||||
|
||||
Returns:
|
||||
QuerySet of ImageLibrary instances
|
||||
"""
|
||||
if isinstance(tags, str):
|
||||
tags = [tag.strip() for tag in tags.split(",")]
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
query = Q()
|
||||
for tag in tags:
|
||||
query |= Q(tags__icontains=tag)
|
||||
|
||||
return ImageLibrary.objects.filter(query).distinct()
|
||||
|
||||
|
||||
def cleanup_unused_images():
|
||||
"""
|
||||
Find and optionally clean up unused images from the library.
|
||||
|
||||
Returns:
|
||||
List of ImageLibrary instances with usage_count = 0
|
||||
"""
|
||||
unused_images = ImageLibrary.objects.filter(usage_count=0)
|
||||
|
||||
print(f"Found {unused_images.count()} unused images:")
|
||||
for image in unused_images:
|
||||
print(f" - {image.name} ({image.slug})")
|
||||
|
||||
return unused_images
|
||||
|
||||
|
||||
def optimize_image(image_library_instance, max_width=1920, max_height=1080, quality=85):
|
||||
"""
|
||||
Optimize an image in the library by resizing and compressing.
|
||||
|
||||
Args:
|
||||
image_library_instance: ImageLibrary instance
|
||||
max_width: Maximum width in pixels
|
||||
max_height: Maximum height in pixels
|
||||
quality: JPEG quality (1-100)
|
||||
|
||||
Returns:
|
||||
bool: True if optimization was successful
|
||||
"""
|
||||
try:
|
||||
if not image_library_instance.image:
|
||||
return False
|
||||
|
||||
# Open the image
|
||||
with PILImage.open(image_library_instance.image.path) as img:
|
||||
# Calculate new dimensions while maintaining aspect ratio
|
||||
ratio = min(max_width / img.width, max_height / img.height)
|
||||
|
||||
if ratio < 1: # Only resize if image is larger than max dimensions
|
||||
new_width = int(img.width * ratio)
|
||||
new_height = int(img.height * ratio)
|
||||
|
||||
# Resize the image
|
||||
img_resized = img.resize(
|
||||
(new_width, new_height), PILImage.Resampling.LANCZOS
|
||||
)
|
||||
|
||||
# Save the optimized image
|
||||
img_resized.save(
|
||||
image_library_instance.image.path,
|
||||
format="JPEG",
|
||||
quality=quality,
|
||||
optimize=True,
|
||||
)
|
||||
|
||||
# Update the image properties
|
||||
image_library_instance._update_image_properties()
|
||||
|
||||
print(f"Optimized image: {image_library_instance.name}")
|
||||
return True
|
||||
else:
|
||||
print(f"Image already optimal: {image_library_instance.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error optimizing image {image_library_instance.name}: {e}")
|
||||
return False
|
||||
|
|
@ -7,4 +7,3 @@ from .services import *
|
|||
from .pages import *
|
||||
from .subscriptions import *
|
||||
from .pricelist import *
|
||||
from .calculator import *
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ def article_list(request):
|
|||
# Apply filters based on request parameters
|
||||
if search_query:
|
||||
articles = articles.filter(
|
||||
Q(title__icontains=search_query)
|
||||
Q(title__icontains=search_query)
|
||||
| Q(excerpt__icontains=search_query)
|
||||
| Q(content__icontains=search_query)
|
||||
| Q(meta_keywords__icontains=search_query)
|
||||
|
|
@ -41,7 +41,7 @@ def article_list(request):
|
|||
# Order articles: featured first, then by creation date (newest first)
|
||||
articles = articles.order_by(
|
||||
"-is_featured", # Featured first (True before False)
|
||||
"-article_date", # Newest first
|
||||
"-created_at", # Newest first
|
||||
)
|
||||
|
||||
# Create base querysets for each filter type that apply all OTHER current filters
|
||||
|
|
@ -51,7 +51,7 @@ def article_list(request):
|
|||
service_filter_base = all_articles
|
||||
if search_query:
|
||||
service_filter_base = service_filter_base.filter(
|
||||
Q(title__icontains=search_query)
|
||||
Q(title__icontains=search_query)
|
||||
| Q(excerpt__icontains=search_query)
|
||||
| Q(content__icontains=search_query)
|
||||
| Q(meta_keywords__icontains=search_query)
|
||||
|
|
@ -69,7 +69,7 @@ def article_list(request):
|
|||
cp_filter_base = all_articles
|
||||
if search_query:
|
||||
cp_filter_base = cp_filter_base.filter(
|
||||
Q(title__icontains=search_query)
|
||||
Q(title__icontains=search_query)
|
||||
| Q(excerpt__icontains=search_query)
|
||||
| Q(content__icontains=search_query)
|
||||
| Q(meta_keywords__icontains=search_query)
|
||||
|
|
@ -85,7 +85,7 @@ def article_list(request):
|
|||
cloud_filter_base = all_articles
|
||||
if search_query:
|
||||
cloud_filter_base = cloud_filter_base.filter(
|
||||
Q(title__icontains=search_query)
|
||||
Q(title__icontains=search_query)
|
||||
| Q(excerpt__icontains=search_query)
|
||||
| Q(content__icontains=search_query)
|
||||
| Q(meta_keywords__icontains=search_query)
|
||||
|
|
@ -136,14 +136,16 @@ def article_detail(request, slug):
|
|||
Article.objects.select_related(
|
||||
"author",
|
||||
"related_service",
|
||||
"related_consulting_partner",
|
||||
"related_cloud_provider",
|
||||
"related_consulting_partner",
|
||||
"related_cloud_provider"
|
||||
).filter(is_published=True),
|
||||
slug=slug,
|
||||
)
|
||||
|
||||
# Get related articles (same service, partner, or provider)
|
||||
related_articles = Article.objects.filter(is_published=True).exclude(id=article.id)
|
||||
related_articles = Article.objects.filter(
|
||||
is_published=True
|
||||
).exclude(id=article.id)
|
||||
|
||||
if article.related_service:
|
||||
related_articles = related_articles.filter(
|
||||
|
|
@ -162,13 +164,13 @@ def article_detail(request, slug):
|
|||
related_articles = related_articles.filter(
|
||||
related_service__isnull=True,
|
||||
related_consulting_partner__isnull=True,
|
||||
related_cloud_provider__isnull=True,
|
||||
related_cloud_provider__isnull=True
|
||||
)
|
||||
|
||||
related_articles = related_articles.order_by("-article_date")[:3]
|
||||
related_articles = related_articles.order_by("-created_at")[:3]
|
||||
|
||||
context = {
|
||||
"article": article,
|
||||
"related_articles": related_articles,
|
||||
}
|
||||
return render(request, "services/article_detail.html", context)
|
||||
return render(request, "services/article_detail.html", context)
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
from django.shortcuts import render, redirect
|
||||
from django.contrib import messages
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def csp_roi_calculator(request):
|
||||
"""
|
||||
CSP ROI Calculator - Protected view with password authentication
|
||||
Provides a comprehensive ROI calculation tool for cloud provider investors
|
||||
"""
|
||||
# Handle logout
|
||||
if request.method == "POST" and request.POST.get("logout"):
|
||||
request.session.pop("csp_calculator_authenticated", None)
|
||||
return redirect("services:csp_roi_calculator")
|
||||
|
||||
# Get password from Django settings
|
||||
calculator_password = getattr(settings, "CSP_CALCULATOR_PASSWORD", None)
|
||||
|
||||
# If no password is configured, deny access
|
||||
if not calculator_password:
|
||||
messages.error(
|
||||
request,
|
||||
"Calculator is not properly configured. Please contact administrator.",
|
||||
)
|
||||
return render(
|
||||
request, "calculator/password_form.html", {"password_error": True}
|
||||
)
|
||||
|
||||
# Password protection - check if authenticated in session
|
||||
if not request.session.get("csp_calculator_authenticated", False):
|
||||
if request.method == "POST":
|
||||
password = request.POST.get("password", "")
|
||||
|
||||
# Validate password
|
||||
if password == calculator_password:
|
||||
request.session["csp_calculator_authenticated"] = True
|
||||
# Set session timeout (optional - expires after 24 hours of inactivity)
|
||||
request.session.set_expiry(86400) # 24 hours
|
||||
messages.success(request, "Access granted to CSP ROI Calculator.")
|
||||
return redirect("services:csp_roi_calculator")
|
||||
else:
|
||||
messages.error(request, "Invalid password. Please try again.")
|
||||
|
||||
# Show password form
|
||||
return render(request, "calculator/password_form.html")
|
||||
|
||||
# User is authenticated, show the calculator
|
||||
context = {
|
||||
"page_title": "CSP ROI Calculator",
|
||||
"page_description": "Calculate potential returns from investing in Servala platform",
|
||||
}
|
||||
|
||||
return render(request, "calculator/csp_roi_calculator.html", context)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def roi_calculator_help(request):
|
||||
"""
|
||||
ROI Calculator Help page - Protected view with same password authentication as calculator
|
||||
Shows detailed information about investment models and market analysis
|
||||
"""
|
||||
# Handle logout
|
||||
if request.method == "POST" and request.POST.get("logout"):
|
||||
request.session.pop("csp_calculator_authenticated", None)
|
||||
return redirect("services:roi_calculator_help")
|
||||
|
||||
# Get password from Django settings
|
||||
calculator_password = getattr(settings, "CSP_CALCULATOR_PASSWORD", None)
|
||||
|
||||
# If no password is configured, deny access
|
||||
if not calculator_password:
|
||||
messages.error(
|
||||
request,
|
||||
"Calculator help is not properly configured. Please contact administrator.",
|
||||
)
|
||||
return render(
|
||||
request, "calculator/password_form.html", {"password_error": True}
|
||||
)
|
||||
|
||||
# Password protection - check if authenticated in session
|
||||
if not request.session.get("csp_calculator_authenticated", False):
|
||||
if request.method == "POST":
|
||||
password = request.POST.get("password", "")
|
||||
|
||||
# Validate password
|
||||
if password == calculator_password:
|
||||
request.session["csp_calculator_authenticated"] = True
|
||||
# Set session timeout (optional - expires after 24 hours of inactivity)
|
||||
request.session.set_expiry(86400) # 24 hours
|
||||
messages.success(request, "Access granted to CSP ROI Calculator Help.")
|
||||
return redirect("services:roi_calculator_help")
|
||||
else:
|
||||
messages.error(request, "Invalid password. Please try again.")
|
||||
|
||||
# Show password form
|
||||
return render(request, "calculator/password_form.html")
|
||||
|
||||
# User is authenticated, show the help page
|
||||
context = {
|
||||
"page_title": "ROI Calculator Help - Investment Models",
|
||||
"page_description": "Understand Servala's Loan and Direct Investment models with detailed explanations and market analysis",
|
||||
}
|
||||
|
||||
return render(request, "calculator/roi_calculator_help.html", context)
|
||||
|
|
@ -173,28 +173,15 @@ def generate_exoscale_marketplace_yaml(offering):
|
|||
).strip()
|
||||
|
||||
# Build YAML structure
|
||||
service_name = offering.service.name
|
||||
|
||||
# List of service names that should have "Enterprise" appended
|
||||
# This concerns all services which are already available on Exoscale Marketplace or DBaaS for differentiation
|
||||
# A workaround because we don't particularly have "Enterprise" services yet
|
||||
enterprise_services = ["GitLab", "PostgreSQL"]
|
||||
|
||||
if any(
|
||||
enterprise_service in service_name for enterprise_service in enterprise_services
|
||||
):
|
||||
service_name += " Enterprise"
|
||||
|
||||
title = f"{service_name} by Servala"
|
||||
yaml_structure = {
|
||||
yaml_key: {
|
||||
"page_class": "tmpl-marketplace-product",
|
||||
"html_title": title,
|
||||
"meta_desc": f"Managed {offering.service.name} by Servala - a product by VSHN. Servala is the The Sovereign App Store. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
|
||||
"page_header_title": title,
|
||||
"html_title": f"Managed {offering.service.name} by VSHN via Servala",
|
||||
"meta_desc": "Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
|
||||
"page_header_title": f"Managed {offering.service.name} by VSHN via Servala",
|
||||
"provider_key": "vshn",
|
||||
"slug": f"{offering.service.slug}-by-servala",
|
||||
"title": title,
|
||||
"slug": f"servala-managed-{offering.service.slug}",
|
||||
"title": f"Managed {offering.service.name} by VSHN via Servala",
|
||||
"logo": f"img/servala-{offering.service.slug}.svg",
|
||||
"list_display": [],
|
||||
"meta": [
|
||||
|
|
@ -255,221 +242,19 @@ def generate_exoscale_marketplace_yaml(offering):
|
|||
|
||||
|
||||
def generate_pricing_data(offering):
|
||||
"""Generate pricing data for a specific offering and cloud provider"""
|
||||
# Fetch compute plans for this cloud provider
|
||||
compute_plans = (
|
||||
ComputePlan.objects.filter(active=True, cloud_provider=offering.cloud_provider)
|
||||
.select_related("cloud_provider", "group")
|
||||
.prefetch_related("prices")
|
||||
.order_by("group__order", "group__name")
|
||||
)
|
||||
"""Generate pricing data for a specific offering and its plans with multi-currency support"""
|
||||
# Fetch all plans for this offering
|
||||
plans = offering.plans.prefetch_related("plan_prices")
|
||||
|
||||
# Apply natural sorting for compute plan names
|
||||
compute_plans = sorted(
|
||||
compute_plans,
|
||||
key=lambda x: (
|
||||
x.group.order if x.group else 999,
|
||||
x.group.name if x.group else "ZZZ",
|
||||
natural_sort_key(x.name),
|
||||
),
|
||||
)
|
||||
pricing_data = []
|
||||
for plan in plans:
|
||||
for plan_price in plan.plan_prices.all():
|
||||
pricing_data.append({
|
||||
"plan_id": plan.id,
|
||||
"plan_name": plan.name,
|
||||
"description": plan.description,
|
||||
"currency": plan_price.currency,
|
||||
"amount": float(plan_price.amount),
|
||||
})
|
||||
|
||||
# Fetch storage plans for this cloud provider
|
||||
storage_plans = (
|
||||
StoragePlan.objects.filter(cloud_provider=offering.cloud_provider)
|
||||
.prefetch_related("prices")
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
# Get default storage pricing (use first available storage plan)
|
||||
storage_price_data = {}
|
||||
if storage_plans.exists():
|
||||
default_storage_plan = storage_plans.first()
|
||||
for currency in ["CHF", "EUR", "USD"]: # Add currencies as needed
|
||||
price = default_storage_plan.get_price(currency)
|
||||
if price is not None:
|
||||
storage_price_data[currency] = price
|
||||
|
||||
# Fetch pricing for this specific service
|
||||
try:
|
||||
appcat_price = (
|
||||
VSHNAppCatPrice.objects.select_related("service", "discount_model")
|
||||
.prefetch_related("base_fees", "unit_rates", "discount_model__tiers")
|
||||
.get(service=offering.service)
|
||||
)
|
||||
except VSHNAppCatPrice.DoesNotExist:
|
||||
return None
|
||||
|
||||
pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
|
||||
processed_combinations = set()
|
||||
|
||||
# Generate pricing combinations for each compute plan
|
||||
for plan in compute_plans:
|
||||
plan_currencies = set(plan.prices.values_list("currency", flat=True))
|
||||
|
||||
# Determine units based on variable unit type
|
||||
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
|
||||
units = int(plan.ram)
|
||||
elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
|
||||
units = int(plan.vcpus)
|
||||
else:
|
||||
continue
|
||||
|
||||
base_fee_currencies = set(
|
||||
appcat_price.base_fees.values_list("currency", flat=True)
|
||||
)
|
||||
|
||||
service_levels = appcat_price.unit_rates.values_list(
|
||||
"service_level", flat=True
|
||||
).distinct()
|
||||
|
||||
for service_level in service_levels:
|
||||
unit_rate_currencies = set(
|
||||
appcat_price.unit_rates.filter(service_level=service_level).values_list(
|
||||
"currency", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
# Find currencies that exist across all pricing components
|
||||
matching_currencies = plan_currencies.intersection(
|
||||
base_fee_currencies
|
||||
).intersection(unit_rate_currencies)
|
||||
|
||||
if not matching_currencies:
|
||||
continue
|
||||
|
||||
for currency in matching_currencies:
|
||||
combination_key = (
|
||||
plan.name,
|
||||
service_level,
|
||||
currency,
|
||||
)
|
||||
|
||||
# Skip if combination already processed
|
||||
if combination_key in processed_combinations:
|
||||
continue
|
||||
|
||||
processed_combinations.add(combination_key)
|
||||
|
||||
# Get pricing components
|
||||
compute_plan_price = plan.get_price(currency)
|
||||
base_fee = appcat_price.get_base_fee(currency, service_level)
|
||||
unit_rate = appcat_price.get_unit_rate(currency, service_level)
|
||||
|
||||
# Skip if any pricing component is missing
|
||||
if any(
|
||||
price is None for price in [compute_plan_price, base_fee, unit_rate]
|
||||
):
|
||||
continue
|
||||
|
||||
# Calculate replica enforcement based on service level
|
||||
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
|
||||
replica_enforce = appcat_price.ha_replica_min
|
||||
else:
|
||||
replica_enforce = 1
|
||||
|
||||
total_units = units * replica_enforce
|
||||
standard_sla_price = base_fee + (total_units * unit_rate)
|
||||
|
||||
# Apply discount if available
|
||||
if appcat_price.discount_model and appcat_price.discount_model.active:
|
||||
discounted_price = appcat_price.discount_model.calculate_discount(
|
||||
unit_rate, total_units
|
||||
)
|
||||
sla_price = base_fee + discounted_price
|
||||
else:
|
||||
sla_price = standard_sla_price
|
||||
|
||||
# Get addons information
|
||||
addons = appcat_price.addons.filter(active=True)
|
||||
mandatory_addons = []
|
||||
optional_addons = []
|
||||
|
||||
# Calculate additional price from mandatory addons
|
||||
addon_total = 0
|
||||
|
||||
for addon in addons:
|
||||
addon_price = None
|
||||
addon_price_per_unit = None
|
||||
|
||||
if addon.addon_type == "BF": # Base Fee
|
||||
addon_price = addon.get_price(currency, service_level)
|
||||
elif addon.addon_type == "UR": # Unit Rate
|
||||
addon_price_per_unit = addon.get_price(currency, service_level)
|
||||
if addon_price_per_unit:
|
||||
addon_price = addon_price_per_unit * total_units
|
||||
|
||||
addon_info = {
|
||||
"id": addon.id,
|
||||
"name": addon.name,
|
||||
"description": addon.description,
|
||||
"commercial_description": addon.commercial_description,
|
||||
"addon_type": addon.get_addon_type_display(),
|
||||
"price": addon_price,
|
||||
"price_per_unit": addon_price_per_unit, # Add per-unit price for frontend calculations
|
||||
}
|
||||
|
||||
if addon.mandatory:
|
||||
mandatory_addons.append(addon_info)
|
||||
if addon_price:
|
||||
addon_total += addon_price
|
||||
sla_price += addon_price
|
||||
else:
|
||||
optional_addons.append(addon_info)
|
||||
|
||||
final_price = compute_plan_price + sla_price
|
||||
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
||||
service_level
|
||||
]
|
||||
|
||||
group_name = plan.group.name if plan.group else "No Group"
|
||||
|
||||
# Add pricing data to the grouped structure
|
||||
pricing_data_by_group_and_service_level[group_name][
|
||||
service_level_display
|
||||
].append(
|
||||
{
|
||||
"compute_plan": plan.name,
|
||||
"compute_plan_group": group_name,
|
||||
"compute_plan_group_description": (
|
||||
plan.group.description if plan.group else ""
|
||||
),
|
||||
"vcpus": plan.vcpus,
|
||||
"ram": plan.ram,
|
||||
"currency": currency,
|
||||
"compute_plan_price": compute_plan_price,
|
||||
"sla_price": sla_price,
|
||||
"final_price": final_price,
|
||||
"storage_price": storage_price_data.get(currency, 0),
|
||||
"ha_replica_min": appcat_price.ha_replica_min,
|
||||
"ha_replica_max": appcat_price.ha_replica_max,
|
||||
"variable_unit": appcat_price.variable_unit,
|
||||
"units": units,
|
||||
"total_units": total_units,
|
||||
"mandatory_addons": mandatory_addons,
|
||||
"optional_addons": optional_addons,
|
||||
}
|
||||
)
|
||||
|
||||
# Order groups correctly, placing "No Group" last
|
||||
ordered_groups = {}
|
||||
all_group_names = list(pricing_data_by_group_and_service_level.keys())
|
||||
|
||||
if "No Group" in all_group_names:
|
||||
all_group_names.remove("No Group")
|
||||
all_group_names.append("No Group")
|
||||
|
||||
for group_name_key in all_group_names:
|
||||
ordered_groups[group_name_key] = pricing_data_by_group_and_service_level[
|
||||
group_name_key
|
||||
]
|
||||
|
||||
# Convert defaultdicts to regular dicts for the template
|
||||
final_context_data = {}
|
||||
for group_key, service_levels_dict in ordered_groups.items():
|
||||
final_context_data[group_key] = {
|
||||
sl_key: list(plans_list)
|
||||
for sl_key, plans_list in service_levels_dict.items()
|
||||
}
|
||||
|
||||
return final_context_data
|
||||
return {"plans": pricing_data}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from django.shortcuts import render, get_object_or_404
|
||||
from django.db.models import Q
|
||||
from hub.services.models import ConsultingPartner, CloudProvider, Service
|
||||
from hub.services.models.base import PartnerCategory
|
||||
|
||||
|
||||
def partner_list(request):
|
||||
|
|
@ -9,7 +8,6 @@ def partner_list(request):
|
|||
search_query = request.GET.get("search", "")
|
||||
service_id = request.GET.get("service", "")
|
||||
cloud_provider_id = request.GET.get("cloud_provider", "")
|
||||
category = request.GET.get("category", "")
|
||||
|
||||
# Start with all active partners
|
||||
partners = ConsultingPartner.objects.filter(disable_listing=False).order_by("order")
|
||||
|
|
@ -26,9 +24,6 @@ def partner_list(request):
|
|||
if cloud_provider_id:
|
||||
partners = partners.filter(cloud_providers__id=cloud_provider_id)
|
||||
|
||||
if category:
|
||||
partners = partners.filter(category=category)
|
||||
|
||||
# Get available services from filtered partners
|
||||
available_service_ids = partners.values_list("services__id", flat=True).distinct()
|
||||
available_services = Service.objects.filter(
|
||||
|
|
@ -73,7 +68,6 @@ def partner_list(request):
|
|||
),
|
||||
"available_services": available_services,
|
||||
"available_cloud_providers": available_cloud_providers,
|
||||
"partner_categories": PartnerCategory.choices,
|
||||
}
|
||||
return render(request, "services/partner_list.html", context)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,19 +2,14 @@ import re
|
|||
|
||||
from django.shortcuts import render
|
||||
from collections import defaultdict
|
||||
from hub.services.models.pricing import (
|
||||
ComputePlan,
|
||||
StoragePlan,
|
||||
ExternalPricePlans,
|
||||
VSHNAppCatPrice,
|
||||
)
|
||||
from hub.services.models.pricing import ComputePlan, StoragePlan, ExternalPricePlans, VSHNAppCatPrice
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.db import models
|
||||
|
||||
|
||||
def natural_sort_key(obj):
|
||||
"""Extract numeric parts for natural sorting (works for any plan name)"""
|
||||
name = obj.name if hasattr(obj, "name") else str(obj)
|
||||
name = obj.name if hasattr(obj, 'name') else str(obj)
|
||||
parts = re.split(r"(\d+)", name)
|
||||
return [int(part) if part.isdigit() else part for part in parts]
|
||||
|
||||
|
|
@ -135,61 +130,18 @@ def pricelist(request):
|
|||
filter_compute_plan_group = request.GET.get("compute_plan_group", "")
|
||||
filter_service_level = request.GET.get("service_level", "")
|
||||
|
||||
# Get filter options for dropdowns first (needed for initial page load)
|
||||
all_cloud_providers = (
|
||||
ComputePlan.objects.all()
|
||||
.values_list("cloud_provider__name", flat=True)
|
||||
.distinct()
|
||||
.order_by("cloud_provider__name")
|
||||
)
|
||||
all_services = (
|
||||
VSHNAppCatPrice.objects.values_list("service__name", flat=True)
|
||||
.distinct()
|
||||
.order_by("service__name")
|
||||
)
|
||||
all_compute_plan_groups = list(
|
||||
ComputePlan.objects.filter(group__isnull=False)
|
||||
.values_list("group__name", flat=True)
|
||||
.distinct()
|
||||
.order_by("group__name")
|
||||
)
|
||||
all_compute_plan_groups.append("No Group") # Add option for plans without groups
|
||||
all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices]
|
||||
|
||||
# Only process pricing data if both cloud provider and service are selected
|
||||
if not filter_cloud_provider or not filter_service:
|
||||
context = {
|
||||
"pricing_data_by_group_and_service_level": {},
|
||||
"show_discount_details": show_discount_details,
|
||||
"show_addon_details": show_addon_details,
|
||||
"show_price_comparison": show_price_comparison,
|
||||
"filter_cloud_provider": filter_cloud_provider,
|
||||
"filter_service": filter_service,
|
||||
"filter_compute_plan_group": filter_compute_plan_group,
|
||||
"filter_service_level": filter_service_level,
|
||||
"all_cloud_providers": all_cloud_providers,
|
||||
"all_services": all_services,
|
||||
"all_compute_plan_groups": all_compute_plan_groups,
|
||||
"all_service_levels": all_service_levels,
|
||||
"show_empty_state": True, # Flag to show empty state message
|
||||
}
|
||||
return render(request, "services/pricelist.html", context)
|
||||
|
||||
# Fetch all compute plans (active and inactive) with related data
|
||||
compute_plans_qs = ComputePlan.objects.all()
|
||||
if filter_cloud_provider:
|
||||
compute_plans_qs = compute_plans_qs.filter(
|
||||
cloud_provider__name=filter_cloud_provider
|
||||
)
|
||||
compute_plans_qs = compute_plans_qs.filter(cloud_provider__name=filter_cloud_provider)
|
||||
if filter_compute_plan_group:
|
||||
if filter_compute_plan_group == "No Group":
|
||||
compute_plans_qs = compute_plans_qs.filter(group__isnull=True)
|
||||
else:
|
||||
compute_plans_qs = compute_plans_qs.filter(
|
||||
group__name=filter_compute_plan_group
|
||||
)
|
||||
compute_plans_qs = compute_plans_qs.filter(group__name=filter_compute_plan_group)
|
||||
compute_plans = list(
|
||||
compute_plans_qs.select_related("cloud_provider", "group")
|
||||
compute_plans_qs
|
||||
.select_related("cloud_provider", "group")
|
||||
.prefetch_related("prices")
|
||||
.order_by("group__order", "group__name", "cloud_provider__name", "name")
|
||||
)
|
||||
|
|
@ -234,30 +186,20 @@ def pricelist(request):
|
|||
units = int(plan.vcpus)
|
||||
else:
|
||||
continue
|
||||
base_fee_currencies = set(
|
||||
appcat_price.base_fees.values_list("currency", flat=True)
|
||||
)
|
||||
service_levels = appcat_price.unit_rates.values_list(
|
||||
"service_level", flat=True
|
||||
).distinct()
|
||||
base_fee_currencies = set(appcat_price.base_fees.values_list("currency", flat=True))
|
||||
service_levels = appcat_price.unit_rates.values_list("service_level", flat=True).distinct()
|
||||
# Apply service level filter
|
||||
if filter_service_level:
|
||||
service_levels = [
|
||||
sl
|
||||
for sl in service_levels
|
||||
if dict(VSHNAppCatPrice.ServiceLevel.choices)[sl]
|
||||
== filter_service_level
|
||||
sl for sl in service_levels
|
||||
if dict(VSHNAppCatPrice.ServiceLevel.choices)[sl] == filter_service_level
|
||||
]
|
||||
for service_level in service_levels:
|
||||
unit_rate_currencies = set(
|
||||
appcat_price.unit_rates.filter(
|
||||
service_level=service_level
|
||||
).values_list("currency", flat=True)
|
||||
appcat_price.unit_rates.filter(service_level=service_level).values_list("currency", flat=True)
|
||||
)
|
||||
# Find currencies that exist across all pricing components
|
||||
matching_currencies = plan_currencies.intersection(
|
||||
base_fee_currencies
|
||||
).intersection(unit_rate_currencies)
|
||||
matching_currencies = plan_currencies.intersection(base_fee_currencies).intersection(unit_rate_currencies)
|
||||
if not matching_currencies:
|
||||
continue
|
||||
for currency in matching_currencies:
|
||||
|
|
@ -275,10 +217,7 @@ def pricelist(request):
|
|||
compute_plan_price = plan.get_price(currency)
|
||||
base_fee = appcat_price.get_base_fee(currency, service_level)
|
||||
unit_rate = appcat_price.get_unit_rate(currency, service_level)
|
||||
if any(
|
||||
price is None
|
||||
for price in [compute_plan_price, base_fee, unit_rate]
|
||||
):
|
||||
if any(price is None for price in [compute_plan_price, base_fee, unit_rate]):
|
||||
continue
|
||||
# Calculate replica enforcement based on service level
|
||||
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
|
||||
|
|
@ -289,27 +228,12 @@ def pricelist(request):
|
|||
standard_sla_price = base_fee + (total_units * unit_rate)
|
||||
# Apply discount if available
|
||||
discount_breakdown = None
|
||||
if (
|
||||
appcat_price.discount_model
|
||||
and appcat_price.discount_model.active
|
||||
):
|
||||
discounted_price = (
|
||||
appcat_price.discount_model.calculate_discount(
|
||||
unit_rate, total_units
|
||||
)
|
||||
)
|
||||
if appcat_price.discount_model and appcat_price.discount_model.active:
|
||||
discounted_price = appcat_price.discount_model.calculate_discount(unit_rate, total_units)
|
||||
sla_price = base_fee + discounted_price
|
||||
discount_savings = standard_sla_price - sla_price
|
||||
discount_percentage = (
|
||||
(discount_savings / standard_sla_price) * 100
|
||||
if standard_sla_price > 0
|
||||
else 0
|
||||
)
|
||||
discount_breakdown = (
|
||||
appcat_price.discount_model.get_discount_breakdown(
|
||||
unit_rate, total_units
|
||||
)
|
||||
)
|
||||
discount_percentage = (discount_savings / standard_sla_price) * 100 if standard_sla_price > 0 else 0
|
||||
discount_breakdown = appcat_price.discount_model.get_discount_breakdown(unit_rate, total_units)
|
||||
else:
|
||||
sla_price = standard_sla_price
|
||||
discounted_price = total_units * unit_rate
|
||||
|
|
@ -335,9 +259,7 @@ def pricelist(request):
|
|||
if addon.addon_type == "BF": # Base Fee
|
||||
addon_price = addon.get_price(currency, service_level)
|
||||
elif addon.addon_type == "UR": # Unit Rate
|
||||
addon_price_per_unit = addon.get_price(
|
||||
currency, service_level
|
||||
)
|
||||
addon_price_per_unit = addon.get_price(currency, service_level)
|
||||
if addon_price_per_unit:
|
||||
addon_price = addon_price_per_unit * total_units
|
||||
addon_info = {
|
||||
|
|
@ -354,131 +276,89 @@ def pricelist(request):
|
|||
optional_addons.append(addon_info)
|
||||
service_price_with_addons = price_calculation["total_price"]
|
||||
final_price = compute_plan_price + service_price_with_addons
|
||||
service_level_display = dict(
|
||||
VSHNAppCatPrice.ServiceLevel.choices
|
||||
).get(service_level, service_level)
|
||||
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices).get(service_level, service_level)
|
||||
# Get external/internal price comparisons if enabled (unchanged, but could be optimized further)
|
||||
external_comparisons = []
|
||||
internal_comparisons = []
|
||||
if show_price_comparison:
|
||||
external_prices = get_external_price_comparisons(
|
||||
plan, appcat_price, currency, service_level
|
||||
)
|
||||
external_prices = get_external_price_comparisons(plan, appcat_price, currency, service_level)
|
||||
for ext_price in external_prices:
|
||||
difference = ext_price.amount - final_price
|
||||
ratio = (
|
||||
ext_price.amount / final_price if final_price > 0 else 0
|
||||
)
|
||||
external_comparisons.append(
|
||||
{
|
||||
"plan_name": ext_price.plan_name,
|
||||
"provider": ext_price.cloud_provider.name,
|
||||
"description": ext_price.description,
|
||||
"amount": ext_price.amount,
|
||||
"currency": ext_price.currency,
|
||||
"vcpus": ext_price.vcpus,
|
||||
"ram": ext_price.ram,
|
||||
"storage": ext_price.storage,
|
||||
"replicas": ext_price.replicas,
|
||||
"difference": difference,
|
||||
"ratio": ratio,
|
||||
"source": ext_price.source,
|
||||
"date_retrieved": ext_price.date_retrieved,
|
||||
"is_internal": False,
|
||||
}
|
||||
)
|
||||
internal_price_comparisons = (
|
||||
get_internal_cloud_provider_comparisons(
|
||||
plan, appcat_price, currency, service_level
|
||||
)
|
||||
)
|
||||
ratio = ext_price.amount / final_price if final_price > 0 else 0
|
||||
external_comparisons.append({
|
||||
"plan_name": ext_price.plan_name,
|
||||
"provider": ext_price.cloud_provider.name,
|
||||
"description": ext_price.description,
|
||||
"amount": ext_price.amount,
|
||||
"currency": ext_price.currency,
|
||||
"vcpus": ext_price.vcpus,
|
||||
"ram": ext_price.ram,
|
||||
"storage": ext_price.storage,
|
||||
"replicas": ext_price.replicas,
|
||||
"difference": difference,
|
||||
"ratio": ratio,
|
||||
"source": ext_price.source,
|
||||
"date_retrieved": ext_price.date_retrieved,
|
||||
"is_internal": False,
|
||||
})
|
||||
internal_price_comparisons = get_internal_cloud_provider_comparisons(plan, appcat_price, currency, service_level)
|
||||
for int_price in internal_price_comparisons:
|
||||
difference = int_price["final_price"] - final_price
|
||||
ratio = (
|
||||
int_price["final_price"] / final_price
|
||||
if final_price > 0
|
||||
else 0
|
||||
)
|
||||
internal_comparisons.append(
|
||||
{
|
||||
"plan_name": int_price["plan_name"],
|
||||
"provider": int_price["provider"],
|
||||
"description": f"Same specs with {int_price['provider']}",
|
||||
"amount": int_price["final_price"],
|
||||
"currency": int_price["currency"],
|
||||
"vcpus": int_price["vcpus"],
|
||||
"ram": int_price["ram"],
|
||||
"group_name": int_price["group_name"],
|
||||
"compute_plan_price": int_price[
|
||||
"compute_plan_price"
|
||||
],
|
||||
"service_price": int_price["service_price"],
|
||||
"difference": difference,
|
||||
"ratio": ratio,
|
||||
"is_internal": True,
|
||||
}
|
||||
)
|
||||
ratio = int_price["final_price"] / final_price if final_price > 0 else 0
|
||||
internal_comparisons.append({
|
||||
"plan_name": int_price["plan_name"],
|
||||
"provider": int_price["provider"],
|
||||
"description": f"Same specs with {int_price['provider']}",
|
||||
"amount": int_price["final_price"],
|
||||
"currency": int_price["currency"],
|
||||
"vcpus": int_price["vcpus"],
|
||||
"ram": int_price["ram"],
|
||||
"group_name": int_price["group_name"],
|
||||
"compute_plan_price": int_price["compute_plan_price"],
|
||||
"service_price": int_price["service_price"],
|
||||
"difference": difference,
|
||||
"ratio": ratio,
|
||||
"is_internal": True,
|
||||
})
|
||||
group_name = plan.group.name if plan.group else "No Group"
|
||||
# Use prefetched storage plans
|
||||
storage_plans = storage_plans_by_provider.get(
|
||||
plan.cloud_provider_id, []
|
||||
)
|
||||
pricing_data_by_group_and_service_level[group_name][
|
||||
service_level_display
|
||||
].append(
|
||||
{
|
||||
"cloud_provider": plan.cloud_provider.name,
|
||||
"service": appcat_price.service.name,
|
||||
"compute_plan": plan.name,
|
||||
"compute_plan_group": group_name,
|
||||
"compute_plan_group_description": (
|
||||
plan.group.description if plan.group else ""
|
||||
),
|
||||
"compute_plan_group_node_label": (
|
||||
plan.group.node_label if plan.group else ""
|
||||
),
|
||||
"storage_plans": storage_plans,
|
||||
"vcpus": plan.vcpus,
|
||||
"ram": plan.ram,
|
||||
"cpu_mem_ratio": plan.cpu_mem_ratio,
|
||||
"term": plan.get_term_display(),
|
||||
"currency": currency,
|
||||
"compute_plan_price": compute_plan_price,
|
||||
"variable_unit": appcat_price.get_variable_unit_display(),
|
||||
"units": units,
|
||||
"replica_enforce": replica_enforce,
|
||||
"total_units": total_units,
|
||||
"service_level": service_level_display,
|
||||
"sla_base": base_fee,
|
||||
"sla_per_unit": unit_rate,
|
||||
"sla_price": service_price_with_addons,
|
||||
"standard_sla_price": base_sla_price,
|
||||
"discounted_sla_price": (
|
||||
base_fee + discounted_price
|
||||
if appcat_price.discount_model
|
||||
and appcat_price.discount_model.active
|
||||
else None
|
||||
),
|
||||
"discount_savings": discount_savings,
|
||||
"discount_percentage": discount_percentage,
|
||||
"discount_breakdown": discount_breakdown,
|
||||
"final_price": final_price,
|
||||
"discount_model": (
|
||||
appcat_price.discount_model.name
|
||||
if appcat_price.discount_model
|
||||
else None
|
||||
),
|
||||
"has_discount": bool(
|
||||
appcat_price.discount_model
|
||||
and appcat_price.discount_model.active
|
||||
),
|
||||
"external_comparisons": external_comparisons,
|
||||
"internal_comparisons": internal_comparisons,
|
||||
"mandatory_addons": mandatory_addons,
|
||||
"optional_addons": optional_addons,
|
||||
"is_active": plan.active,
|
||||
}
|
||||
)
|
||||
storage_plans = storage_plans_by_provider.get(plan.cloud_provider_id, [])
|
||||
pricing_data_by_group_and_service_level[group_name][service_level_display].append({
|
||||
"cloud_provider": plan.cloud_provider.name,
|
||||
"service": appcat_price.service.name,
|
||||
"compute_plan": plan.name,
|
||||
"compute_plan_group": group_name,
|
||||
"compute_plan_group_description": (plan.group.description if plan.group else ""),
|
||||
"compute_plan_group_node_label": (plan.group.node_label if plan.group else ""),
|
||||
"storage_plans": storage_plans,
|
||||
"vcpus": plan.vcpus,
|
||||
"ram": plan.ram,
|
||||
"cpu_mem_ratio": plan.cpu_mem_ratio,
|
||||
"term": plan.get_term_display(),
|
||||
"currency": currency,
|
||||
"compute_plan_price": compute_plan_price,
|
||||
"variable_unit": appcat_price.get_variable_unit_display(),
|
||||
"units": units,
|
||||
"replica_enforce": replica_enforce,
|
||||
"total_units": total_units,
|
||||
"service_level": service_level_display,
|
||||
"sla_base": base_fee,
|
||||
"sla_per_unit": unit_rate,
|
||||
"sla_price": service_price_with_addons,
|
||||
"standard_sla_price": base_sla_price,
|
||||
"discounted_sla_price": (base_fee + discounted_price if appcat_price.discount_model and appcat_price.discount_model.active else None),
|
||||
"discount_savings": discount_savings,
|
||||
"discount_percentage": discount_percentage,
|
||||
"discount_breakdown": discount_breakdown,
|
||||
"final_price": final_price,
|
||||
"discount_model": (appcat_price.discount_model.name if appcat_price.discount_model else None),
|
||||
"has_discount": bool(appcat_price.discount_model and appcat_price.discount_model.active),
|
||||
"external_comparisons": external_comparisons,
|
||||
"internal_comparisons": internal_comparisons,
|
||||
"mandatory_addons": mandatory_addons,
|
||||
"optional_addons": optional_addons,
|
||||
"is_active": plan.active,
|
||||
})
|
||||
# Order groups correctly, placing "No Group" last
|
||||
ordered_groups_intermediate = {}
|
||||
all_group_names = list(pricing_data_by_group_and_service_level.keys())
|
||||
|
|
@ -486,9 +366,7 @@ def pricelist(request):
|
|||
all_group_names.remove("No Group")
|
||||
all_group_names.append("No Group")
|
||||
for group_name_key in all_group_names:
|
||||
ordered_groups_intermediate[group_name_key] = (
|
||||
pricing_data_by_group_and_service_level[group_name_key]
|
||||
)
|
||||
ordered_groups_intermediate[group_name_key] = pricing_data_by_group_and_service_level[group_name_key]
|
||||
# Convert defaultdicts to regular dicts for the template
|
||||
final_context_data = {}
|
||||
for group_key, service_levels_dict in ordered_groups_intermediate.items():
|
||||
|
|
@ -516,7 +394,11 @@ def pricelist(request):
|
|||
)
|
||||
all_compute_plan_groups.append("No Group") # Add option for plans without groups
|
||||
all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices]
|
||||
|
||||
# If no filter is specified, select the first available provider/service by default
|
||||
if not filter_cloud_provider and all_cloud_providers:
|
||||
filter_cloud_provider = all_cloud_providers[0]
|
||||
if not filter_service and all_services:
|
||||
filter_service = all_services[0]
|
||||
context = {
|
||||
"pricing_data_by_group_and_service_level": final_context_data,
|
||||
"show_discount_details": show_discount_details,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in HTTPS_HOSTS] + [
|
|||
|
||||
# Primary website URL
|
||||
WEBSITE_URL = env.str("WEBSITE_URL", default="https://servala.com")
|
||||
DISABLE_REDIRECT = env.bool("DISABLE_REDIRECT", default=False)
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
USE_X_FORWARDED_HOST = True
|
||||
|
|
@ -77,7 +76,6 @@ INSTALLED_APPS = [
|
|||
"django.contrib.staticfiles",
|
||||
"django.contrib.sitemaps",
|
||||
# 3rd party
|
||||
"compressor",
|
||||
"django_prose_editor",
|
||||
"rest_framework",
|
||||
"schema_viewer",
|
||||
|
|
@ -188,25 +186,6 @@ USE_TZ = True
|
|||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = env.path("STATIC_ROOT", default=BASE_DIR / "static")
|
||||
|
||||
# Static files configuration
|
||||
STATICFILES_FINDERS = [
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||
"compressor.finders.CompressorFinder",
|
||||
]
|
||||
|
||||
# Django Compressor settings
|
||||
COMPRESS_ENABLED = True
|
||||
COMPRESS_OFFLINE = True # Compress during build, not runtime
|
||||
COMPRESS_CSS_FILTERS = [
|
||||
"compressor.filters.css_default.CssAbsoluteFilter",
|
||||
"compressor.filters.cssmin.rCSSMinFilter",
|
||||
]
|
||||
COMPRESS_JS_FILTERS = [
|
||||
"compressor.filters.jsmin.rJSMinFilter",
|
||||
]
|
||||
COMPRESS_OUTPUT_DIR = "CACHE"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
|
||||
|
|
@ -238,9 +217,6 @@ ODOO_CONFIG = {
|
|||
"mailing_list_id": env.int("ODOO_MAILING_LIST_ID", default=46),
|
||||
}
|
||||
|
||||
# CSP ROI Calculator Configuration
|
||||
CSP_CALCULATOR_PASSWORD = env.str("CSP_CALCULATOR_PASSWORD", default=None)
|
||||
|
||||
BROKER_USERNAME = env.str("BROKER_USERNAME", default="broker")
|
||||
BROKER_PASSWORD = env.str("BROKER_PASSWORD", default="secret")
|
||||
BASE_URL = "https://your-domain.com"
|
||||
|
|
@ -269,7 +245,6 @@ JAZZMIN_SETTINGS = {
|
|||
"new_window": True,
|
||||
},
|
||||
{"name": "Articles", "url": "/admin/services/article/"},
|
||||
{"name": "Image Library", "url": "/admin/services/imagelibrary/"},
|
||||
{"name": "FAQs", "url": "/admin/services/websitefaq/"},
|
||||
],
|
||||
"show_sidebar": True,
|
||||
|
|
@ -280,11 +255,8 @@ JAZZMIN_SETTINGS = {
|
|||
"services.ProgressiveDiscountModel": "single",
|
||||
"services.VSHNAppCatPrice": "single",
|
||||
"services.VSHNAppCatAddon": "single",
|
||||
"services.ServiceOffering": "single",
|
||||
"services.Plan": "single",
|
||||
},
|
||||
"related_modal_active": True,
|
||||
}
|
||||
|
||||
IMPORT_EXPORT_FORMATS = [CSV]
|
||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from hub.services.models import (
|
|||
CloudProvider,
|
||||
ConsultingPartner,
|
||||
ServiceOffering,
|
||||
Article,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -14,12 +13,7 @@ class StaticSitemap(Sitemap):
|
|||
priority = 1.0
|
||||
|
||||
def items(self):
|
||||
return [
|
||||
"services:homepage",
|
||||
"services:contact",
|
||||
"services:article_list",
|
||||
"services:article_rss",
|
||||
]
|
||||
return ["services:homepage", "services:contact"]
|
||||
|
||||
def location(self, item):
|
||||
return reverse(item)
|
||||
|
|
@ -66,14 +60,3 @@ class ConsultingPartnerSitemap(Sitemap):
|
|||
|
||||
def lastmod(self, obj):
|
||||
return obj.updated_at
|
||||
|
||||
|
||||
class ArticleSitemap(Sitemap):
|
||||
changefreq = "weekly"
|
||||
priority = 0.8
|
||||
|
||||
def items(self):
|
||||
return Article.objects.filter(is_published=True)
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.updated_at
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue