diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js
index 1fa9e2b..c2c8eb1 100644
--- a/hub/services/static/js/price-calculator.js
+++ b/hub/services/static/js/price-calculator.js
@@ -1,1899 +1,82 @@
/**
- * Price Calculator for Service Offerings
- * Handles interactive pricing calculation with sliders and plan selection
+ * Price Calculator Module Loader
+ * Loads the modular price calculator components
+ * The original monolithic code has been split into multiple files for better maintainability
*/
-class PriceCalculator {
- constructor() {
- this.pricingData = null;
- this.storagePrice = null;
- this.currentOffering = null;
- this.selectedConfiguration = null;
- this.replicaInfo = null;
- this.addonsData = null;
- this.init();
+(function () {
+ 'use strict';
+
+ // Check if we're on a page that needs the price calculator
+ if (!document.getElementById('cpuRange')) {
+ return;
}
- // 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();
- }
-
- // Setup order button click handler
- this.setupOrderButton();
- }
-
- // Initialize DOM element references
- initElements() {
- // Calculator controls
- this.cpuRange = document.getElementById('cpuRange');
- this.memoryRange = document.getElementById('memoryRange');
- this.storageRange = document.getElementById('storageRange');
- this.instancesRange = document.getElementById('instancesRange');
- this.cpuValue = document.getElementById('cpuValue');
- this.memoryValue = document.getElementById('memoryValue');
- this.storageValue = document.getElementById('storageValue');
- this.instancesValue = document.getElementById('instancesValue');
- this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
- this.planSelect = document.getElementById('planSelect');
-
- // Addon elements
- this.addonsContainer = document.getElementById('addonsContainer');
- this.addonPricingContainer = document.getElementById('addonPricingContainer');
- this.managedServiceIncludesContainer = document.getElementById('managedServiceIncludesContainer');
-
- // 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.planInstances = document.getElementById('planInstances');
- this.planServiceLevel = document.getElementById('planServiceLevel');
- this.managedServicePrice = document.getElementById('managedServicePrice');
- this.storagePriceEl = document.getElementById('storagePrice');
- this.storageAmount = document.getElementById('storageAmount');
- this.totalPrice = document.getElementById('totalPrice');
-
- // Order button
- this.orderButton = document.querySelector('a[href="#order-form"]');
- }
-
- // Update slider display values (min/max text below sliders)
- updateSliderDisplayValues() {
- // Update CPU slider display
- if (this.cpuRange) {
- const cpuMinDisplay = document.getElementById('cpuMinDisplay');
- const cpuMaxDisplay = document.getElementById('cpuMaxDisplay');
- if (cpuMinDisplay) cpuMinDisplay.textContent = this.cpuRange.min;
- if (cpuMaxDisplay) cpuMaxDisplay.textContent = this.cpuRange.max;
- }
-
- // Update Memory slider display
- if (this.memoryRange) {
- const memoryMinDisplay = document.getElementById('memoryMinDisplay');
- const memoryMaxDisplay = document.getElementById('memoryMaxDisplay');
- if (memoryMinDisplay) memoryMinDisplay.textContent = this.memoryRange.min + ' GB';
- if (memoryMaxDisplay) memoryMaxDisplay.textContent = this.memoryRange.max + ' GB';
- }
-
- // Update Storage slider display
- if (this.storageRange) {
- const storageMinDisplay = document.getElementById('storageMinDisplay');
- const storageMaxDisplay = document.getElementById('storageMaxDisplay');
- if (storageMinDisplay) storageMinDisplay.textContent = this.storageRange.min + ' GB';
- if (storageMaxDisplay) storageMaxDisplay.textContent = this.storageRange.max + ' GB';
- }
-
- // Update Instances slider display
- if (this.instancesRange) {
- const instancesMinDisplay = document.getElementById('instancesMinDisplay');
- const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
- if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min;
- if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max;
- }
- }
-
- // Setup order button click handler
- setupOrderButton() {
- if (this.orderButton) {
- this.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' });
+ // Get the current script's directory
+ const getCurrentScriptPath = () => {
+ const scripts = document.getElementsByTagName('script');
+ // Find the script that contains 'price-calculator.js'
+ for (let script of scripts) {
+ if (script.src.includes('price-calculator.js')) {
+ const scriptPath = script.src;
+ const lastSlash = scriptPath.lastIndexOf('/');
+ return scriptPath.substring(0, lastSlash + 1);
}
}
- }
+ return '/static/js/';
+ };
- // Pre-fill contact form with selected configuration
- prefillContactForm() {
- if (!this.selectedConfiguration) return;
+ // Define the modules to load in dependency order
+ const modules = [
+ 'price-calculator/dom-manager.js',
+ 'price-calculator/pricing-data-manager.js',
+ 'price-calculator/plan-manager.js',
+ 'price-calculator/addon-manager.js',
+ 'price-calculator/ui-manager.js',
+ 'price-calculator/order-manager.js',
+ 'price-calculator/price-calculator.js'
+ ];
- const config = this.selectedConfiguration;
+ // Helper function to load a script
+ const loadScript = (src) => {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = src;
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ };
- // Create configuration summary message
- const configMessage = this.generateConfigurationMessage(config);
+ // Load modules sequentially
+ const loadModules = async () => {
+ const basePath = getCurrentScriptPath();
- // Find and fill the message textarea in the contact form
- const messageField = document.querySelector('#order-form textarea[name="message"]');
- if (messageField) {
- messageField.value = configMessage;
- }
-
- // Store configuration details in hidden field
- 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;
- }
-
- // 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');
- }
-
- 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);
- this.showError('Failed to load pricing information');
- }
- }
-
- // 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;
- }
+ for (const module of modules) {
+ try {
+ await loadScript(basePath + module);
+ } catch (error) {
+ console.error(`Failed to load module: ${module}`, error);
}
}
- }
- // 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;
-
- // 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();
- });
-
- this.instancesRange.addEventListener('input', () => {
- this.instancesValue.textContent = this.instancesRange.value;
- this.updatePricing();
- });
-
- // Service level change listeners
- this.serviceLevelInputs.forEach(input => {
- input.addEventListener('change', () => {
- this.updateInstancesSlider();
- this.populatePlanDropdown();
- this.updateAddons();
- 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;
-
- // Fade out CPU and Memory sliders since plan is manually selected
- this.fadeOutSliders(['cpu', 'memory']);
-
- // Update addons for the new configuration
- this.updateAddons();
- // Update pricing with the selected plan
- this.updatePricingWithPlan(selectedPlan);
- } else {
- // Auto-select mode - reset sliders to default values
- this.resetSlidersToDefaults();
-
- // Auto-select mode - fade sliders back in
- this.fadeInSliders(['cpu', 'memory']);
-
- // Auto-select mode - update addons and recalculate
- this.updateAddons();
- this.updatePricing();
- }
- });
- }
-
- // Initialize instances slider
- this.updateInstancesSlider();
- }
-
- // Update instances slider based on service level and replica info
- updateInstancesSlider() {
- if (!this.instancesRange || !this.replicaInfo) return;
-
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
-
- if (serviceLevel === 'Guaranteed Availability') {
- // For GA, min is ha_replica_min
- this.instancesRange.min = this.replicaInfo.ha_replica_min;
- this.instancesRange.value = Math.max(this.instancesRange.value, this.replicaInfo.ha_replica_min);
+ // Initialize the calculator after modules are loaded
+ if (window.PriceCalculator) {
+ window.priceCalculator = new window.PriceCalculator();
} else {
- // For BE, min is 1
- this.instancesRange.min = 1;
- this.instancesRange.value = Math.max(this.instancesRange.value, 1);
+ console.error('PriceCalculator class not found after module loading');
}
+ };
- // Set max to ha_replica_max
- this.instancesRange.max = this.replicaInfo.ha_replica_max;
-
- // Update display value
- this.instancesValue.textContent = this.instancesRange.value;
-
- // Update the min/max display under the slider using direct IDs
- const instancesMinDisplay = document.getElementById('instancesMinDisplay');
- const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
-
- if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min;
- if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max;
+ // Start loading modules when DOM is ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', loadModules);
+ } else {
+ loadModules();
}
+})();
- // 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.updateInstancesSlider();
- this.populatePlanDropdown();
- this.updateAddons();
- 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 will call updateSliderDisplayValues()
- 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.min = "0.25";
- this.cpuRange.max = Math.ceil(maxCpus);
- }
-
- if (maxMemory > 0) {
- this.memoryRange.min = "0.25";
- this.memoryRange.max = Math.ceil(maxMemory);
- }
-
- // Update display values after changing min/max - moved to end and call explicitly
- this.updateSliderDisplayValues();
- }
-
- // 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 (parseFloat(a.vcpus) !== parseFloat(b.vcpus)) {
- return parseFloat(a.vcpus) - parseFloat(b.vcpus);
- }
- return parseFloat(a.ram) - parseFloat(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`;
- this.planSelect.appendChild(option);
- });
- }
-
- // Update addons based on current configuration
- updateAddons() {
- if (!this.addonsContainer || !this.addonsData) {
- // Hide addons section if no container or data
- const addonsSection = document.getElementById('addonsSection');
- if (addonsSection) addonsSection.style.display = 'none';
- return;
- }
-
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
- if (!serviceLevel || !this.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 = this.addonsData[serviceLevel];
-
- // Clear existing addons
- this.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 = `
-
- `;
-
- this.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();
- this.updatePricing();
- });
- }
- });
-
- // Update addon prices
- this.updateAddonPrices();
- } // Update addon prices based on current configuration
- updateAddonPrices() {
- if (!this.addonsContainer) return;
-
- const cpus = parseFloat(this.cpuRange?.value || 0.5);
- const memory = parseFloat(this.memoryRange?.value || 1);
- const storage = parseInt(this.storageRange?.value || 20);
- const instances = parseInt(this.instancesRange?.value || 1);
-
- // Find the current plan data to get variable_unit for addon calculations
- 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;
-
- // Calculate addon price based on type
- if (addon.addon_type === 'BASE_FEE') {
- // Base fee: price per instance
- calculatedPrice = parseFloat(addon.price || 0) * 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 current plan based on configuration
- getCurrentPlan() {
- const cpus = parseFloat(this.cpuRange?.value || 0.5);
- const memory = parseFloat(this.memoryRange?.value || 1);
- 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;
-
- 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 = 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;
- }
-
- // Update pricing with specific plan
- updatePricingWithPlan(selectedPlan) {
- 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');
- }
-
- // Main pricing update function
- updatePricing() {
- if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return;
-
- // Update addon prices first to ensure they're current
- this.updateAddonPrices();
-
- // Reset plan selection if in auto-select mode
- if (!this.planSelect?.value) {
- const cpus = parseFloat(this.cpuRange.value);
- const memory = parseFloat(this.memoryRange.value);
- const storage = parseInt(this.storageRange.value);
- const instances = parseInt(this.instancesRange.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, instances);
- this.updateStatusMessage('Perfect match found!', 'success');
- } else {
- this.showNoMatch();
- }
- } else {
- // Plan is directly selected, update storage pricing
- const selectedPlan = JSON.parse(this.planSelect.value);
- const storage = parseInt(this.storageRange.value);
- const instances = parseInt(this.instancesRange.value);
-
- // Update addon prices for current configuration
- this.updateAddonPrices();
- this.showPlanDetails(selectedPlan, storage, instances);
- this.updateStatusMessage('Plan selected directly!', 'success');
- }
- }
-
- // Show plan details in the UI
- showPlanDetails(plan, storage, instances) {
- 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';
-
- // Get current service level
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value || 'Best Effort';
-
- // 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';
- if (this.planInstances) this.planInstances.textContent = instances;
- if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel;
-
- // Ensure addon prices are calculated with current configuration
- this.updateAddonPrices();
-
- // 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 addon information for display and calculation
- let mandatoryAddonTotal = 0;
- let optionalAddonTotal = 0;
- const mandatoryAddons = [];
- const selectedOptionalAddons = [];
-
- 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) {
- // Mandatory addons are already included in plan.final_price
- // We collect them for display purposes only
- mandatoryAddons.push({
- name: addon.name,
- price: calculatedPrice.toFixed(2)
- });
- } else if (checkbox.checked) {
- // Only count checked optional addons
- optionalAddonTotal += calculatedPrice;
- selectedOptionalAddons.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;
-
- // Total price = managed service price (includes mandatory addons) + storage + optional addons
- const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal;
-
- // Update pricing display
- if (this.managedServicePrice) this.managedServicePrice.textContent = managedServicePrice.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);
-
- // Update addon pricing display
- this.updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons);
-
- // Store current configuration for order button
- this.selectedConfiguration = {
- planName: plan.compute_plan,
- planGroup: plan.groupName,
- vcpus: plan.vcpus,
- memory: plan.ram,
- storage: storage,
- instances: instances,
- serviceLevel: serviceLevel,
- 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 (for informational purposes only)
- if (mandatoryAddons && mandatoryAddons.length > 0) {
- // Add a note explaining mandatory addons are included
- const mandatoryNote = document.createElement('div');
- mandatoryNote.className = 'text-muted small mb-2';
- mandatoryNote.innerHTML = 'Required add-ons (included in managed service price):';
- this.addonPricingContainer.appendChild(mandatoryNote);
-
- mandatoryAddons.forEach(addon => {
- const addonRow = document.createElement('div');
- addonRow.className = 'd-flex justify-content-between mb-1 ps-3';
- addonRow.innerHTML = `
- ${addon.name}
- CHF ${addon.price}
- `;
- this.addonPricingContainer.appendChild(addonRow);
- });
-
- // Add separator if there are also optional addons
- if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
- const separator = document.createElement('hr');
- separator.className = 'my-2';
- this.addonPricingContainer.appendChild(separator);
- }
- }
-
- // 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 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';
- 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';
- }
- }
-
- // Fade out specified sliders when plan is manually selected
- fadeOutSliders(sliderTypes) {
- sliderTypes.forEach(type => {
- const sliderContainer = this.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';
- }
- }
- });
- }
-
- // Fade in specified sliders when auto-select mode is chosen
- fadeInSliders(sliderTypes) {
- sliderTypes.forEach(type => {
- const sliderContainer = this.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';
- }
- }
- });
- }
-
- // Get slider container element by type
- getSliderContainer(type) {
- switch (type) {
- case 'cpu':
- return this.cpuRange?.closest('.mb-4');
- case 'memory':
- return this.memoryRange?.closest('.mb-4');
- case 'storage':
- return this.storageRange?.closest('.mb-4');
- case 'instances':
- return this.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.cpuRange) {
- this.cpuRange.value = '0.5';
- if (this.cpuValue) this.cpuValue.textContent = '0.5';
- }
-
- // Reset Memory slider to default value (1 GB)
- if (this.memoryRange) {
- this.memoryRange.value = '1';
- if (this.memoryValue) this.memoryValue.textContent = '1';
- }
-
- // Reset Storage slider to default value (20 GB)
- if (this.storageRange) {
- this.storageRange.value = '20';
- if (this.storageValue) this.storageValue.textContent = '20';
- }
-
- // Reset Instances slider to default value (1)
- if (this.instancesRange) {
- this.instancesRange.value = '1';
- if (this.instancesValue) this.instancesValue.textContent = '1';
- }
- }
-
- // Setup order button click handler
- setupOrderButton() {
- if (this.orderButton) {
- this.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;
- }
-
- // Store configuration details in hidden field
- 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;
- }
-
- // 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');
- }
-
- 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);
- this.showError('Failed to load pricing information');
- }
- }
-
- // 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;
- }
- });
- });
- }
-
- // Setup event listeners for calculator controls
- setupEventListeners() {
- if (!this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) 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();
- });
-
- this.instancesRange.addEventListener('input', () => {
- this.instancesValue.textContent = this.instancesRange.value;
- this.updatePricing();
- });
-
- // Service level change listeners
- this.serviceLevelInputs.forEach(input => {
- input.addEventListener('change', () => {
- this.updateInstancesSlider();
- this.populatePlanDropdown();
- this.updateAddons();
- 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;
-
- // Fade out CPU and Memory sliders since plan is manually selected
- this.fadeOutSliders(['cpu', 'memory']);
-
- // Update addons for the new configuration
- this.updateAddons();
- // Update pricing with the selected plan
- this.updatePricingWithPlan(selectedPlan);
- } else {
- // Auto-select mode - reset sliders to default values
- this.resetSlidersToDefaults();
-
- // Auto-select mode - fade sliders back in
- this.fadeInSliders(['cpu', 'memory']);
-
- // Auto-select mode - update addons and recalculate
- this.updateAddons();
- this.updatePricing();
- }
- });
- }
-
- // Initialize instances slider
- this.updateInstancesSlider();
- }
-
- // Update instances slider based on service level and replica info
- updateInstancesSlider() {
- if (!this.instancesRange || !this.replicaInfo) return;
-
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
-
- if (serviceLevel === 'Guaranteed Availability') {
- // For GA, min is ha_replica_min
- this.instancesRange.min = this.replicaInfo.ha_replica_min;
- this.instancesRange.value = Math.max(this.instancesRange.value, this.replicaInfo.ha_replica_min);
- } else {
- // For BE, min is 1
- this.instancesRange.min = 1;
- this.instancesRange.value = Math.max(this.instancesRange.value, 1);
- }
-
- // Set max to ha_replica_max
- this.instancesRange.max = this.replicaInfo.ha_replica_max;
-
- // Update display value
- this.instancesValue.textContent = this.instancesRange.value;
-
- // Update the min/max display under the slider using direct IDs
- const instancesMinDisplay = document.getElementById('instancesMinDisplay');
- const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
-
- if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min;
- if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max;
- }
-
- // 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.updateInstancesSlider();
- this.populatePlanDropdown();
- this.updateAddons();
- 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 will call updateSliderDisplayValues()
- 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.min = "0.25";
- this.cpuRange.max = Math.ceil(maxCpus);
- }
-
- if (maxMemory > 0) {
- this.memoryRange.min = "0.25";
- this.memoryRange.max = Math.ceil(maxMemory);
- }
-
- // Update display values after changing min/max - moved to end and call explicitly
- this.updateSliderDisplayValues();
- }
-
- // 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 (parseFloat(a.vcpus) !== parseFloat(b.vcpus)) {
- return parseFloat(a.vcpus) - parseFloat(b.vcpus);
- }
- return parseFloat(a.ram) - parseFloat(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`;
- this.planSelect.appendChild(option);
- });
- }
-
- // Update addons based on current configuration
- updateAddons() {
- if (!this.addonsContainer || !this.addonsData) {
- // Hide addons section if no container or data
- const addonsSection = document.getElementById('addonsSection');
- if (addonsSection) addonsSection.style.display = 'none';
- return;
- }
-
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value;
- if (!serviceLevel || !this.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 = this.addonsData[serviceLevel];
-
- // Clear existing addons
- this.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 = `
-
- `;
-
- this.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();
- this.updatePricing();
- });
- }
- });
-
- // Update addon prices
- this.updateAddonPrices();
- } // Update addon prices based on current configuration
- updateAddonPrices() {
- if (!this.addonsContainer) return;
-
- const cpus = parseFloat(this.cpuRange?.value || 0.5);
- const memory = parseFloat(this.memoryRange?.value || 1);
- const storage = parseInt(this.storageRange?.value || 20);
- const instances = parseInt(this.instancesRange?.value || 1);
-
- // Find the current plan data to get variable_unit for addon calculations
- 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;
-
- // Calculate addon price based on type
- if (addon.addon_type === 'BASE_FEE') {
- // Base fee: price per instance
- calculatedPrice = parseFloat(addon.price || 0) * 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 current plan based on configuration
- getCurrentPlan() {
- const cpus = parseFloat(this.cpuRange?.value || 0.5);
- const memory = parseFloat(this.memoryRange?.value || 1);
- 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;
-
- 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 = 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;
- }
-
- // Update pricing with specific plan
- updatePricingWithPlan(selectedPlan) {
- 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');
- }
-
- // Main pricing update function
- updatePricing() {
- if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return;
-
- // Update addon prices first to ensure they're current
- this.updateAddonPrices();
-
- // Reset plan selection if in auto-select mode
- if (!this.planSelect?.value) {
- const cpus = parseFloat(this.cpuRange.value);
- const memory = parseFloat(this.memoryRange.value);
- const storage = parseInt(this.storageRange.value);
- const instances = parseInt(this.instancesRange.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, instances);
- this.updateStatusMessage('Perfect match found!', 'success');
- } else {
- this.showNoMatch();
- }
- } else {
- // Plan is directly selected, update storage pricing
- const selectedPlan = JSON.parse(this.planSelect.value);
- const storage = parseInt(this.storageRange.value);
- const instances = parseInt(this.instancesRange.value);
-
- // Update addon prices for current configuration
- this.updateAddonPrices();
- this.showPlanDetails(selectedPlan, storage, instances);
- this.updateStatusMessage('Plan selected directly!', 'success');
- }
- }
-
- // Show plan details in the UI
- showPlanDetails(plan, storage, instances) {
- 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';
-
- // Get current service level
- const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value || 'Best Effort';
-
- // 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';
- if (this.planInstances) this.planInstances.textContent = instances;
- if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel;
-
- // Ensure addon prices are calculated with current configuration
- this.updateAddonPrices();
-
- // 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 addon information for display and calculation
- let mandatoryAddonTotal = 0;
- let optionalAddonTotal = 0;
- const mandatoryAddons = [];
- const selectedOptionalAddons = [];
-
- 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) {
- // Mandatory addons are already included in plan.final_price
- // We collect them for display purposes only
- mandatoryAddons.push({
- name: addon.name,
- price: calculatedPrice.toFixed(2)
- });
- } else if (checkbox.checked) {
- // Only count checked optional addons
- optionalAddonTotal += calculatedPrice;
- selectedOptionalAddons.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;
-
- // Total price = managed service price (includes mandatory addons) + storage + optional addons
- const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal;
-
- // Update pricing display
- if (this.managedServicePrice) this.managedServicePrice.textContent = managedServicePrice.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);
-
- // Update addon pricing display
- this.updateAddonPricingDisplay(mandatoryAddons, selectedOptionalAddons);
-
- // Store current configuration for order button
- this.selectedConfiguration = {
- planName: plan.compute_plan,
- planGroup: plan.groupName,
- vcpus: plan.vcpus,
- memory: plan.ram,
- storage: storage,
- instances: instances,
- serviceLevel: serviceLevel,
- 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 (for informational purposes only)
- if (mandatoryAddons && mandatoryAddons.length > 0) {
- // Add a note explaining mandatory addons are included
- const mandatoryNote = document.createElement('div');
- mandatoryNote.className = 'text-muted small mb-2';
- mandatoryNote.innerHTML = 'Required add-ons (included in managed service price):';
- this.addonPricingContainer.appendChild(mandatoryNote);
-
- mandatoryAddons.forEach(addon => {
- const addonRow = document.createElement('div');
- addonRow.className = 'd-flex justify-content-between mb-1 ps-3';
- addonRow.innerHTML = `
- ${addon.name}
- CHF ${addon.price}
- `;
- this.addonPricingContainer.appendChild(addonRow);
- });
-
- // Add separator if there are also optional addons
- if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
- const separator = document.createElement('hr');
- separator.className = 'my-2';
- this.addonPricingContainer.appendChild(separator);
- }
- }
-
- // 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 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';
- 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';
- }
- }
-
- // Fade out specified sliders when plan is manually selected
- fadeOutSliders(sliderTypes) {
- sliderTypes.forEach(type => {
- const sliderContainer = this.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';
- }
- }
- });
- }
-
- // Fade in specified sliders when auto-select mode is chosen
- fadeInSliders(sliderTypes) {
- sliderTypes.forEach(type => {
- const sliderContainer = this.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';
- }
- }
- });
- }
-
- // Get slider container element by type
- getSliderContainer(type) {
- switch (type) {
- case 'cpu':
- return this.cpuRange?.closest('.mb-4');
- case 'memory':
- return this.memoryRange?.closest('.mb-4');
- case 'storage':
- return this.storageRange?.closest('.mb-4');
- case 'instances':
- return this.instancesRange?.closest('.mb-4');
- default:
- return null;
- }
- }
-}
-
-// Override the updateAddonPricingDisplay method to use improved layout
-PriceCalculator.prototype.updateAddonPricingDisplay = function (mandatoryAddons, selectedOptionalAddons) {
- // Handle mandatory addons - show them in the managed service includes section
- if (this.managedServiceIncludesContainer) {
- this.managedServiceIncludesContainer.innerHTML = '';
-
- if (mandatoryAddons && mandatoryAddons.length > 0) {
- mandatoryAddons.forEach(addon => {
- const addonRow = document.createElement('div');
- addonRow.className = 'd-flex justify-content-between mb-1 small text-success';
- addonRow.innerHTML = `
- ${addon.name}
- (CHF ${addon.price})
- `;
- this.managedServiceIncludesContainer.appendChild(addonRow);
- });
- } else {
- // Show basic compute resources when no specific addons
- const basicRow = document.createElement('div');
- basicRow.className = 'small text-success mb-1';
- basicRow.innerHTML = `
- Compute resources & management
- `;
- this.managedServiceIncludesContainer.appendChild(basicRow);
- }
- }
-
- // Handle optional addons - show them in the main addon pricing container
- if (!this.addonPricingContainer) return;
-
- // Clear existing addon pricing display (only for optional addons now)
- this.addonPricingContainer.innerHTML = '';
-
- // Add optional addons to pricing breakdown (these are added to total)
- if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
- // Add a header for optional addons
- const optionalHeader = document.createElement('div');
- optionalHeader.className = 'small text-muted mb-2';
- optionalHeader.innerHTML = 'Additional add-ons:';
- this.addonPricingContainer.appendChild(optionalHeader);
-
- 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);
- });
- }
-};
-
-// 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();
- }
-});
-
+// Global function for traditional plan selection (used by template buttons)
function selectPlan(element) {
const planId = element.getAttribute('data-plan-id');
const planName = element.getAttribute('data-plan-name');
diff --git a/hub/services/static/js/price-calculator/addon-manager.js b/hub/services/static/js/price-calculator/addon-manager.js
new file mode 100644
index 0000000..daf4735
--- /dev/null
+++ b/hub/services/static/js/price-calculator/addon-manager.js
@@ -0,0 +1,176 @@
+/**
+ * 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 = `
+
+ `;
+
+ 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;
diff --git a/hub/services/static/js/price-calculator/dom-manager.js b/hub/services/static/js/price-calculator/dom-manager.js
new file mode 100644
index 0000000..42c4626
--- /dev/null
+++ b/hub/services/static/js/price-calculator/dom-manager.js
@@ -0,0 +1,160 @@
+/**
+ * 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');
+
+ // 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
+ get(key) {
+ return this.elements[key];
+ }
+
+ // Check if element exists
+ 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';
+ }
+ }
+
+ // 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;
diff --git a/hub/services/static/js/price-calculator/order-manager.js b/hub/services/static/js/price-calculator/order-manager.js
new file mode 100644
index 0000000..99ea5a8
--- /dev/null
+++ b/hub/services/static/js/price-calculator/order-manager.js
@@ -0,0 +1,113 @@
+/**
+ * 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;
+ }
+
+ // Store configuration details in hidden field
+ 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;
diff --git a/hub/services/static/js/price-calculator/plan-manager.js b/hub/services/static/js/price-calculator/plan-manager.js
new file mode 100644
index 0000000..9abdc0f
--- /dev/null
+++ b/hub/services/static/js/price-calculator/plan-manager.js
@@ -0,0 +1,104 @@
+/**
+ * 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) return;
+
+ // Clear existing options
+ planSelect.innerHTML = '';
+
+ // Get plans for the selected service level
+ const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
+
+ // 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;
diff --git a/hub/services/static/js/price-calculator/price-calculator.js b/hub/services/static/js/price-calculator/price-calculator.js
new file mode 100644
index 0000000..ecde2ac
--- /dev/null
+++ b/hub/services/static/js/price-calculator/price-calculator.js
@@ -0,0 +1,252 @@
+/**
+ * Price Calculator - Main orchestrator class
+ * Coordinates all the different managers to provide pricing calculation functionality
+ */
+class PriceCalculator {
+ constructor() {
+ // Initialize managers
+ this.domManager = new DOMManager();
+ this.currentOffering = this.extractOfferingFromURL();
+ 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();
+ }
+
+ // 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 {
+ console.warn('No current offering found, calculator not initialized');
+ }
+ } catch (error) {
+ console.error('Error initializing price calculator:', error);
+ this.uiManager.showError(this.domManager, 'Failed to load pricing information');
+ }
+ }
+
+ // Setup initial UI components
+ setupUI() {
+ // Setup service levels based on available data
+ this.uiManager.setupServiceLevels(this.domManager, this.pricingDataManager);
+
+ // Calculate and set slider maximums
+ this.uiManager.updateSliderMaximums(this.domManager, this.pricingDataManager);
+
+ // Populate plan dropdown
+ this.planManager.populatePlanDropdown(this.domManager);
+
+ // Initialize instances slider
+ this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
+ }
+
+ // 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;
+ this.updatePricing();
+ });
+
+ memoryRange.addEventListener('input', () => {
+ this.domManager.get('memoryValue').textContent = 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();
+ });
+
+ // Service level change listeners
+ const serviceLevelInputs = this.domManager.get('serviceLevelInputs');
+ serviceLevelInputs.forEach(input => {
+ input.addEventListener('change', () => {
+ this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
+ this.planManager.populatePlanDropdown(this.domManager);
+ this.addonManager.updateAddons(this.domManager);
+ 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 default values
+ this.domManager.resetSlidersToDefaults();
+
+ // 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();
+ });
+ }
+
+ // 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]
+ );
+ }
+}
+
+// Export for use in other modules
+window.PriceCalculator = PriceCalculator;
diff --git a/hub/services/static/js/price-calculator/pricing-data-manager.js b/hub/services/static/js/price-calculator/pricing-data-manager.js
new file mode 100644
index 0000000..147086d
--- /dev/null
+++ b/hub/services/static/js/price-calculator/pricing-data-manager.js
@@ -0,0 +1,190 @@
+/**
+ * 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 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: ${response.status} ${response.statusText}`);
+ }
+
+ 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();
+
+ 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 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;
diff --git a/hub/services/static/js/price-calculator/ui-manager.js b/hub/services/static/js/price-calculator/ui-manager.js
new file mode 100644
index 0000000..e1a099a
--- /dev/null
+++ b/hub/services/static/js/price-calculator/ui-manager.js
@@ -0,0 +1,269 @@
+/**
+ * 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 = `${message}`;
+ 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 = `${message}`;
+ 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) {
+ // Update mandatory addons in the managed service includes container
+ const managedServiceIncludesContainer = domManager.get('managedServiceIncludesContainer');
+ if (managedServiceIncludesContainer) {
+ // Clear existing content
+ managedServiceIncludesContainer.innerHTML = '';
+
+ // 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 = `
+ ${addon.name}
+ CHF ${addon.price}
+ `;
+ 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 mb-2';
+ addonRow.innerHTML = `
+ Add-on: ${addon.name}
+ CHF ${addon.price}
+ `;
+ 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"]');
+ }
+
+ // 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 { maxCpus, maxMemory } = pricingDataManager.getSliderMaximums();
+
+ // Set slider maximums with some padding
+ if (maxCpus > 0) {
+ cpuRange.min = "0.25";
+ cpuRange.max = Math.ceil(maxCpus);
+ }
+
+ if (maxMemory > 0) {
+ memoryRange.min = "0.25";
+ memoryRange.max = Math.ceil(maxMemory);
+ }
+
+ // Update display values after changing min/max
+ domManager.updateSliderDisplayValues();
+ }
+
+ // 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;