diff --git a/src/servala/frontend/templates/includes/plan_selection.html b/src/servala/frontend/templates/includes/plan_selection.html index 04e435c..5ca113a 100644 --- a/src/servala/frontend/templates/includes/plan_selection.html +++ b/src/servala/frontend/templates/includes/plan_selection.html @@ -1,139 +1,9 @@ -{% load i18n %} - -
+{% load i18n static %} + +
{% if plan_form %}
@@ -253,208 +123,4 @@
{% trans "No compute plans available for this service offering." %}
{% endif %}
- + diff --git a/src/servala/static/css/plan-selection.css b/src/servala/static/css/plan-selection.css new file mode 100644 index 0000000..8ce099c --- /dev/null +++ b/src/servala/static/css/plan-selection.css @@ -0,0 +1,140 @@ +.plan-selection .plan-dropdown { + position: relative; +} + +.plan-selection .plan-dropdown-toggle { + cursor: pointer; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + background: #fff; +} + +.plan-selection .plan-dropdown-toggle:hover { + border-color: #a1afdf; +} + +.plan-selection .plan-dropdown-toggle:focus { + color: #607080; + background-color: #fff; + border-color: #a1afdf; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25); +} + +.plan-selection .plan-dropdown-toggle[aria-expanded="true"] { + border-color: #a1afdf; + box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom-color: transparent; +} + +.plan-selection .plan-dropdown-toggle .dropdown-arrow { + transition: transform 0.2s ease; +} + +.plan-selection .plan-dropdown-toggle[aria-expanded="true"] .dropdown-arrow { + transform: rotate(180deg); +} + +.plan-selection .plan-dropdown-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1000; + display: none; + margin-top: -1px; + background: #fff; + border: 1px solid #a1afdf; + border-top: none; + border-radius: 0 0 0.375rem 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + max-height: 400px; + overflow-y: auto; +} + +.plan-selection .plan-dropdown-menu.show { + display: block; +} + +.plan-selection .plan-option { + padding: 0.75rem 1rem; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.15s ease; +} + +.plan-selection .plan-option:last-child { + border-bottom: none; +} + +.plan-selection .plan-option:hover, +.plan-selection .plan-option.focused { + background-color: #f8f9fa; +} + +.plan-selection .plan-option.selected { + background-color: #f0f4ff; +} + +.plan-selection .plan-option.selected.focused { + background-color: #e0e8ff; +} + +.plan-selection .plan-content { + padding: 0.75rem 1rem; +} + +.plan-selection .plan-header h6 { + margin-bottom: 0.25rem; + font-size: 1rem; +} + +.plan-selection .badge { + font-size: 0.75rem; + padding: 0.25em 0.5em; +} + +.plan-selection .price-display { + font-size: 1.25rem; + font-weight: 600; +} + +.plan-selection .plan-specs { + margin-top: 0.5rem; +} + +.plan-selection .form-check-input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.plan-selection .storage-card { + margin-top: 0.75rem; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + background: #fff; +} + +.plan-selection .storage-card .plan-content { + background-color: #f8f9fa; + border-radius: 0.375rem; +} + +.plan-selection .storage-card .storage-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: #e9ecef; + border-radius: 0.25rem; + color: #6c757d; +} + +.plan-selection .storage-card .storage-icon .bi::before { + vertical-align: top; +} diff --git a/src/servala/static/js/plan-selection.js b/src/servala/static/js/plan-selection.js new file mode 100644 index 0000000..4b4c014 --- /dev/null +++ b/src/servala/static/js/plan-selection.js @@ -0,0 +1,244 @@ +/** + * Plan Selection Dropdown + * A custom dropdown component for selecting compute plans with keyboard navigation. + */ +(function() { + function initPlanSelection(container) { + const planDropdownToggle = container.querySelector('#plan-dropdown-toggle'); + const planDropdownMenu = container.querySelector('#plan-dropdown-menu'); + const planOptions = Array.from(container.querySelectorAll('.plan-option')); + const radioInputName = container.dataset.radioName; + const radioInputs = container.querySelectorAll('input[name="' + radioInputName + '"]'); + let focusedIndex = -1; + + if (!planDropdownToggle || planOptions.length === 0) { + return; + } + + // Update the display in the toggle button based on selected plan + function updateSelectedDisplay(data) { + const nameEl = container.querySelector('#selected-plan-name'); + const priceEl = container.querySelector('#selected-plan-price'); + const unitEl = container.querySelector('#selected-plan-unit'); + const cpuEl = container.querySelector('#selected-plan-cpu'); + const memoryEl = container.querySelector('#selected-plan-memory'); + const slaBadge = container.querySelector('#selected-plan-sla'); + + if (nameEl) nameEl.textContent = data.planName; + if (priceEl) priceEl.textContent = 'CHF ' + data.planPrice; + if (unitEl) unitEl.textContent = data.planUnit; + if (cpuEl) cpuEl.textContent = data.cpuLimits; + if (memoryEl) memoryEl.textContent = data.memoryLimits; + + if (slaBadge) { + slaBadge.textContent = data.planSlaDisplay; + slaBadge.className = 'badge bg-' + (data.planSla === 'guaranteed' ? 'success' : 'secondary'); + } + } + + // Update CPU/memory fields in the form + function updateFormFields(data) { + const cpuLimit = document.querySelector('input[name="expert-spec.parameters.size.cpu"]'); + const memoryLimit = document.querySelector('input[name="expert-spec.parameters.size.memory"]'); + const cpuRequest = document.querySelector('input[name="expert-spec.parameters.size.requests.cpu"]'); + const memoryRequest = document.querySelector('input[name="expert-spec.parameters.size.requests.memory"]'); + + if (cpuLimit) cpuLimit.value = data.cpuLimits; + if (memoryLimit) memoryLimit.value = data.memoryLimits; + if (cpuRequest) cpuRequest.value = data.cpuRequests; + if (memoryRequest) memoryRequest.value = data.memoryRequests; + } + + // Select a plan by value + function selectPlan(value) { + // Update the hidden radio input + radioInputs.forEach(radio => { + radio.checked = (radio.value === value); + }); + + // Update visual selection + planOptions.forEach(option => { + option.classList.toggle('selected', option.dataset.value === value); + }); + + // Find selected option and update display + const selectedOption = container.querySelector('.plan-option[data-value="' + value + '"]'); + if (selectedOption) { + const data = { + planName: selectedOption.dataset.planName, + planSla: selectedOption.dataset.planSla, + planSlaDisplay: selectedOption.dataset.planSlaDisplay, + planPrice: selectedOption.dataset.planPrice, + planUnit: selectedOption.dataset.planUnit, + cpuLimits: selectedOption.dataset.cpuLimits, + memoryLimits: selectedOption.dataset.memoryLimits, + cpuRequests: selectedOption.dataset.cpuRequests, + memoryRequests: selectedOption.dataset.memoryRequests + }; + updateSelectedDisplay(data); + updateFormFields(data); + } + } + + // Update focused option visually + function updateFocusedOption(newIndex) { + planOptions.forEach(opt => opt.classList.remove('focused')); + if (newIndex >= 0 && newIndex < planOptions.length) { + focusedIndex = newIndex; + planOptions[focusedIndex].classList.add('focused'); + // Scroll into view if needed + planOptions[focusedIndex].scrollIntoView({ block: 'nearest' }); + } + } + + // Get index of currently selected option + function getSelectedIndex() { + return planOptions.findIndex(opt => opt.classList.contains('selected')); + } + + // Open dropdown + function openDropdown() { + planDropdownToggle.setAttribute('aria-expanded', 'true'); + planDropdownMenu.classList.add('show'); + // Set focus to selected option or first option + const selectedIdx = getSelectedIndex(); + updateFocusedOption(selectedIdx >= 0 ? selectedIdx : 0); + } + + // Close dropdown + function closeDropdown() { + planDropdownToggle.setAttribute('aria-expanded', 'false'); + planDropdownMenu.classList.remove('show'); + planOptions.forEach(opt => opt.classList.remove('focused')); + focusedIndex = -1; + } + + // Check if dropdown is open + function isOpen() { + return planDropdownToggle.getAttribute('aria-expanded') === 'true'; + } + + // Toggle dropdown visibility + function toggleDropdown() { + if (isOpen()) { + closeDropdown(); + } else { + openDropdown(); + } + } + + // Event listeners + planDropdownToggle.addEventListener('click', toggleDropdown); + planDropdownToggle.addEventListener('keydown', function(e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (isOpen() && focusedIndex >= 0) { + selectPlan(planOptions[focusedIndex].dataset.value); + closeDropdown(); + } else { + toggleDropdown(); + } + } else if (e.key === 'Escape') { + closeDropdown(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!isOpen()) { + openDropdown(); + } else { + const newIndex = focusedIndex < planOptions.length - 1 ? focusedIndex + 1 : 0; + updateFocusedOption(newIndex); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (!isOpen()) { + openDropdown(); + } else { + const newIndex = focusedIndex > 0 ? focusedIndex - 1 : planOptions.length - 1; + updateFocusedOption(newIndex); + } + } + }); + + planOptions.forEach((option, index) => { + option.addEventListener('click', function() { + selectPlan(this.dataset.value); + closeDropdown(); + planDropdownToggle.focus(); + }); + option.addEventListener('mouseenter', function() { + updateFocusedOption(index); + }); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', function(e) { + if (!planDropdownToggle.contains(e.target) && !planDropdownMenu.contains(e.target)) { + closeDropdown(); + } + }); + + // Initialize with currently checked radio + const checkedRadio = container.querySelector('input[name="' + radioInputName + '"]:checked'); + if (checkedRadio) { + selectPlan(checkedRadio.value); + } + } + + function initStorageCostCalculator(container) { + const pricePerGiB = parseFloat(container.dataset.storagePricePerGib) || 0; + const perHourText = container.dataset.perHourText || 'per hour'; + + if (pricePerGiB === 0) { + return; + } + + function setup() { + const diskInput = document.getElementById('id_custom-spec.parameters.size.disk'); + if (diskInput && !diskInput.dataset.storageListenerAttached) { + diskInput.dataset.storageListenerAttached = 'true'; + diskInput.addEventListener('input', function() { + const sizeGiB = parseFloat(this.value) || 0; + const totalCost = (sizeGiB * pricePerGiB).toFixed(2); + const display = document.getElementById('storage-cost-display'); + if (display && sizeGiB > 0) { + display.innerHTML = ' ' + sizeGiB + ' GiB × CHF ' + pricePerGiB + ' = CHF ' + totalCost + ' ' + perHourText; + } else if (display) { + display.textContent = ''; + } + }); + + // Trigger initial calculation if disk field has a value + if (diskInput.value) { + diskInput.dispatchEvent(new Event('input')); + } + } + } + + // Try to setup immediately (in case form is already loaded) + setup(); + + // Also setup after HTMX swaps the form in + document.body.addEventListener('htmx:afterSwap', function(event) { + if (event.detail.target.id === 'service-form' || event.detail.target.id === 'control-plane-info') { + setup(); + } + }); + } + + // Initialize all plan selection components on the page + function init() { + document.querySelectorAll('.plan-selection').forEach(function(container) { + if (container.dataset.radioName) { + initPlanSelection(container); + initStorageCostCalculator(container); + } + }); + } + + // Run on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();