diff --git a/hub/services/static/css/price-calculator.css b/hub/services/static/css/price-calculator.css
new file mode 100644
index 0000000..de8074e
--- /dev/null
+++ b/hub/services/static/css/price-calculator.css
@@ -0,0 +1,36 @@
+.form-range::-webkit-slider-thumb {
+ background: #6f42c1;
+}
+
+.form-range::-moz-range-thumb {
+ background: #6f42c1;
+ border: none;
+}
+
+.btn-check:checked+.btn-outline-primary {
+ background-color: #6f42c1;
+ border-color: #6f42c1;
+ color: white;
+}
+
+.card {
+ transition: box-shadow 0.2s ease-in-out;
+}
+
+.card:hover {
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
+}
+
+#selectedPlanDetails {
+ animation: fadeIn 0.3s ease-in;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
\ No newline at end of file
diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js
new file mode 100644
index 0000000..2cafe7d
--- /dev/null
+++ b/hub/services/static/js/price-calculator.js
@@ -0,0 +1,411 @@
+/**
+ * Price Calculator for Service Offerings
+ * Handles interactive pricing calculation with sliders and plan selection
+ */
+
+class PriceCalculator {
+ constructor() {
+ this.pricingData = null;
+ this.storagePrice = 0.15; // CHF per GB per month
+ this.currentOffering = null;
+ this.init();
+ }
+
+ // Initialize calculator elements and event listeners
+ init() {
+ // Get offering info from URL
+ const pathParts = window.location.pathname.split('/');
+ if (pathParts.length >= 4 && pathParts[1] === 'offering') {
+ this.currentOffering = {
+ provider_slug: pathParts[2],
+ service_slug: pathParts[3]
+ };
+ }
+
+ // Initialize DOM elements
+ this.initElements();
+
+ // Load pricing data and setup calculator
+ if (this.currentOffering) {
+ this.loadPricingData();
+ }
+ }
+
+ // Initialize DOM element references
+ initElements() {
+ // Calculator controls
+ this.cpuRange = document.getElementById('cpuRange');
+ this.memoryRange = document.getElementById('memoryRange');
+ this.storageRange = document.getElementById('storageRange');
+ this.cpuValue = document.getElementById('cpuValue');
+ this.memoryValue = document.getElementById('memoryValue');
+ this.storageValue = document.getElementById('storageValue');
+ this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
+ this.planSelect = document.getElementById('planSelect');
+
+ // Result display elements
+ this.planMatchStatus = document.getElementById('planMatchStatus');
+ this.selectedPlanDetails = document.getElementById('selectedPlanDetails');
+ this.noMatchFound = document.getElementById('noMatchFound');
+
+ // Plan detail elements
+ this.planGroup = document.getElementById('planGroup');
+ this.planName = document.getElementById('planName');
+ this.planDescription = document.getElementById('planDescription');
+ this.planCpus = document.getElementById('planCpus');
+ this.planMemory = document.getElementById('planMemory');
+ this.computePrice = document.getElementById('computePrice');
+ this.servicePrice = document.getElementById('servicePrice');
+ this.storagePriceEl = document.getElementById('storagePrice');
+ this.storageAmount = document.getElementById('storageAmount');
+ this.totalPrice = document.getElementById('totalPrice');
+ }
+
+ // Load pricing data from API endpoint
+ async loadPricingData() {
+ try {
+ const response = await fetch(`/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`);
+ if (!response.ok) {
+ throw new Error('Failed to load pricing data');
+ }
+
+ this.pricingData = await response.json();
+ this.setupEventListeners();
+ this.populatePlanDropdown();
+ this.updatePricing();
+ } catch (error) {
+ console.error('Error loading pricing data:', error);
+ this.showError('Failed to load pricing information');
+ }
+ }
+
+ // Setup event listeners for calculator controls
+ setupEventListeners() {
+ if (!this.cpuRange || !this.memoryRange || !this.storageRange) return;
+
+ // Setup service levels based on available data
+ this.setupServiceLevels();
+
+ // Slider event listeners
+ this.cpuRange.addEventListener('input', () => {
+ this.cpuValue.textContent = this.cpuRange.value;
+ this.updatePricing();
+ });
+
+ this.memoryRange.addEventListener('input', () => {
+ this.memoryValue.textContent = this.memoryRange.value;
+ this.updatePricing();
+ });
+
+ this.storageRange.addEventListener('input', () => {
+ this.storageValue.textContent = this.storageRange.value;
+ this.updatePricing();
+ });
+
+ // Service level change listeners
+ this.serviceLevelInputs.forEach(input => {
+ input.addEventListener('change', () => {
+ this.populatePlanDropdown();
+ this.updatePricing();
+ });
+ });
+
+ // Plan selection listener
+ if (this.planSelect) {
+ this.planSelect.addEventListener('change', () => {
+ if (this.planSelect.value) {
+ const selectedPlan = JSON.parse(this.planSelect.value);
+
+ // Update sliders to match selected plan
+ this.cpuRange.value = selectedPlan.vcpus;
+ this.memoryRange.value = selectedPlan.ram;
+ this.cpuValue.textContent = selectedPlan.vcpus;
+ this.memoryValue.textContent = selectedPlan.ram;
+
+ this.updatePricingWithPlan(selectedPlan);
+ } else {
+ this.updatePricing();
+ }
+ });
+ }
+ }
+
+ // Setup service levels dynamically from pricing data
+ setupServiceLevels() {
+ if (!this.pricingData) return;
+
+ const serviceLevelGroup = document.getElementById('serviceLevelGroup');
+ if (!serviceLevelGroup) return;
+
+ // Get all available service levels from the pricing data
+ const availableServiceLevels = new Set();
+ Object.keys(this.pricingData).forEach(groupName => {
+ const group = this.pricingData[groupName];
+ Object.keys(group).forEach(serviceLevel => {
+ availableServiceLevels.add(serviceLevel);
+ });
+ });
+
+ // 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;
+
+ // Add event listener
+ input.addEventListener('change', () => {
+ this.populatePlanDropdown();
+ this.updatePricing();
+ });
+
+ serviceLevelGroup.appendChild(input);
+ serviceLevelGroup.appendChild(label);
+ });
+
+ // Update the serviceLevelInputs reference
+ this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
+
+ // Calculate and set slider maximums based on available plans
+ this.updateSliderMaximums();
+ }
+
+ // Calculate maximum values for sliders based on available plans
+ updateSliderMaximums() {
+ if (!this.pricingData || !this.cpuRange || !this.memoryRange) return;
+
+ 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;
+ });
+ });
+ });
+
+ // Set slider maximums with some padding
+ if (maxCpus > 0) {
+ this.cpuRange.max = Math.ceil(maxCpus);
+ // Update the max display under the slider
+ const cpuMaxDisplay = this.cpuRange.parentElement.querySelector('.d-flex.justify-content-between .text-muted span:last-child');
+ if (cpuMaxDisplay) cpuMaxDisplay.textContent = Math.ceil(maxCpus);
+ }
+
+ if (maxMemory > 0) {
+ this.memoryRange.max = Math.ceil(maxMemory);
+ // Update the max display under the slider
+ const memoryMaxDisplay = this.memoryRange.parentElement.querySelector('.d-flex.justify-content-between .text-muted span:last-child');
+ if (memoryMaxDisplay) memoryMaxDisplay.textContent = Math.ceil(maxMemory) + ' GB';
+ }
+ }
+
+ // Populate plan dropdown based on selected service level
+ populatePlanDropdown() {
+ if (!this.planSelect || !this.pricingData) return;
+
+ const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
+ if (!serviceLevel) return;
+
+ // Clear existing options
+ this.planSelect.innerHTML = '';
+
+ // Collect all plans for selected service level
+ 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 (parseInt(a.vcpus) !== parseInt(b.vcpus)) {
+ return parseInt(a.vcpus) - parseInt(b.vcpus);
+ }
+ return parseInt(a.ram) - parseInt(b.ram);
+ });
+
+ // 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 (CHF ${parseFloat(plan.final_price).toFixed(2)}/month)`;
+ this.planSelect.appendChild(option);
+ });
+ }
+
+ // Find best matching plan based on requirements
+ findBestMatchingPlan(cpus, memory, serviceLevel) {
+ if (!this.pricingData) return null;
+
+ let bestMatch = null;
+ let bestScore = Infinity;
+
+ // Iterate through all groups and service levels
+ Object.keys(this.pricingData).forEach(groupName => {
+ const group = this.pricingData[groupName];
+
+ if (group[serviceLevel]) {
+ group[serviceLevel].forEach(plan => {
+ const planCpus = parseInt(plan.vcpus);
+ const planMemory = parseInt(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;
+ }
+
+ // Update pricing with specific plan
+ updatePricingWithPlan(selectedPlan) {
+ const storage = parseInt(this.storageRange?.value || 20);
+
+ this.showPlanDetails(selectedPlan, storage);
+ this.updateStatusMessage('Plan selected directly!', 'success');
+ }
+
+ // Main pricing update function
+ updatePricing() {
+ if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange) return;
+
+ // Reset plan selection if in auto-select mode
+ if (!this.planSelect?.value) {
+ const cpus = parseInt(this.cpuRange.value);
+ const memory = parseInt(this.memoryRange.value);
+ const storage = parseInt(this.storageRange.value);
+ const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
+
+ if (!serviceLevel) return;
+
+ // Find best matching plan
+ const matchedPlan = this.findBestMatchingPlan(cpus, memory, serviceLevel);
+
+ if (matchedPlan) {
+ this.showPlanDetails(matchedPlan, storage);
+ this.updateStatusMessage('Perfect match found!', 'success');
+ } else {
+ this.showNoMatch();
+ }
+ } else {
+ // Plan is directly selected, update storage pricing
+ const selectedPlan = JSON.parse(this.planSelect.value);
+ this.updatePricingWithPlan(selectedPlan);
+ }
+ }
+
+ // Show plan details in the UI
+ showPlanDetails(plan, storage) {
+ if (!this.selectedPlanDetails) return;
+
+ // Show plan details section
+ this.planMatchStatus.style.display = 'block';
+ this.selectedPlanDetails.style.display = 'block';
+ if (this.noMatchFound) this.noMatchFound.style.display = 'none';
+
+ // Update plan information
+ if (this.planGroup) this.planGroup.textContent = plan.groupName;
+ if (this.planName) this.planName.textContent = plan.compute_plan;
+ if (this.planDescription) this.planDescription.textContent = plan.compute_plan_group_description || '';
+ if (this.planCpus) this.planCpus.textContent = plan.vcpus;
+ if (this.planMemory) this.planMemory.textContent = plan.ram + ' GB';
+
+ // Calculate pricing
+ const computePriceValue = parseFloat(plan.compute_plan_price);
+ const servicePriceValue = parseFloat(plan.sla_price);
+ const storagePriceValue = storage * this.storagePrice;
+ const totalPriceValue = computePriceValue + servicePriceValue + storagePriceValue;
+
+ // Update pricing display
+ if (this.computePrice) this.computePrice.textContent = computePriceValue.toFixed(2);
+ if (this.servicePrice) this.servicePrice.textContent = servicePriceValue.toFixed(2);
+ if (this.storagePriceEl) this.storagePriceEl.textContent = storagePriceValue.toFixed(2);
+ if (this.storageAmount) this.storageAmount.textContent = storage;
+ if (this.totalPrice) this.totalPrice.textContent = totalPriceValue.toFixed(2);
+ }
+
+ // Show no matching plan found
+ showNoMatch() {
+ if (this.planMatchStatus) this.planMatchStatus.style.display = 'none';
+ if (this.selectedPlanDetails) this.selectedPlanDetails.style.display = 'none';
+ if (this.noMatchFound) this.noMatchFound.style.display = 'block';
+ }
+
+ // Update status message
+ updateStatusMessage(message, type) {
+ if (!this.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';
+
+ this.planMatchStatus.innerHTML = `${message}`;
+ this.planMatchStatus.className = `alert ${alertClass} mb-3`;
+ this.planMatchStatus.style.display = 'block';
+ }
+
+ // Show error message
+ showError(message) {
+ if (this.planMatchStatus) {
+ this.planMatchStatus.innerHTML = `${message}`;
+ this.planMatchStatus.className = 'alert alert-danger mb-3';
+ this.planMatchStatus.style.display = 'block';
+ }
+ }
+}
+
+// Initialize calculator when DOM is loaded
+document.addEventListener('DOMContentLoaded', () => {
+ // Only initialize if we're on an offering detail page with pricing calculator
+ if (document.getElementById('cpuRange')) {
+ new PriceCalculator();
+ }
+});
\ No newline at end of file
diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html
index 77b4413..92f5c28 100644
--- a/hub/services/templates/services/offering_detail.html
+++ b/hub/services/templates/services/offering_detail.html
@@ -4,6 +4,11 @@
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
+{% block extra_js %}
+
+
+{% endblock %}
+
{% block content %}
@@ -152,76 +157,153 @@
{% endif %}
-
+
{% if offering.msp == "VS" and pricing_data_by_group_and_service_level %}
-
-
Service Plans
-
- {% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
-
-
-
-
-
-
- {% comment %} Display group description from first available plan {% endcomment %}
- {% for service_level, pricing_data in service_levels.items %}
- {% if pricing_data and forloop.first %}
- {% with pricing_data.0 as representative_plan %}
- {% if representative_plan.compute_plan_group_description %}
-