frontend price calculator
This commit is contained in:
parent
4f9a39fd36
commit
475a4643fd
4 changed files with 617 additions and 64 deletions
36
hub/services/static/css/price-calculator.css
Normal file
36
hub/services/static/css/price-calculator.css
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
411
hub/services/static/js/price-calculator.js
Normal file
411
hub/services/static/js/price-calculator.js
Normal file
|
@ -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 = '<option value="">Auto-select best matching plan</option>';
|
||||||
|
|
||||||
|
// 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 = `<i class="bi ${iconClass} me-2 ${textClass}"></i><span class="${textClass}">${message}</span>`;
|
||||||
|
this.planMatchStatus.className = `alert ${alertClass} mb-3`;
|
||||||
|
this.planMatchStatus.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error message
|
||||||
|
showError(message) {
|
||||||
|
if (this.planMatchStatus) {
|
||||||
|
this.planMatchStatus.innerHTML = `<i class="bi bi-exclamation-triangle me-2 text-danger"></i><span class="text-danger">${message}</span>`;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
|
@ -4,6 +4,11 @@
|
||||||
|
|
||||||
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
|
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script defer src="{% static "js/price-calculator.js" %}"></script>
|
||||||
|
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section bg-primary-subtle">
|
<section class="section bg-primary-subtle">
|
||||||
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
|
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
|
||||||
|
@ -152,76 +157,153 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Plans or Service Plans -->
|
<!-- Price Calculator -->
|
||||||
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
|
<div class="pt-24" id="plans" style="scroll-margin-top: 30px;">
|
||||||
{% if offering.msp == "VS" and pricing_data_by_group_and_service_level %}
|
{% if offering.msp == "VS" and pricing_data_by_group_and_service_level %}
|
||||||
<!-- Service Plans with Pricing Data -->
|
<!-- Interactive Price Calculator -->
|
||||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Service Plans</h3>
|
<h3 class="fs-24 fw-semibold lh-1 mb-12">Configure Your Plan</h3>
|
||||||
<div class="accordion" id="servicePlansAccordion">
|
<div class="bg-light rounded-4 p-4 mb-4">
|
||||||
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
|
<div class="row">
|
||||||
<div class="accordion-item">
|
<!-- Calculator Controls -->
|
||||||
<h2 class="accordion-header" id="heading{{ forloop.counter }}">
|
<div class="col-12 col-lg-6">
|
||||||
<button class="accordion-button{% if not forloop.first %} collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="{% if forloop.first %}true{% else %}false{% endif %}" aria-controls="collapse{{ forloop.counter }}">
|
<div class="card h-100">
|
||||||
<strong>{{ group_name }}</strong>
|
<div class="card-body">
|
||||||
</button>
|
<h5 class="card-title mb-4">Customize Your Configuration</h5>
|
||||||
</h2>
|
|
||||||
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse{% if forloop.first %} show{% endif %}" aria-labelledby="heading{{ forloop.counter }}" data-bs-parent="#servicePlansAccordion">
|
|
||||||
<div class="accordion-body">
|
|
||||||
{% 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 %}
|
|
||||||
<p class="text-muted mb-3">{{ representative_plan.compute_plan_group_description }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
{% if forloop.first %}
|
|
||||||
{% comment %} Only show description for first service level {% endcomment %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% for service_level, pricing_data in service_levels.items %}
|
<!-- CPU Slider -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="mb-3 text-primary">{{ service_level }}</h4>
|
<label for="cpuRange" class="form-label d-flex justify-content-between">
|
||||||
{% if pricing_data %}
|
<span>vCPUs</span>
|
||||||
<div class="table-responsive">
|
<span class="fw-bold" id="cpuValue">2</span>
|
||||||
<table class="table table-striped table-sm">
|
</label>
|
||||||
<thead class="table-dark">
|
<input type="range" class="form-range" id="cpuRange" min="1" max="32" value="2" step="1">
|
||||||
<tr>
|
<div class="d-flex justify-content-between text-muted small">
|
||||||
<th>Compute Plan</th>
|
<span>1</span>
|
||||||
<th>vCPUs</th>
|
<span>32</span>
|
||||||
<th>RAM (GB)</th>
|
|
||||||
<th>Currency</th>
|
|
||||||
<th>Compute Price</th>
|
|
||||||
<th>Service Price</th>
|
|
||||||
<th class="table-warning">Total Price</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in pricing_data %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.compute_plan }}</td>
|
|
||||||
<td>{{ row.vcpus }}</td>
|
|
||||||
<td>{{ row.ram }}</td>
|
|
||||||
<td>{{ row.currency }}</td>
|
|
||||||
<td>{{ row.compute_plan_price|floatformat:2 }}</td>
|
|
||||||
<td>{{ row.sla_price|floatformat:2 }}</td>
|
|
||||||
<td class="table-warning fw-bold">{{ row.final_price|floatformat:2 }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-muted">No pricing data available for {{ service_level }}.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
</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">4</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" class="form-range" id="memoryRange" min="1" max="128" value="4" step="1">
|
||||||
|
<div class="d-flex justify-content-between text-muted small">
|
||||||
|
<span>1 GB</span>
|
||||||
|
<span>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>10 GB</span>
|
||||||
|
<span>1000 GB</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">
|
||||||
|
<label class="btn btn-outline-primary" for="serviceLevelGuaranteed">Guaranteed Availability</label>
|
||||||
|
</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>
|
||||||
|
<small class="form-text text-muted">Selecting a plan will override the slider configuration</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
|
<!-- 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 Configuration</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-6">
|
||||||
|
<small class="text-muted">vCPUs</small>
|
||||||
|
<div class="fw-bold" id="planCpus"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<small class="text-muted">Memory</small>
|
||||||
|
<div class="fw-bold" id="planMemory"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pricing Breakdown -->
|
||||||
|
<div class="border-top pt-3">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span>Compute Plan</span>
|
||||||
|
<span class="fw-bold">CHF <span id="computePrice">0.00</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span>Service Price</span>
|
||||||
|
<span class="fw-bold">CHF <span id="servicePrice">0.00</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span>Storage (<span id="storageAmount">20</span> GB)</span>
|
||||||
|
<span class="fw-bold">CHF <span id="storagePrice">0.00</span></span>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="fs-5 fw-bold">Total Monthly Price</span>
|
||||||
|
<span class="fs-4 fw-bold text-primary">CHF <span id="totalPrice">0.00</span></span>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Button -->
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="#interest" 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>
|
</div>
|
||||||
{% elif offering.plans.all %}
|
{% elif offering.plans.all %}
|
||||||
<!-- Traditional Plans -->
|
<!-- Traditional Plans -->
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import re
|
import re
|
||||||
import yaml
|
import yaml
|
||||||
|
import json
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.db.models import Q
|
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 django.template.loader import render_to_string
|
||||||
from hub.services.models import (
|
from hub.services.models import (
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
|
@ -17,6 +19,17 @@ from collections import defaultdict
|
||||||
from markdownify import markdownify
|
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):
|
def natural_sort_key(name):
|
||||||
"""Extract numeric part from compute plan name for natural sorting"""
|
"""Extract numeric part from compute plan name for natural sorting"""
|
||||||
match = re.search(r"compute-std-(\d+)", name)
|
match = re.search(r"compute-std-(\d+)", name)
|
||||||
|
@ -84,6 +97,17 @@ def offering_detail(request, provider_slug, service_slug):
|
||||||
service__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
|
# Check if Exoscale marketplace YAML is requested
|
||||||
if request.GET.get("exo_marketplace") == "true":
|
if request.GET.get("exo_marketplace") == "true":
|
||||||
return generate_exoscale_marketplace_yaml(offering)
|
return generate_exoscale_marketplace_yaml(offering)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue