Files
ladill-servers/resources/js/app.js
T
isaaccladandCursor 942d996f12
Deploy Ladill Servers / deploy (push) Successful in 27s
Wire Ladill domain picker and purchase modal into servers flows.
Use the Domains API for owned domains across panel, account, and order forms, with an embedded iframe purchase modal instead of redirecting to domains.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 18:01:23 +00:00

562 lines
18 KiB
JavaScript

import './bootstrap';
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
import { registerLadillDomainPurchase } from './ladill-domain-purchase';
registerLadillModalHelpers();
registerLadillSearchShortcut();
registerLadillDomainPurchase();
import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse';
Alpine.plugin(collapse);
Alpine.data('notificationDropdown', (config = {}) => ({
open: false,
loading: false,
notifications: [],
unreadCount: 0,
unreadUrl: config.unreadUrl || '/notifications/unread',
markReadUrl: config.markReadUrl || '/notifications/__ID__/read',
markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read',
indexUrl: config.indexUrl || '/notifications',
csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '',
init() {
this.fetchUnread();
setInterval(() => this.fetchUnread(), 60000);
},
async fetchUnread() {
try {
const res = await fetch(this.unreadUrl, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await res.json();
this.notifications = data.notifications || [];
this.unreadCount = data.unread_count || 0;
} catch (e) {
console.error('Failed to fetch notifications', e);
}
},
toggle() {
this.open = !this.open;
if (this.open) {
this.loading = true;
this.fetchUnread().finally(() => { this.loading = false; });
}
},
async markRead(id) {
const url = this.markReadUrl.replace('__ID__', id);
try {
await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
});
this.notifications = this.notifications.filter(n => n.id !== id);
this.unreadCount = Math.max(0, this.unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
},
async markAllRead() {
try {
await fetch(this.markAllReadUrl, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
});
this.notifications = [];
this.unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
},
getIconBg(icon) {
const map = { domain: 'bg-emerald-50', hosting: 'bg-violet-50', email: 'bg-pink-50', billing: 'bg-amber-50', success: 'bg-green-50' };
return map[icon] || 'bg-slate-100';
},
getIconColor(icon) {
const map = { domain: 'text-emerald-600', hosting: 'text-violet-600', email: 'text-pink-600', billing: 'text-amber-600', success: 'text-green-600' };
return map[icon] || 'text-slate-500';
},
}));
// Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button
// which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade.
Alpine.data('afia', (config = {}) => ({
open: false,
input: '',
loading: false,
messages: [
{ role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" },
],
suggestions: config.suggestions || [],
init() {
window.addEventListener('afia-open', () => {
this.open = true;
this.$nextTick(() => this.$refs.input && this.$refs.input.focus());
});
},
close() { this.open = false; },
useSuggestion(s) { this.input = s; this.send(); },
scrollDown() {
this.$nextTick(() => { const el = this.$refs.scroll; if (el) el.scrollTop = el.scrollHeight; });
},
async send() {
const text = this.input.trim();
if (!text || this.loading) return;
const history = this.messages.map((m) => ({ role: m.role, text: m.text }));
this.messages.push({ role: 'user', text });
this.input = '';
this.loading = true;
this.scrollDown();
try {
const res = await fetch(config.chatUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': config.csrf, 'Accept': 'application/json' },
body: JSON.stringify({ message: text, history }),
});
const data = await res.json();
this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' });
} catch (e) {
this.messages.push({ role: 'assistant', text: 'Network error — please try again.' });
}
this.loading = false;
this.scrollDown();
},
}));
Alpine.data('topbarSearch', (config = {}) => ({
query: config.initialQuery || '',
results: Array.isArray(config.initialResults) ? config.initialResults : [],
open: !!config.openOnInit,
loading: false,
active: 0,
_abort: null,
searchUrl: config.searchUrl || '/search',
init() {
if (config.autoFocus) {
this.$nextTick(() => this.$refs.input?.focus());
}
},
onFocus() {
if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true;
},
async search() {
const q = this.query.trim();
if (q.length < 2) { this.results = []; this.open = false; return; }
this.loading = true;
this.open = true;
this.active = 0;
if (this._abort) this._abort.abort();
this._abort = new AbortController();
try {
const res = await fetch(`${this.searchUrl}?q=${encodeURIComponent(q)}`, {
signal: this._abort.signal,
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await res.json();
this.results = data.results || [];
} catch (e) {
if (e.name !== 'AbortError') this.results = [];
} finally {
this.loading = false;
}
},
moveDown() { if (this.active < this.results.length - 1) this.active++; },
moveUp() { if (this.active > 0) this.active--; },
go() {
if (this.results[this.active]) window.location.href = this.results[this.active].url;
},
}));
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;
registerLadillConfirmStore(Alpine);
Alpine.start();