From 475a4643fdc2067e892816951b51c5069f85deef Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 2 Jun 2025 16:22:54 +0200 Subject: [PATCH] frontend price calculator --- hub/services/static/css/price-calculator.css | 36 ++ hub/services/static/js/price-calculator.js | 411 ++++++++++++++++++ .../templates/services/offering_detail.html | 208 ++++++--- hub/services/views/offerings.py | 26 +- 4 files changed, 617 insertions(+), 64 deletions(-) create mode 100644 hub/services/static/css/price-calculator.css create mode 100644 hub/services/static/js/price-calculator.js 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 %} -

{{ representative_plan.compute_plan_group_description }}

- {% endif %} - {% endwith %} - {% endif %} - {% if forloop.first %} - {% comment %} Only show description for first service level {% endcomment %} - {% endif %} - {% endfor %} + +

Configure Your Plan

+
+
+ +
+
+
+
Customize Your Configuration
- {% for service_level, pricing_data in service_levels.items %} -
-

{{ service_level }}

- {% if pricing_data %} -
- - - - - - - - - - - - - - {% for row in pricing_data %} - - - - - - - - - - {% endfor %} - -
Compute PlanvCPUsRAM (GB)CurrencyCompute PriceService PriceTotal Price
{{ row.compute_plan }}{{ row.vcpus }}{{ row.ram }}{{ row.currency }}{{ row.compute_plan_price|floatformat:2 }}{{ row.sla_price|floatformat:2 }}{{ row.final_price|floatformat:2 }}
-
- {% else %} -

No pricing data available for {{ service_level }}.

- {% endif %} + +
+ + +
+ 1 + 32
- {% endfor %} +
+ + +
+ + +
+ 1 GB + 128 GB +
+
+ + +
+ + +
+ 10 GB + 1000 GB +
+
+ + +
+ +
+ + + + + +
+
+ + +
+ + + Selecting a plan will override the slider configuration +
- {% endfor %} + + +
+
+
+
Your Configuration
+ + +
+ + Finding best matching plan... +
+ + + + + + +
+
+
+
+
+ + + {% elif offering.plans.all %} diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index a7df1cc..68de2d4 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -1,9 +1,11 @@ import re import yaml +import json +from decimal import Decimal from django.shortcuts import render, get_object_or_404 from django.db.models import Q -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.template.loader import render_to_string from hub.services.models import ( ServiceOffering, @@ -17,6 +19,17 @@ from collections import defaultdict from markdownify import markdownify +def decimal_to_float(obj): + """Convert Decimal objects to float for JSON serialization""" + if isinstance(obj, Decimal): + return float(obj) + elif isinstance(obj, dict): + return {key: decimal_to_float(value) for key, value in obj.items()} + elif isinstance(obj, list): + return [decimal_to_float(item) for item in obj] + return obj + + def natural_sort_key(name): """Extract numeric part from compute plan name for natural sorting""" match = re.search(r"compute-std-(\d+)", name) @@ -84,6 +97,17 @@ def offering_detail(request, provider_slug, service_slug): service__slug=service_slug, ) + # Check if JSON pricing data is requested + if request.GET.get("pricing") == "json": + pricing_data = None + if offering.msp == "VS": + pricing_data = generate_pricing_data(offering) + if pricing_data: + # Convert Decimal objects to float for JSON serialization + pricing_data = decimal_to_float(pricing_data) + + return JsonResponse(pricing_data or {}) + # Check if Exoscale marketplace YAML is requested if request.GET.get("exo_marketplace") == "true": return generate_exoscale_marketplace_yaml(offering)