diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js
index a65a9f2..30a581a 100644
--- a/hub/services/static/js/price-calculator.js
+++ b/hub/services/static/js/price-calculator.js
@@ -10,6 +10,7 @@ class PriceCalculator {
this.currentOffering = null;
this.selectedConfiguration = null;
this.replicaInfo = null;
+ this.addonsData = null;
this.init();
}
@@ -50,6 +51,10 @@ class PriceCalculator {
this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
this.planSelect = document.getElementById('planSelect');
+ // Addon elements
+ this.addonsContainer = document.getElementById('addonsContainer');
+ this.addonPricingContainer = document.getElementById('addonPricingContainer');
+
// Result display elements
this.planMatchStatus = document.getElementById('planMatchStatus');
this.selectedPlanDetails = document.getElementById('selectedPlanDetails');
@@ -156,25 +161,36 @@ class PriceCalculator {
storage: config.storage,
instances: config.instances,
serviceLevel: config.serviceLevel,
- totalPrice: config.totalPrice
+ totalPrice: config.totalPrice,
+ addons: config.addons || []
});
}
}
// Generate human-readable configuration message
generateConfigurationMessage(config) {
- return `I would like to order the following configuration:
+ 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}
+Service Level: ${config.serviceLevel}`;
-Total Monthly Price: CHF ${config.totalPrice}
+ // 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;
}
// Load pricing data from API endpoint
@@ -185,13 +201,18 @@ Please contact me with next steps for ordering this configuration.`;
throw new Error('Failed to load pricing data');
}
- this.pricingData = await response.json();
+ const data = await response.json();
+ this.pricingData = data.pricing || data;
+
+ // Extract addons data from the plans - addons are embedded in each plan
+ this.extractAddonsData();
// Extract storage price from the first available plan
this.extractStoragePrice();
this.setupEventListeners();
this.populatePlanDropdown();
+ this.updateAddons();
this.updatePricing();
} catch (error) {
console.error('Error loading pricing data:', error);
@@ -220,6 +241,50 @@ Please contact me with next steps for ordering this configuration.`;
}
}
+ // 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;
+ }
+ });
+ });
+ }
+
// Setup event listeners for calculator controls
setupEventListeners() {
if (!this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return;
@@ -253,6 +318,7 @@ Please contact me with next steps for ordering this configuration.`;
input.addEventListener('change', () => {
this.updateInstancesSlider();
this.populatePlanDropdown();
+ this.updateAddons();
this.updatePricing();
});
});
@@ -269,8 +335,10 @@ Please contact me with next steps for ordering this configuration.`;
this.cpuValue.textContent = selectedPlan.vcpus;
this.memoryValue.textContent = selectedPlan.ram;
+ this.updateAddons();
this.updatePricingWithPlan(selectedPlan);
} else {
+ this.updateAddons();
this.updatePricing();
}
});
@@ -356,6 +424,7 @@ Please contact me with next steps for ordering this configuration.`;
input.addEventListener('change', () => {
this.updateInstancesSlider();
this.populatePlanDropdown();
+ this.updateAddons();
this.updatePricing();
});
@@ -445,6 +514,103 @@ Please contact me with next steps for ordering this configuration.`;
});
}
+ // Update addons based on current configuration
+ updateAddons() {
+ if (!this.addonsContainer || !this.addonsData) return;
+
+ const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
+ if (!serviceLevel || !this.addonsData[serviceLevel]) return;
+
+ const addons = this.addonsData[serviceLevel];
+
+ // Clear existing addons
+ this.addonsContainer.innerHTML = '';
+
+ // 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 = `
+
+ `;
+
+ this.addonsContainer.appendChild(addonElement);
+
+ // Add event listener for optional addons
+ if (!addon.is_mandatory) {
+ const checkbox = addonElement.querySelector('.addon-checkbox');
+ checkbox.addEventListener('change', () => {
+ this.updatePricing();
+ });
+ }
+ });
+
+ // Update addon prices
+ this.updateAddonPrices();
+ } // Update addon prices based on current configuration
+ updateAddonPrices() {
+ if (!this.addonsContainer) return;
+
+ const cpus = parseInt(this.cpuRange?.value || 2);
+ const memory = parseInt(this.memoryRange?.value || 4);
+ const storage = parseInt(this.storageRange?.value || 20);
+ const instances = parseInt(this.instancesRange?.value || 1);
+
+ // Find the current plan data to get variable_unit
+ const matchedPlan = this.getCurrentPlan();
+ const variableUnit = matchedPlan?.variable_unit || 'CPU';
+ const units = variableUnit === 'CPU' ? cpus : memory;
+ const totalUnits = units * instances;
+
+ const addonCheckboxes = this.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;
+ if (addon.addon_type === 'BASE_FEE') {
+ calculatedPrice = parseFloat(addon.price || 0) * instances;
+ } else if (addon.addon_type === 'UNIT_RATE') {
+ calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits;
+ }
+
+ if (priceElement) {
+ priceElement.textContent = calculatedPrice.toFixed(2);
+ }
+
+ // Update the checkbox data for later calculation
+ checkbox.dataset.calculatedPrice = calculatedPrice.toString();
+ });
+ }
+
+ // Get current plan based on configuration
+ getCurrentPlan() {
+ const cpus = parseInt(this.cpuRange?.value || 2);
+ const memory = parseInt(this.memoryRange?.value || 4);
+ const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
+
+ if (this.planSelect?.value) {
+ return JSON.parse(this.planSelect.value);
+ }
+
+ return this.findBestMatchingPlan(cpus, memory, serviceLevel);
+ }
+
// Find best matching plan based on requirements
findBestMatchingPlan(cpus, memory, serviceLevel) {
if (!this.pricingData) return null;
@@ -488,6 +654,9 @@ Please contact me with next steps for ordering this configuration.`;
const storage = parseInt(this.storageRange?.value || 20);
const instances = parseInt(this.instancesRange?.value || 1);
+ // Update addon prices first to ensure calculated prices are current
+ this.updateAddonPrices();
+
this.showPlanDetails(selectedPlan, storage, instances);
this.updateStatusMessage('Plan selected directly!', 'success');
}
@@ -496,6 +665,9 @@ Please contact me with next steps for ordering this configuration.`;
updatePricing() {
if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return;
+ // Update addon prices first
+ this.updateAddonPrices();
+
// Reset plan selection if in auto-select mode
if (!this.planSelect?.value) {
const cpus = parseInt(this.cpuRange.value);
@@ -543,16 +715,57 @@ Please contact me with next steps for ordering this configuration.`;
if (this.planInstances) this.planInstances.textContent = instances;
if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel;
- // Calculate pricing using storage price from the plan data
- const computePriceValue = parseFloat(plan.compute_plan_price);
- const servicePriceValue = parseFloat(plan.sla_price);
- const managedServicePricePerInstance = computePriceValue + servicePriceValue;
+ // Calculate pricing using final price from plan data (which already includes mandatory addons)
+ // plan.final_price = compute_plan_price + sla_price (where sla_price includes mandatory addons)
+ const managedServicePricePerInstance = parseFloat(plan.final_price);
+
+ // Collect mandatory addons for display (but don't add to price since they're already included)
+ let mandatoryAddonTotal = 0;
+ const mandatoryAddons = [];
+
+ if (this.addonsContainer) {
+ const addonCheckboxes = this.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) {
+ // Don't add to mandatoryAddonTotal since it's already in plan.final_price
+ mandatoryAddons.push({
+ name: addon.name,
+ price: calculatedPrice.toFixed(2)
+ });
+ }
+ });
+ }
+
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.storagePrice;
const storagePriceValue = storage * storageUnitPrice * instances;
- const totalPriceValue = managedServicePrice + storagePriceValue;
+
+ // Calculate optional addon total
+ let optionalAddonTotal = 0;
+ const selectedOptionalAddons = [];
+
+ if (this.addonsContainer) {
+ const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox:checked');
+ addonCheckboxes.forEach(checkbox => {
+ const addon = JSON.parse(checkbox.dataset.addon);
+ const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0);
+
+ if (!addon.is_mandatory) {
+ optionalAddonTotal += calculatedPrice;
+ selectedOptionalAddons.push({
+ name: addon.name,
+ price: calculatedPrice.toFixed(2)
+ });
+ }
+ });
+ }
+
+ const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal;
// Update pricing display
if (this.managedServicePrice) this.managedServicePrice.textContent = managedServicePrice.toFixed(2);
@@ -560,6 +773,9 @@ Please contact me with next steps for ordering this configuration.`;
if (this.storageAmount) this.storageAmount.textContent = storage;
if (this.totalPrice) this.totalPrice.textContent = totalPriceValue.toFixed(2);
+ // Update addon pricing display
+ this.updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons);
+
// Store current configuration for order button
this.selectedConfiguration = {
planName: plan.compute_plan,
@@ -569,10 +785,45 @@ Please contact me with next steps for ordering this configuration.`;
storage: storage,
instances: instances,
serviceLevel: serviceLevel,
- totalPrice: totalPriceValue.toFixed(2)
+ totalPrice: totalPriceValue.toFixed(2),
+ addons: [...mandatoryAddons, ...selectedOptionalAddons]
};
}
+ // Update addon pricing display in the results panel
+ updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons) {
+ if (!this.addonPricingContainer) return;
+
+ // Clear existing addon pricing display
+ this.addonPricingContainer.innerHTML = '';
+
+ // Add mandatory addons to pricing breakdown
+ if (mandatoryAddons && mandatoryAddons.length > 0) {
+ mandatoryAddons.forEach(addon => {
+ const addonRow = document.createElement('div');
+ addonRow.className = 'd-flex justify-content-between mb-2';
+ addonRow.innerHTML = `
+ Add-on: ${addon.name} (Required)
+ CHF ${addon.price}
+ `;
+ this.addonPricingContainer.appendChild(addonRow);
+ });
+ }
+
+ // Add optional addons to pricing breakdown
+ if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
+ selectedOptionalAddons.forEach(addon => {
+ const addonRow = document.createElement('div');
+ addonRow.className = 'd-flex justify-content-between mb-2';
+ addonRow.innerHTML = `
+ Add-on: ${addon.name}
+ CHF ${addon.price}
+ `;
+ this.addonPricingContainer.appendChild(addonRow);
+ });
+ }
+ }
+
// Show no matching plan found
showNoMatch() {
if (this.planMatchStatus) this.planMatchStatus.style.display = 'none';
diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html
index 1d5e5af..b8b5a61 100644
--- a/hub/services/templates/services/offering_detail.html
+++ b/hub/services/templates/services/offering_detail.html
@@ -271,7 +271,7 @@
-
+
@@ -460,342 +460,4 @@
-
-
-
-
{% endblock %}
\ No newline at end of file
diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py
index 8a2c0d1..fc5c59e 100644
--- a/hub/services/views/offerings.py
+++ b/hub/services/views/offerings.py
@@ -377,6 +377,7 @@ def generate_pricing_data(offering):
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)
@@ -392,6 +393,7 @@ def generate_pricing_data(offering):
"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:
@@ -428,6 +430,9 @@ def generate_pricing_data(offering):
"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,
}