diff --git a/resources/js/app.js b/resources/js/app.js index c63c6c1..6204964 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -180,5 +180,365 @@ Alpine.data('topbarSearch', (config = {}) => ({ }, })); +window.serverOrderForm = (products = {}, billingCycleLabels = {}, initial = {}) => ({ + products, + billingCycleLabels, + steps: [{ label: 'Select Plan' }, { label: 'Configure' }, { label: 'Review' }], + step: Number(initial.step ?? 1), + selectedProductId: initial.selectedProductId ? String(initial.selectedProductId) : '', + selectedProduct: null, + serverName: String(initial.serverName ?? ''), + billingCycle: String(initial.billingCycle ?? 'monthly'), + selections: { + region: '', + image: '', + license: '', + application: '', + default_user: '', + additional_ip: '', + private_networking: '', + storage_type: '', + object_storage: '', + }, + serverPassword: '', + serverPasswordConfirmation: '', + submitting: false, + error: '', + + clone(value) { + return JSON.parse(JSON.stringify(value ?? null)); + }, + + init() { + if (this.selectedProductId) { + this.applyProduct(this.selectedProductId, true); + } + }, + + applyProduct(productId, preserveState = false) { + const product = this.products[String(productId)] || null; + + if (!product) { + return; + } + + this.selectedProductId = String(productId); + this.selectedProduct = this.clone(product); + + const defaults = this.clone(product.defaults || {}); + const initialSelections = preserveState ? this.clone(initial.selections || {}) : {}; + + this.selections = { + ...defaults, + ...initialSelections, + }; + + if (!preserveState) { + this.serverName = ''; + this.billingCycle = 'monthly'; + this.serverPassword = ''; + this.serverPasswordConfirmation = ''; + } + + if (!Object.prototype.hasOwnProperty.call(this.billingCycleLabels, this.billingCycle)) { + this.billingCycle = 'monthly'; + } + + this.syncDefaultUser(); + this.syncLicenseSelection(); + this.error = ''; + }, + + selectProduct(productId) { + this.applyProduct(productId, false); + }, + + nextStep() { + if (this.step === 1 && !this.selectedProductId) { + return; + } + + if (this.step === 2 && !this.serverName.trim()) { + return; + } + + if (this.step < this.steps.length) { + this.step += 1; + } + }, + + catalog(key) { + return this.selectedProduct?.catalog?.[key] || []; + }, + + findOption(options, value) { + return (options || []).find((option) => option.value === value) || null; + }, + + optionAmount(option) { + return Number(option?.prices?.[this.billingCycle] || 0); + }, + + optionLabel(option) { + if (!option) { + return ''; + } + + const amount = this.optionAmount(option); + + return amount > 0 + ? `${option.label} (+${this.formatMoney(amount)})` + : option.label; + }, + + selectionLabel(catalogKey, value) { + const option = this.findOption(this.catalog(catalogKey), value); + + return option?.label || value || '-'; + }, + + currentImage() { + return this.findOption(this.catalog('images'), this.selections.image); + }, + + imageSupportsManagedStack(image = null) { + const currentImage = image || this.currentImage(); + const osFamily = String(currentImage?.os_family || this.selectedProduct?.catalog?.default_image_os_family || 'linux').toLowerCase(); + const imageLabel = String(currentImage?.label || '').toLowerCase(); + const installLater = Boolean(currentImage?.is_install_later); + + if (installLater || osFamily.startsWith('win')) { + return false; + } + + if (['cpanel', 'plesk', 'whm'].some((needle) => imageLabel.includes(needle))) { + return false; + } + + return ['ubuntu 22.04', 'ubuntu 24.04', 'debian 12', 'bookworm', 'almalinux', 'alma linux', 'alma', 'rocky linux', 'rocky', 'fedora', 'opensuse leap', 'open suse leap', 'opensuse', 'leap', 'centos'] + .some((needle) => imageLabel.includes(needle)); + }, + + bundledPanelFromImage(image = null) { + const imageLabel = String((image || this.currentImage())?.label || '').toLowerCase(); + + if (imageLabel.includes('cpanel') || imageLabel.includes('whm')) { + return 'cpanel'; + } + + if (imageLabel.includes('plesk')) { + return 'plesk'; + } + + return null; + }, + + availableLicenseOptions() { + const image = this.currentImage(); + const installLater = Boolean(image?.is_install_later); + const bundledPanel = this.bundledPanelFromImage(image); + + return this.catalog('licenses').filter((option) => { + if (installLater) { + return option.value === 'none'; + } + + if (bundledPanel && option.value === 'none') { + return false; + } + + const optionLicense = String(option.license || '').toLowerCase(); + + if (bundledPanel === 'cpanel' && optionLicense.startsWith('plesk')) { + return false; + } + + if (bundledPanel === 'plesk' && optionLicense.startsWith('cpanel')) { + return false; + } + + if (option.panel !== 'ladill') { + return true; + } + + return this.imageSupportsManagedStack(image); + }); + }, + + currentLicenseOption() { + return this.findOption(this.availableLicenseOptions(), this.selections.license) + || this.findOption(this.catalog('licenses'), this.selections.license); + }, + + currentPanelSnapshot() { + const license = this.currentLicenseOption(); + const image = this.currentImage(); + const managedCandidate = this.imageSupportsManagedStack(image); + + if (license?.panel === 'ladill') { + return { + label: license.label || 'Ladill Server Manager', + primary: managedCandidate + ? 'Ladill Server Manager will provision this server with Ladill-managed controls on the selected supported Linux image.' + : 'Ladill Server Manager is not available for the selected image.', + secondary: managedCandidate + ? 'After provisioning finishes, Server Manager can handle power, status, files, databases, domains, PHP, SSL, cron, and logs.' + : 'Choose a plain supported Linux image without cPanel or Plesk to use Ladill-managed hosting features.', + futureCapabilities: managedCandidate ? ['Files', 'Databases', 'Domains', 'PHP', 'SSL', 'Cron', 'Logs'] : [], + toneClasses: managedCandidate + ? 'border-amber-200 bg-amber-50 text-amber-900' + : 'border-slate-200 bg-slate-50 text-slate-700', + }; + } + + if (license?.license) { + return { + label: license.label || 'Server Panel', + primary: `${license.label} will run inside the server as the primary control panel.`, + secondary: 'Ladill will keep the order and infrastructure lifecycle, but site and service management will happen in the installed panel.', + futureCapabilities: [], + toneClasses: 'border-blue-200 bg-blue-50 text-blue-900', + }; + } + + return { + label: license?.label || 'Remote Login Only', + primary: 'This selection provisions the server without a Ladill-managed or commercial control panel.', + secondary: 'You will manage the machine directly over SSH or RDP until a panel or managed runtime is installed separately.', + futureCapabilities: [], + toneClasses: 'border-gray-200 bg-gray-50 text-gray-700', + }; + }, + + defaultUserOptions() { + const defaultFamily = this.selectedProduct?.catalog?.default_image_os_family || 'linux'; + const osFamily = this.currentImage()?.os_family || defaultFamily; + + return osFamily.startsWith('win') + ? this.catalog('windows_default_users') + : this.catalog('linux_default_users'); + }, + + syncDefaultUser() { + const options = this.defaultUserOptions(); + + if (!options.some((option) => option.value === this.selections.default_user)) { + this.selections.default_user = options[0]?.value || ''; + } + }, + + syncLicenseSelection() { + const options = this.availableLicenseOptions(); + + if (!options.some((option) => option.value === this.selections.license)) { + this.selections.license = options[0]?.value || 'none'; + } + }, + + handleImageChange() { + this.syncDefaultUser(); + this.syncLicenseSelection(); + }, + + basePrice() { + return Number(this.selectedProduct?.prices?.[this.billingCycle] || 0); + }, + + setupFee() { + return Number(this.selectedProduct?.setup_fees?.[this.billingCycle] || 0); + }, + + addOnLineItems() { + const items = []; + const setupFee = this.setupFee(); + + if (setupFee > 0) { + items.push({ + key: 'setup_fee', + label: 'One-time setup fee', + amount: setupFee, + }); + } + + const keys = ['image', 'region', 'license', 'application', 'additional_ip', 'private_networking', 'storage_type', 'object_storage']; + const catalogKeys = { + image: 'images', + region: 'regions', + license: 'licenses', + application: 'applications', + additional_ip: 'additional_ip', + private_networking: 'private_networking', + storage_type: 'storage_types', + object_storage: 'object_storage', + }; + + keys.forEach((key) => { + const option = this.findOption(this.catalog(catalogKeys[key]), this.selections[key]); + const amount = this.optionAmount(option); + + if (amount > 0) { + items.push({ + key, + label: option.label, + amount, + }); + } + }); + + return items; + }, + + currentTotal() { + return this.basePrice() + this.addOnLineItems().reduce((sum, item) => sum + item.amount, 0); + }, + + cycleMonths() { + return { + monthly: 1, + quarterly: 3, + semiannual: 6, + yearly: 12, + }[this.billingCycle] || 1; + }, + + monthlyEquivalent() { + return this.currentTotal() / this.cycleMonths(); + }, + + formatMoney(amount, currency = null) { + if (amount === null || amount === undefined) { + return '-'; + } + + const code = currency || this.selectedProduct?.currency || 'GHS'; + + return `${code} ${Number(amount).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, + + canSubmit() { + return Boolean( + this.selectedProductId + && this.serverName.trim() + && this.serverPassword + && this.serverPassword === this.serverPasswordConfirmation + && this.serverPassword.length >= 8 + ); + }, + + submitOrder() { + if (!this.canSubmit()) { + this.error = 'Complete the required fields and make sure the passwords match.'; + return; + } + + this.submitting = true; + this.error = ''; + this.$refs.form.submit(); + }, +}); + window.Alpine = Alpine; Alpine.start(); diff --git a/resources/views/hosting/partials/server-order-modal.blade.php b/resources/views/hosting/partials/server-order-modal.blade.php index 93f139a..949927b 100644 --- a/resources/views/hosting/partials/server-order-modal.blade.php +++ b/resources/views/hosting/partials/server-order-modal.blade.php @@ -23,7 +23,7 @@ @click.stop>